@qwqweeo123/dsh-wallpaper 1.0.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/client.js ADDED
@@ -0,0 +1,1902 @@
1
+ /**
2
+ * Browser half of @local/dsh-wallpaper.
3
+ *
4
+ * Applies a global wallpaper to the shell frame (frame-transparent +
5
+ * translucent surfaces, the same model as codeg's workspace background) and
6
+ * owns the Settings › 壁纸 page: enable/opacity, the current wallpaper, local
7
+ * uploads, and the wallhaven.cc marketplace rendered inline. All network and
8
+ * disk access goes through the bundle's Host routes under
9
+ * `/dsh-wallpaper/api/*`; this file never touches the upstream CDN directly.
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: '@local/dsh-wallpaper',
13
+ factory(require) {
14
+ const React = require('react')
15
+ const { useCallback, useEffect, useRef, useState, useSyncExternalStore } = React
16
+ const h = React.createElement
17
+
18
+ const NS = 'wallpaper'
19
+ const API = '/dsh-wallpaper'
20
+ // DSH's client module system claims every untagged <style> for the next
21
+ // activating plugin and deletes it with that entry — which wipes this
22
+ // plugin's CSS. Tagging the element keeps it ours.
23
+ const STYLE_ATTRS = { 'data-plugin': '@local/dsh-wallpaper' }
24
+
25
+ // ─── Locale dictionaries (flat dotted keys, {name} interpolation) ───────
26
+
27
+ const zh = {
28
+ 'wallpaper.title': '壁纸设置',
29
+ 'wallpaper.enable': '启用壁纸',
30
+ 'wallpaper.opacity': '面板不透明度',
31
+ 'wallpaper.fill': '填充方式',
32
+ 'wallpaper.fill.cover': '覆盖',
33
+ 'wallpaper.fill.contain': '包含',
34
+ 'wallpaper.fill.center': '居中',
35
+ 'wallpaper.fill.tile': '平铺',
36
+ 'wallpaper.mask': '遮罩不透明度',
37
+ 'wallpaper.blur': '图片模糊',
38
+ 'wallpaper.panel': '面板不透明度',
39
+ 'settings.fillHint': '图片铺满窗口的方式:覆盖裁切、包含留白、居中原始大小、平铺重复。',
40
+ 'settings.maskHint': '数值越高,图片越向主题背景色淡化,文字对比越清晰。',
41
+ 'settings.blurHint': '模糊背景图片本身(0 表示不模糊),不影响面板与文字。',
42
+ 'settings.panelHint': '侧栏、面板与标签栏的不透明程度。越低,透出的图片越多。',
43
+ 'wallpaper.upload': '上传本地图片…',
44
+ 'wallpaper.remove': '移除壁纸',
45
+ 'wallpaper.searchPlaceholder': '搜索壁纸…',
46
+ 'wallpaper.applied': '使用中',
47
+ 'wallpaper.unavailable': '不可用',
48
+ 'wallpaper.appliedToast': '壁纸已应用',
49
+ 'wallpaper.downloadFailed': '下载失败',
50
+ 'wallpaper.tooLarge': '超过上限({actual} / 上限 {limit})',
51
+ 'wallpaper.empty': '没有找到壁纸',
52
+ 'wallpaper.pageInfo': '第 {page} / {lastPage} 页',
53
+ 'wallpaper.prevPage': '上一页',
54
+ 'wallpaper.nextPage': '下一页',
55
+ 'wallpaper.refresh': '刷新',
56
+ 'wallpaper.credit': '来自 wallhaven.cc',
57
+ 'wallpaper.cardLabel': '壁纸 {id}',
58
+ 'wallpaper.categories.all': '全部',
59
+ 'wallpaper.categories.general': '常规',
60
+ 'wallpaper.categories.anime': '动漫',
61
+ 'wallpaper.categories.people': '人物',
62
+ 'wallpaper.market': '壁纸市场',
63
+ 'wallpaper.marketHint': '点击卡片即可下载并应用为全局背景',
64
+ 'wallpaper.current': '当前壁纸',
65
+ 'wallpaper.none': '尚未设置壁纸',
66
+ 'wallpaper.localImage': '本地图片',
67
+ 'settings.nav': '壁纸',
68
+ 'settings.wallpaper': '壁纸',
69
+ 'settings.wallpaperHint': '为整个界面应用全局背景',
70
+ 'settings.opacityHint': '面板在壁纸上方的不透明度,越低壁纸越明显',
71
+ 'settings.enabledHint': '关闭后界面恢复默认背景,壁纸文件保留',
72
+ }
73
+
74
+ const en = {
75
+ 'wallpaper.title': 'Wallpaper',
76
+ 'wallpaper.enable': 'Enable wallpaper',
77
+ 'wallpaper.opacity': 'Panel opacity',
78
+ 'wallpaper.fill': 'Fill mode',
79
+ 'wallpaper.fill.cover': 'Cover',
80
+ 'wallpaper.fill.contain': 'Contain',
81
+ 'wallpaper.fill.center': 'Center',
82
+ 'wallpaper.fill.tile': 'Tile',
83
+ 'wallpaper.mask': 'Mask opacity',
84
+ 'wallpaper.blur': 'Image blur',
85
+ 'wallpaper.panel': 'Panel opacity',
86
+ 'settings.fillHint': 'How the image fills the window: cover crops, contain letterboxes, center keeps its size, tile repeats.',
87
+ 'settings.maskHint': 'Higher values fade the image toward the theme background and keep text contrast.',
88
+ 'settings.blurHint': 'Blurs the background image itself (0 = sharp). Panels and text are untouched.',
89
+ 'settings.panelHint': 'Opacity of the sidebar, panels and tab strips. Lower values reveal more of the image.',
90
+ 'wallpaper.upload': 'Upload a local image…',
91
+ 'wallpaper.remove': 'Remove wallpaper',
92
+ 'wallpaper.searchPlaceholder': 'Search wallpapers…',
93
+ 'wallpaper.applied': 'In use',
94
+ 'wallpaper.unavailable': 'Unavailable',
95
+ 'wallpaper.appliedToast': 'Wallpaper applied',
96
+ 'wallpaper.downloadFailed': 'Download failed',
97
+ 'wallpaper.tooLarge': 'Over the limit ({actual} / limit {limit})',
98
+ 'wallpaper.empty': 'No wallpapers found',
99
+ 'wallpaper.pageInfo': 'Page {page} of {lastPage}',
100
+ 'wallpaper.prevPage': 'Prev',
101
+ 'wallpaper.nextPage': 'Next',
102
+ 'wallpaper.refresh': 'Refresh',
103
+ 'wallpaper.credit': 'from wallhaven.cc',
104
+ 'wallpaper.cardLabel': 'Wallpaper {id}',
105
+ 'wallpaper.categories.all': 'All',
106
+ 'wallpaper.categories.general': 'General',
107
+ 'wallpaper.categories.anime': 'Anime',
108
+ 'wallpaper.categories.people': 'People',
109
+ 'wallpaper.market': 'Wallpaper marketplace',
110
+ 'wallpaper.marketHint': 'Click a card to download and apply it as the global background',
111
+ 'wallpaper.current': 'Current wallpaper',
112
+ 'wallpaper.none': 'No wallpaper set',
113
+ 'wallpaper.localImage': 'Local image',
114
+ 'settings.nav': 'Wallpaper',
115
+ 'settings.wallpaper': 'Wallpaper',
116
+ 'settings.wallpaperHint': 'Apply a global background to the whole interface',
117
+ 'settings.opacityHint': 'Opacity of panels above the wallpaper; lower shows more of it',
118
+ 'settings.enabledHint': 'Turning this off restores the default background; the file stays',
119
+ }
120
+
121
+ // ─── Store ──────────────────────────────────────────────────────────────
122
+
123
+ function createStore() {
124
+ const listeners = new Set()
125
+ const state = {
126
+ loaded: false,
127
+ config: { wallpaperEnabled: false, wallpaperOpacity: 0.85, wallpaperSourceUrl: null },
128
+ wallpaperVersion: 'none',
129
+ /** Fill mode / mask / blur / panel opacity — shared by layer + section. */
130
+ display: readWallpaperSettings(),
131
+ }
132
+ let snapshot = { ...state }
133
+ const emit = () => {
134
+ snapshot = { ...state }
135
+ for (const fn of listeners) fn()
136
+ }
137
+ return {
138
+ subscribe(fn) {
139
+ listeners.add(fn)
140
+ return () => listeners.delete(fn)
141
+ },
142
+ getSnapshot() {
143
+ return snapshot
144
+ },
145
+ setLoaded(config) {
146
+ state.loaded = true
147
+ state.config = config
148
+ emit()
149
+ },
150
+ setConfig(config) {
151
+ state.config = config
152
+ emit()
153
+ },
154
+ setWallpaperVersion(version) {
155
+ if (state.wallpaperVersion !== version) {
156
+ state.wallpaperVersion = version
157
+ emit()
158
+ }
159
+ },
160
+ setDisplay(patch) {
161
+ const next = { ...state.display, ...patch }
162
+ state.display = next
163
+ try {
164
+ window.localStorage.setItem(WP_SETTINGS_KEY, JSON.stringify(next))
165
+ } catch {
166
+ /* private mode: settings simply do not persist */
167
+ }
168
+ emit()
169
+ },
170
+ }
171
+ }
172
+
173
+ function useStore(store, selector) {
174
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
175
+ return selector(snapshot)
176
+ }
177
+
178
+ // ─── API helpers ────────────────────────────────────────────────────────
179
+
180
+ async function apiJson(path, options = {}) {
181
+ const res = await fetch(`${API}${path}`, {
182
+ headers: options.body ? { 'content-type': 'application/json' } : undefined,
183
+ ...options,
184
+ })
185
+ const data = await res.json().catch(() => null)
186
+ if (!res.ok || !data || data.ok !== true) {
187
+ const message = data?.error?.message || `HTTP ${res.status}`
188
+ const err = new Error(message)
189
+ err.code = data?.error?.code
190
+ throw err
191
+ }
192
+ return data
193
+ }
194
+
195
+ async function apiBlobUrl(path) {
196
+ const res = await fetch(`${API}${path}`)
197
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
198
+ const blob = await res.blob()
199
+ return URL.createObjectURL(blob)
200
+ }
201
+
202
+ /** Per-page cache of proxied thumbnail blob URLs. */
203
+ function createAssetCache() {
204
+ const cache = new Map()
205
+ return {
206
+ load(url) {
207
+ let entry = cache.get(url)
208
+ if (!entry) {
209
+ entry = apiBlobUrl(url).catch((err) => {
210
+ cache.delete(url)
211
+ throw err
212
+ })
213
+ cache.set(url, entry)
214
+ }
215
+ return entry
216
+ },
217
+ dispose() {
218
+ for (const promise of cache.values()) {
219
+ promise.then((url) => URL.revokeObjectURL(url)).catch(() => {})
220
+ }
221
+ cache.clear()
222
+ },
223
+ }
224
+ }
225
+
226
+ function useProxiedAsset(cache, apiPath) {
227
+ const [state, setState] = useState({ src: null, failed: false, key: null })
228
+ useEffect(() => {
229
+ if (!apiPath) return
230
+ let cancelled = false
231
+ cache
232
+ .load(apiPath)
233
+ .then((url) => {
234
+ if (!cancelled) setState({ src: url, failed: false, key: apiPath })
235
+ })
236
+ .catch(() => {
237
+ if (!cancelled) setState({ src: null, failed: true, key: apiPath })
238
+ })
239
+ return () => {
240
+ cancelled = true
241
+ }
242
+ }, [apiPath, cache])
243
+ if (!apiPath) return { src: null, loading: false, failed: false }
244
+ if (state.key === apiPath) return { src: state.src, loading: false, failed: state.failed }
245
+ return { src: null, loading: true, failed: false }
246
+ }
247
+
248
+ // ─── Shared UI atoms (theme tokens only) ────────────────────────────────
249
+
250
+ const styles = `
251
+ .dshwp-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-button-tool-bar-fill); color: var(--dsw-alias-label-primary); font-size: 12px; cursor: pointer; }
252
+ .dshwp-btn:hover { background: var(--dsw-alias-button-tool-bar-hover); }
253
+ .dshwp-btn:disabled { opacity: 0.5; cursor: default; }
254
+ .dshwp-btn-primary { background: var(--dsw-alias-button-primary-fill); border-color: transparent; color: var(--dsw-alias-label-primary-foreground); }
255
+ .dshwp-btn-primary:hover { background: var(--dsw-alias-button-primary-hover); }
256
+ .dshwp-btn-sm { padding: 3px 9px; font-size: 11px; }
257
+ .dshwp-muted { color: var(--dsw-alias-label-tertiary); font-size: 11px; }
258
+ .dshwp-error { color: var(--dsw-alias-label-error); font-size: 12px; }
259
+ .dshwp-ok { color: var(--dsw-alias-state-success-primary); font-size: 12px; }
260
+ .dshwp-spinner { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--dsw-alias-border-l2); border-top-color: var(--dsw-alias-brand-primary); animation: dshwp-spin 0.8s linear infinite; }
261
+ @keyframes dshwp-spin { to { transform: rotate(360deg); } }
262
+ .dshwp-range { accent-color: var(--dsw-alias-brand-primary); }
263
+ .dshwp-switch { position: relative; width: 32px; height: 18px; border-radius: 9px; border: 0; background: var(--dsw-alias-border-l2); cursor: pointer; transition: background 0.15s; padding: 0; }
264
+ .dshwp-switch[data-on='true'] { background: var(--dsw-alias-brand-primary); }
265
+ .dshwp-switch::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--dsw-alias-switch-thumb, #fff); transition: left 0.15s; }
266
+ .dshwp-switch[data-on='true']::after { left: 16px; }
267
+ /* Settings page: host-consistent section, groups, rows and market grid. */
268
+ .dshwp-section { display: flex; flex-direction: column; gap: 24px; width: 100%; max-width: 860px; color: var(--dsw-alias-label-primary); }
269
+ .dshwp-group { display: flex; flex-direction: column; }
270
+ .dshwp-group-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; }
271
+ .dshwp-group-title { margin: 0; font-size: 14px; font-weight: 500; line-height: 22px; }
272
+ .dshwp-intro { margin: 0 0 10px; color: var(--dsw-alias-label-secondary); font-size: 13px; line-height: 20px; }
273
+ .dshwp-srow { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 14px 0; border-bottom: .5px solid var(--dsw-alias-border-l2); }
274
+ .dshwp-srow[data-last='true'] { border-bottom: none; }
275
+ .dshwp-srow-title { font-size: 14px; line-height: 20px; }
276
+ .dshwp-srow-desc { margin-top: 4px; font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-secondary); }
277
+ .dshwp-srow-control { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
278
+ .dshwp-toolbar32 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
279
+ .dshwp-input32 { height: 32px; padding: 0 10px; border: .5px solid var(--dsw-alias-border-l4); border-radius: var(--dsw-radius-md, 8px); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-size: 13px; outline: none; }
280
+ .dshwp-input32:focus { border-color: var(--dsw-alias-brand-primary); }
281
+ .dshwp-chip { height: 28px; padding: 0 10px; border: .5px solid var(--dsw-alias-border-l4); border-radius: 14px; background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-secondary); font-size: 12.5px; cursor: pointer; }
282
+ .dshwp-chip:hover { color: var(--dsw-alias-label-primary); }
283
+ .dshwp-chip[data-active='true'] { border-color: var(--dsw-alias-brand-primary); background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); }
284
+ .dshwp-btn32 { display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 12px; border: .5px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-md, 8px); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 20px; cursor: pointer; }
285
+ .dshwp-btn32:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-3); }
286
+ .dshwp-btn32:disabled { opacity: .6; cursor: default; }
287
+ .dshwp-btn32-primary { border-color: transparent; background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); }
288
+ .dshwp-btn32-primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }
289
+ .dshwp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(178px, 1fr)); gap: 10px; }
290
+ .dshwp-card { position: relative; aspect-ratio: 16/10; border-radius: var(--dsw-radius-lg, 12px); border: .5px solid var(--dsw-alias-border-l3); background: var(--dsw-alias-bg-layer-2); overflow: hidden; cursor: pointer; padding: 0; }
291
+ .dshwp-card:disabled { cursor: default; }
292
+ .dshwp-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
293
+ .dshwp-card-empty { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
294
+ .dshwp-tag { position: absolute; padding: 1px 6px; border-radius: 4px; font-size: 10px; line-height: 16px; background: rgb(0 0 0 / 55%); color: #fff; }
295
+ .dshwp-shade { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgb(0 0 0 / 35%); opacity: 0; transition: opacity 0.12s; color: #fff; }
296
+ .dshwp-card:hover:not(:disabled) .dshwp-shade { opacity: 1; }
297
+ .dshwp-pager { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 12px; }
298
+ .dshwp-current { display: flex; align-items: center; gap: 12px; }
299
+ .dshwp-current-thumb { width: 104px; height: 64px; flex: none; border: .5px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-md, 8px); overflow: hidden; background: var(--dsw-alias-bg-layer-3); }
300
+ .dshwp-current-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
301
+ .dshwp-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 22px; border: .5px dashed var(--dsw-alias-border-l2); border-radius: var(--dsw-radius-lg, 12px); color: var(--dsw-alias-label-tertiary); font-size: 13px; line-height: 20px; }
302
+ `
303
+
304
+ function Switch({ on, onChange, label }) {
305
+ return h('button', {
306
+ type: 'button',
307
+ className: 'dshwp-switch',
308
+ role: 'switch',
309
+ 'aria-checked': on ? 'true' : 'false',
310
+ 'aria-label': label,
311
+ 'data-on': on ? 'true' : 'false',
312
+ onClick: () => onChange(!on),
313
+ })
314
+ }
315
+
316
+ function Spinner() {
317
+ return h('span', { className: 'dshwp-spinner', role: 'status' })
318
+ }
319
+
320
+ function ImageIcon({ size = 16 }) {
321
+ return h(
322
+ 'svg',
323
+ { viewBox: '0 0 64 64', width: size, height: size, 'aria-hidden': true },
324
+ h('rect', { x: 6, y: 10, width: 52, height: 40, rx: 5, fill: 'none', stroke: 'currentColor', strokeWidth: 5 }),
325
+ h('circle', { cx: 22, cy: 24, r: 5, fill: 'currentColor' }),
326
+ h('path', { d: 'M8 44l14-14 10 10 9-11 15 15v6H8z', fill: 'currentColor' })
327
+ )
328
+ }
329
+
330
+ function SettingsRow({ title, description, last, children }) {
331
+ return h(
332
+ 'div',
333
+ { className: 'dshwp-srow', 'data-last': last ? 'true' : undefined },
334
+ h(
335
+ 'div',
336
+ null,
337
+ h('div', { className: 'dshwp-srow-title' }, title),
338
+ description ? h('div', { className: 'dshwp-srow-desc' }, description) : null
339
+ ),
340
+ h('div', { className: 'dshwp-srow-control' }, children)
341
+ )
342
+ }
343
+
344
+ /** Keeps one crashing child from tearing down the styles and the rest. */
345
+ class Boundary extends React.Component {
346
+ constructor(props) {
347
+ super(props)
348
+ this.state = { failed: false }
349
+ }
350
+ static getDerivedStateFromError() {
351
+ return { failed: true }
352
+ }
353
+ componentDidCatch(error) {
354
+ console.error('dsh-wallpaper: part crashed', error)
355
+ }
356
+ render() {
357
+ if (this.state.failed) return null
358
+ return this.props.children
359
+ }
360
+ }
361
+
362
+ // ─── Marketplace ────────────────────────────────────────────────────────
363
+
364
+ const SEARCH_DEBOUNCE = 300
365
+ const MAX_WP_BYTES_CLIENT = 16 * 1024 * 1024
366
+ const MAX_WP_PIXELS_CLIENT = 40_000_000
367
+ const WP_CATEGORIES = ['all', 'general', 'anime', 'people']
368
+
369
+ function wallpaperBlocker(w) {
370
+ if (w.fileSizeBytes > MAX_WP_BYTES_CLIENT) return 'tooManyBytes'
371
+ if (w.width * w.height > MAX_WP_PIXELS_CLIENT) return 'tooManyPixels'
372
+ return null
373
+ }
374
+
375
+ function formatBytes(bytes) {
376
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
377
+ }
378
+
379
+ function formatPixels(pixels) {
380
+ return `${(pixels / 1_000_000).toFixed(1)} MP`
381
+ }
382
+
383
+ function WallpaperCard({ wallpaper, t, assetCache, applied, downloading, busy, onApply }) {
384
+ const thumb = useProxiedAsset(
385
+ assetCache,
386
+ `/api/market/asset?url=${encodeURIComponent(wallpaper.thumbUrl)}`
387
+ )
388
+ const blocker = wallpaperBlocker(wallpaper)
389
+ const blockerHint =
390
+ blocker === 'tooManyBytes'
391
+ ? t('wallpaper.tooLarge', { actual: formatBytes(wallpaper.fileSizeBytes), limit: formatBytes(MAX_WP_BYTES_CLIENT) })
392
+ : blocker === 'tooManyPixels'
393
+ ? t('wallpaper.tooLarge', {
394
+ actual: formatPixels(wallpaper.width * wallpaper.height),
395
+ limit: formatPixels(MAX_WP_PIXELS_CLIENT),
396
+ })
397
+ : null
398
+ const resolution = wallpaper.width > 0 && wallpaper.height > 0 ? `${wallpaper.width}×${wallpaper.height}` : ''
399
+ return h(
400
+ 'button',
401
+ {
402
+ type: 'button',
403
+ className: 'dshwp-card',
404
+ disabled: busy || blocker !== null,
405
+ style: blocker ? { opacity: 0.5 } : undefined,
406
+ title: blockerHint || resolution || wallpaper.id,
407
+ 'aria-label': [t('wallpaper.cardLabel', { id: wallpaper.id }), resolution, applied ? t('wallpaper.applied') : null, blockerHint]
408
+ .filter(Boolean)
409
+ .join(' · '),
410
+ onClick: () => onApply(wallpaper),
411
+ },
412
+ thumb.src
413
+ ? h('img', { src: thumb.src, alt: '', loading: 'lazy' })
414
+ : h('span', { className: 'dshwp-card-empty' }, thumb.failed ? '×' : h(Spinner)),
415
+ (blocker === 'tooManyBytes' ? formatBytes(wallpaper.fileSizeBytes) : resolution)
416
+ ? h(
417
+ 'span',
418
+ { className: 'dshwp-tag', style: { left: 6, bottom: 6 } },
419
+ blocker === 'tooManyBytes' ? formatBytes(wallpaper.fileSizeBytes) : resolution
420
+ )
421
+ : null,
422
+ applied
423
+ ? h('span', { className: 'dshwp-tag', style: { right: 6, top: 6, background: 'var(--dsw-alias-state-success-primary)' } }, t('wallpaper.applied'))
424
+ : blocker
425
+ ? h('span', { className: 'dshwp-tag', style: { right: 6, top: 6 } }, t('wallpaper.unavailable'))
426
+ : null,
427
+ !blocker
428
+ ? h(
429
+ 'span',
430
+ { className: 'dshwp-shade', style: downloading ? { opacity: 1 } : undefined },
431
+ downloading ? h(Spinner) : '↓'
432
+ )
433
+ : null
434
+ )
435
+ }
436
+
437
+ /**
438
+ * The wallhaven.cc market, inline in the settings page (and reused wherever
439
+ * else the surface is needed). Single-flight downloads: two concurrent
440
+ * writes would race on the same background file.
441
+ */
442
+ function WallpaperMarket({ store, t }) {
443
+ const assetCacheRef = useRef(null)
444
+ if (!assetCacheRef.current) assetCacheRef.current = createAssetCache()
445
+ const assetCache = assetCacheRef.current
446
+ useEffect(() => () => assetCache.dispose(), [assetCache])
447
+
448
+ const config = useStore(store, (s) => s.config)
449
+ const [searchInput, setSearchInput] = useState('')
450
+ const [query, setQuery] = useState('')
451
+ const [category, setCategory] = useState('all')
452
+ const [page, setPage] = useState(1)
453
+ const [shownPage, setShownPage] = useState(1)
454
+ const [items, setItems] = useState([])
455
+ const [lastPage, setLastPage] = useState(1)
456
+ const [loading, setLoading] = useState(false)
457
+ const [error, setError] = useState(null)
458
+ const [notice, setNotice] = useState(null)
459
+ const [downloadingId, setDownloadingId] = useState(null)
460
+ const downloadingRef = useRef(null)
461
+ const seqRef = useRef(0)
462
+ const committedQuery = useRef('')
463
+
464
+ useEffect(() => {
465
+ const handle = setTimeout(() => {
466
+ const next = searchInput.trim()
467
+ if (next === committedQuery.current) return
468
+ committedQuery.current = next
469
+ setQuery(next)
470
+ setPage(1)
471
+ }, SEARCH_DEBOUNCE)
472
+ return () => clearTimeout(handle)
473
+ }, [searchInput])
474
+
475
+ const load = useCallback(async (q, c, p) => {
476
+ const seq = ++seqRef.current
477
+ setLoading(true)
478
+ setError(null)
479
+ setShownPage(p)
480
+ try {
481
+ const params = new URLSearchParams({ query: q, category: c, page: String(p) })
482
+ const data = await apiJson(`/api/market/search?${params}`)
483
+ if (seq !== seqRef.current) return
484
+ setItems(data.items)
485
+ setLastPage(data.lastPage)
486
+ setShownPage(data.page)
487
+ // Upstream clamps out-of-range pages; pull the cursor back (never forward).
488
+ if (data.page < p) setPage(data.page)
489
+ } catch (err) {
490
+ if (seq !== seqRef.current) return
491
+ setItems([])
492
+ setLastPage(1)
493
+ setError(err.message)
494
+ } finally {
495
+ if (seq === seqRef.current) setLoading(false)
496
+ }
497
+ }, [])
498
+
499
+ useEffect(() => {
500
+ void load(query, category, page)
501
+ }, [query, category, page, load])
502
+
503
+ const onCardApply = async (wallpaper) => {
504
+ if (downloadingRef.current !== null) return
505
+ downloadingRef.current = wallpaper.id
506
+ setDownloadingId(wallpaper.id)
507
+ setNotice(null)
508
+ setError(null)
509
+ try {
510
+ await apiJson('/api/market/download', {
511
+ method: 'POST',
512
+ body: JSON.stringify({ url: wallpaper.fullUrl, sourceUrl: wallpaper.sourceUrl }),
513
+ })
514
+ const data = await apiJson('/api/config', {
515
+ method: 'PUT',
516
+ body: JSON.stringify({ wallpaperEnabled: true, wallpaperSourceUrl: wallpaper.sourceUrl }),
517
+ })
518
+ store.setConfig(data.config)
519
+ const v = await apiJson('/api/version')
520
+ store.setWallpaperVersion(v.version)
521
+ setNotice(t('wallpaper.appliedToast'))
522
+ } catch (err) {
523
+ setError(`${t('wallpaper.downloadFailed')}: ${err.message}`)
524
+ } finally {
525
+ downloadingRef.current = null
526
+ setDownloadingId(null)
527
+ }
528
+ }
529
+
530
+ return h(
531
+ 'div',
532
+ { className: 'dshwp-group' },
533
+ h(
534
+ 'div',
535
+ { className: 'dshwp-group-head' },
536
+ h('h3', { className: 'dshwp-group-title' }, t('wallpaper.market')),
537
+ h('span', { className: 'dshwp-muted' },
538
+ `${t('wallpaper.credit')}${loading ? '' : ` · ${t('wallpaper.pageInfo', { page: shownPage, lastPage })}`}`
539
+ )
540
+ ),
541
+ h('p', { className: 'dshwp-intro' }, t('wallpaper.marketHint')),
542
+ h(
543
+ 'div',
544
+ { className: 'dshwp-toolbar32' },
545
+ h('input', {
546
+ className: 'dshwp-input32',
547
+ style: { width: 220 },
548
+ value: searchInput,
549
+ placeholder: t('wallpaper.searchPlaceholder'),
550
+ 'aria-label': t('wallpaper.searchPlaceholder'),
551
+ onChange: (event) => setSearchInput(event.target.value),
552
+ }),
553
+ WP_CATEGORIES.map((c) =>
554
+ h(
555
+ 'button',
556
+ {
557
+ key: c,
558
+ type: 'button',
559
+ className: 'dshwp-chip',
560
+ 'data-active': category === c ? 'true' : undefined,
561
+ onClick: () => {
562
+ setCategory(c)
563
+ setPage(1)
564
+ },
565
+ },
566
+ t(`wallpaper.categories.${c}`)
567
+ )
568
+ ),
569
+ h(
570
+ 'button',
571
+ { type: 'button', className: 'dshwp-btn32', disabled: loading, onClick: () => void load(query, category, page) },
572
+ loading ? h(Spinner) : t('wallpaper.refresh')
573
+ )
574
+ ),
575
+ error ? h('p', { className: 'dshwp-error' }, error) : null,
576
+ notice ? h('p', { className: 'dshwp-ok' }, notice) : null,
577
+ loading && items.length === 0
578
+ ? h('div', { style: { display: 'flex', justifyContent: 'center', padding: 40 } }, h(Spinner))
579
+ : items.length === 0 && !error
580
+ ? h('div', { className: 'dshwp-empty' }, h(ImageIcon, { size: 24 }), h('span', null, t('wallpaper.empty')))
581
+ : h(
582
+ 'div',
583
+ { className: 'dshwp-grid', style: loading ? { opacity: 0.6 } : undefined },
584
+ items.map((w) =>
585
+ h(WallpaperCard, {
586
+ key: w.id,
587
+ wallpaper: w,
588
+ t,
589
+ assetCache,
590
+ applied: config.wallpaperSourceUrl === w.sourceUrl,
591
+ downloading: downloadingId === w.id,
592
+ busy: downloadingId !== null,
593
+ onApply: (item) => void onCardApply(item),
594
+ })
595
+ )
596
+ ),
597
+ h(
598
+ 'div',
599
+ { className: 'dshwp-pager' },
600
+ h('span', { className: 'dshwp-muted' }, t('wallpaper.pageInfo', { page: shownPage, lastPage })),
601
+ h(
602
+ 'div',
603
+ { style: { display: 'flex', gap: 8 } },
604
+ h(
605
+ 'button',
606
+ {
607
+ type: 'button',
608
+ className: 'dshwp-btn32',
609
+ disabled: shownPage <= 1 || loading,
610
+ onClick: () => setPage(Math.max(1, shownPage - 1)),
611
+ },
612
+ t('wallpaper.prevPage')
613
+ ),
614
+ h(
615
+ 'button',
616
+ {
617
+ type: 'button',
618
+ className: 'dshwp-btn32',
619
+ disabled: shownPage >= lastPage || loading,
620
+ onClick: () => setPage(shownPage + 1),
621
+ },
622
+ t('wallpaper.nextPage')
623
+ )
624
+ )
625
+ )
626
+ )
627
+ }
628
+
629
+ // ─── Display settings (localStorage, codeg's model) ─────────────────────
630
+ //
631
+ // The image bytes belong to the Host, but the cheap display knobs stay in
632
+ // the browser: dragging a slider repaints instantly with no round trip, and
633
+ // the Host schema never has to grow for them. Defaults mirror codeg.
634
+
635
+ const WP_SETTINGS_KEY = 'dsh.wallpaper.display'
636
+ const WP_FILL_MODES = ['cover', 'contain', 'center', 'tile']
637
+ const WP_FILL_STYLE = {
638
+ cover: { size: 'cover', repeat: 'no-repeat' },
639
+ contain: { size: 'contain', repeat: 'no-repeat' },
640
+ center: { size: 'auto', repeat: 'no-repeat' },
641
+ tile: { size: 'auto', repeat: 'repeat' },
642
+ }
643
+ const WP_DEFAULTS = { fillMode: 'cover', maskOpacity: 0.82, imageBlur: 0, panelOpacity: 0.3 }
644
+ const WP_RANGE = {
645
+ maskOpacity: { min: 0, max: 0.99, step: 0.01 },
646
+ imageBlur: { min: 0, max: 24, step: 1 },
647
+ panelOpacity: { min: 0, max: 1, step: 0.05 },
648
+ }
649
+ /**
650
+ * Frost for the chrome that keeps a glass look. codeg uses 8px for every
651
+ * structural surface; the old 20px on the sidebar read as mush over a busy
652
+ * image, so the sidebar and the top bar share this value.
653
+ */
654
+ const WP_FROST = 'blur(8px) saturate(140%)'
655
+
656
+ function clampSetting(key, value) {
657
+ const num = Number(value)
658
+ if (!Number.isFinite(num)) return WP_DEFAULTS[key]
659
+ const range = WP_RANGE[key]
660
+ return Math.min(range.max, Math.max(range.min, num))
661
+ }
662
+
663
+ function readWallpaperSettings() {
664
+ try {
665
+ const raw = window.localStorage.getItem(WP_SETTINGS_KEY)
666
+ if (!raw) return { ...WP_DEFAULTS }
667
+ const parsed = JSON.parse(raw)
668
+ return {
669
+ fillMode: WP_FILL_MODES.includes(parsed?.fillMode) ? parsed.fillMode : WP_DEFAULTS.fillMode,
670
+ maskOpacity: clampSetting('maskOpacity', parsed?.maskOpacity ?? WP_DEFAULTS.maskOpacity),
671
+ imageBlur: clampSetting('imageBlur', parsed?.imageBlur ?? WP_DEFAULTS.imageBlur),
672
+ panelOpacity: clampSetting('panelOpacity', parsed?.panelOpacity ?? WP_DEFAULTS.panelOpacity),
673
+ }
674
+ } catch {
675
+ return { ...WP_DEFAULTS }
676
+ }
677
+ }
678
+
679
+ // ─── Wallpaper layer (the global background itself) ─────────────────────
680
+
681
+ /** Rewrite a computed colour literal with a different alpha. */
682
+ function withAlpha(css, alpha) {
683
+ if (/\/\s*[\d.]+%?\s*\)\s*$/.test(css)) return css.replace(/\/\s*[\d.]+%?\s*\)\s*$/, `/ ${alpha})`)
684
+ const rgb = css.match(/^rgba?\(([^)]+)\)$/i)
685
+ if (rgb) {
686
+ const parts = rgb[1].split(/[,/\s]+/).filter(Boolean).slice(0, 3).join(', ')
687
+ return `rgba(${parts}, ${alpha})`
688
+ }
689
+ return css
690
+ }
691
+
692
+ /**
693
+ * Alpha of a computed CSS color, 1 when opaque. Handles `rgb()/rgba()` and
694
+ * the `color(srgb ... / a)` form Chrome returns for `color-mix()`.
695
+ */
696
+ function colorAlpha(css) {
697
+ if (!css) return 0
698
+ if (css === 'transparent') return 0
699
+ const rgb = css.match(/rgba?\(([^)]+)\)/i)
700
+ if (rgb) {
701
+ const parts = rgb[1].split(/[,/\s]+/).filter((p) => p !== '')
702
+ if (parts.length >= 4) {
703
+ const v = Number(parts[3])
704
+ return Number.isFinite(v) ? v : 1
705
+ }
706
+ return 1
707
+ }
708
+ const slash = css.match(/\/\s*([\d.]+%?)\s*\)/)
709
+ if (slash) {
710
+ const raw = slash[1]
711
+ const v = raw.endsWith('%') ? Number(raw.slice(0, -1)) / 100 : Number(raw)
712
+ return Number.isFinite(v) ? v : 1
713
+ }
714
+ return 1
715
+ }
716
+
717
+ /**
718
+ * Apply the wallpaper to the shell frame: find the top-level app frame by
719
+ * walking up from our own overlay node (robust against hashed CSS-module
720
+ * class names), make it transparent, mount our own image div behind its
721
+ * content, then repaint the frame's large surfaces as translucent
722
+ * `color-mix` overlays — codeg's "background + panel alpha" model.
723
+ *
724
+ * Two rules keep the effect stable and complete:
725
+ * - Each surface's alpha is always computed from the color captured on the
726
+ * first touch, never from the already-mixed computed value, so moving the
727
+ * opacity slider cannot compound transparency away.
728
+ * - Coverage is classified by geometry, not by one area threshold: the
729
+ * frame, the columns (including the narrow left sidebar and the wide top
730
+ * bar), and the deeper panels each get a treatment. The sidebar and bar
731
+ * also get a frosted-glass backdrop so they read as distinct chrome.
732
+ *
733
+ * Everything is restored on cleanup.
734
+ */
735
+ function useWallpaperLayer(hostRef, enabled, settings, version) {
736
+ const repaintRef = useRef(null)
737
+ const settingsRef = useRef(settings)
738
+ settingsRef.current = settings
739
+
740
+ // Mount/unmount the layer and repaint surfaces (settings read via a ref so
741
+ // slider drags never re-fetch the image or re-mount the layer).
742
+ useEffect(() => {
743
+ const host = hostRef.current
744
+ if (!host) return
745
+ if (!enabled) return
746
+ let disposed = false
747
+ let observer = null
748
+ let scanTimer = null
749
+ let wallpaperUrl = null
750
+ let bodyBackground = null
751
+ let backgroundStyle = null
752
+ let frameOriginalPosition = null
753
+ /** el -> {backgroundColor, backgroundImage, backdropFilter, base, kind} */
754
+ const originals = new Map()
755
+
756
+ const findFrame = () => {
757
+ let node = host
758
+ const root = host.closest('#root')
759
+ if (!root) return null
760
+ while (node && node.parentElement !== root) node = node.parentElement
761
+ return node
762
+ }
763
+
764
+ const frame = findFrame()
765
+ if (!frame) {
766
+ console.warn('dsh-wallpaper: app frame not found; layer idle')
767
+ return
768
+ }
769
+ // The image covers the whole window (not just the frame's box): a fixed
770
+ // body-level layer also shows behind a titlebar row that the desktop
771
+ // shell may render outside #root.
772
+ const mountRoot = document.body
773
+ if (getComputedStyle(frame).position === 'static') {
774
+ frameOriginalPosition = ''
775
+ frame.style.position = 'relative'
776
+ }
777
+
778
+ const classify = (el, frameRect) => {
779
+ if (el === frame) return 'frame'
780
+ const rect = el.getBoundingClientRect()
781
+ if (rect.width <= 0 || rect.height <= 0) return null
782
+ const frameArea = frameRect.width * frameRect.height
783
+ const area = rect.width * rect.height
784
+ // The top bar is measured relative to the frame, not in absolute
785
+ // pixels: the Windows titlebar row is ~100px tall in a tall window
786
+ // and was missed by a fixed cap.
787
+ const inTopBand = rect.top <= frameRect.top + frameRect.height * 0.3
788
+ const wideShort =
789
+ rect.width >= frameRect.width * 0.5 &&
790
+ rect.height <= frameRect.height * 0.25 &&
791
+ inTopBand
792
+ const tallNarrow = rect.width <= frameRect.width * 0.45 && rect.height >= frameRect.height * 0.5
793
+ if (wideShort) return 'bar'
794
+ if (tallNarrow) return 'sidebar'
795
+ if (area >= frameArea * 0.08) return 'panel'
796
+ return null
797
+ }
798
+
799
+ /**
800
+ * Panels that must stay opaque: settings pages and floating dialogs are
801
+ * content surfaces, not the canvas the wallpaper shows through. Chrome
802
+ * (frame, columns, bars) is exempt from this check so the sidebar keeps
803
+ * its glass even while a settings page is open.
804
+ *
805
+ * The settings panel can stay mounted while closed (hidden), so a plain
806
+ * `querySelector` match proved nothing and silently excluded the whole
807
+ * canvas: the slot must actually be laid out to count.
808
+ */
809
+ const isContentPanel = (el) => {
810
+ const dialog = el.closest('[role="dialog"], [aria-modal="true"]')
811
+ if (dialog !== null && dialog.getClientRects().length > 0) return true
812
+ const settings = el.querySelector('[data-slot^="settings."]')
813
+ return settings !== null && settings.getClientRects().length > 0
814
+ }
815
+
816
+ /** The styles the layer wrote, so a theme switch can re-capture bases. */
817
+ const restoreOne = (el, orig) => {
818
+ el.style.backgroundColor = orig.backgroundColor
819
+ el.style.backgroundImage = orig.backgroundImage
820
+ el.style.backdropFilter = orig.backdropFilter
821
+ el.style.backgroundAttachment = orig.backgroundAttachment
822
+ el.style.backgroundSize = orig.backgroundSize
823
+ el.style.backgroundRepeat = orig.backgroundRepeat
824
+ el.style.backgroundPosition = orig.backgroundPosition
825
+ el.style.borderTopLeftRadius = orig.borderTopLeftRadius
826
+ el.style.borderRight = orig.borderRight
827
+ el.style.borderBottom = orig.borderBottom
828
+ }
829
+
830
+ /**
831
+ * Inline `border-top-left-radius` of every element this layer squared
832
+ * off, keyed by element so the value can be handed back on cleanup.
833
+ */
834
+ const flattened = new Map()
835
+
836
+ const restoreFlattened = () => {
837
+ for (const [el, radius] of flattened) el.style.borderTopLeftRadius = radius
838
+ flattened.clear()
839
+ }
840
+
841
+ const restoreAll = () => {
842
+ for (const [el, orig] of originals) restoreOne(el, orig)
843
+ originals.clear()
844
+ restoreFlattened()
845
+ }
846
+
847
+ let themeKey = ''
848
+ const seen = new Set()
849
+
850
+ let warned = false
851
+
852
+ const repaint = () => {
853
+ if (disposed) return
854
+ try {
855
+ repaintInner()
856
+ } catch (error) {
857
+ // A throw mid-walk must never escape into the observer/interval or
858
+ // leave the layer half-painted: report once and keep the app alive.
859
+ if (!warned) {
860
+ warned = true
861
+ console.warn('dsh-wallpaper: surface repaint failed', error)
862
+ }
863
+ }
864
+ }
865
+
866
+ /**
867
+ * The frame's visible box. `findFrame` can land on a `display: contents`
868
+ * wrapper — the Windows shell nests the real grid frame inside one — and
869
+ * such an element measures 0x0. Geometry-driven decisions (the top band,
870
+ * the minimum child area) silently degrade to nothing when that happens,
871
+ * so fall back to the first laid-out child and then to the viewport.
872
+ */
873
+ const measureFrameBox = () => {
874
+ const own = frame.getBoundingClientRect()
875
+ if (own.width > 0 && own.height > 0) return own
876
+ for (const child of frame.children) {
877
+ if (!(child instanceof HTMLElement)) continue
878
+ const rect = child.getBoundingClientRect()
879
+ if (rect.width > 0 && rect.height > 0) return rect
880
+ }
881
+ return { top: 0, left: 0, width: window.innerWidth, height: window.innerHeight }
882
+ }
883
+
884
+ /**
885
+ * The laid-out box of the app frame. `findFrame` can land on a
886
+ * `display: contents` wrapper (the Windows shell nests the real grid
887
+ * inside one); the tint — and the empty strip above the columns — belong
888
+ * to the grid element inside it.
889
+ */
890
+ const laidOutRoot = () => {
891
+ const own = frame.getBoundingClientRect()
892
+ if (own.width > 0 && own.height > 0) return frame
893
+ for (const child of frame.children) {
894
+ if (!(child instanceof HTMLElement)) continue
895
+ const rect = child.getBoundingClientRect()
896
+ if (rect.width > 0 && rect.height > 0) return child
897
+ }
898
+ return null
899
+ }
900
+
901
+ /**
902
+ * The background stack, built from two fixed pseudo-elements on <body>
903
+ * rather than the body background itself: an image layer plus a mask
904
+ * veil. `filter: blur()` on <body> would blur the whole application, and
905
+ * a `z-index: -1` child would be hidden by an opaque body background
906
+ * (exactly what bit this plugin before), so <body> is made transparent
907
+ * and both layers sit behind all content — codeg's model.
908
+ */
909
+ const applyBackgroundStack = () => {
910
+ const tuned = settingsRef.current
911
+ const fill = WP_FILL_STYLE[tuned.fillMode] ?? WP_FILL_STYLE.cover
912
+ if (backgroundStyle === null) {
913
+ backgroundStyle = document.createElement('style')
914
+ backgroundStyle.setAttribute('data-plugin', '@local/dsh-wallpaper')
915
+ backgroundStyle.setAttribute('data-dshwp-bg', '1')
916
+ document.head.appendChild(backgroundStyle)
917
+ }
918
+ backgroundStyle.textContent = [
919
+ 'body::before {',
920
+ " content: '';",
921
+ ' position: fixed;',
922
+ ' inset: 0;',
923
+ ' z-index: -1;',
924
+ ' pointer-events: none;',
925
+ ' background-image: var(--dshwp-image);',
926
+ ` background-size: ${fill.size};`,
927
+ ` background-repeat: ${fill.repeat};`,
928
+ ' background-position: center;',
929
+ tuned.imageBlur > 0 ? ` filter: blur(${tuned.imageBlur}px);` : '',
930
+ '}',
931
+ 'body::after {',
932
+ " content: '';",
933
+ ' position: fixed;',
934
+ ' inset: 0;',
935
+ ' z-index: -1;',
936
+ ' pointer-events: none;',
937
+ ` background-color: color-mix(in srgb, var(--dsw-alias-bg-base) ${Math.round(tuned.maskOpacity * 100)}%, transparent);`,
938
+ '}',
939
+ ].join('\n')
940
+ }
941
+
942
+ /** Region separators: one hairline wherever two regions meet. */
943
+ const line = 'color-mix(in srgb, currentColor 28%, transparent)'
944
+
945
+ /**
946
+ * The Windows caption strip is opaque chrome painted twice over the page:
947
+ * the shell's own `[data-windows-titlebar]` strip, filled with the
948
+ * sidebar-fill token, and the window-controls overlay above it, whose
949
+ * colour the shell publishes to the main process from a hidden probe
950
+ * element that reads the same token. Point that token at `transparent`
951
+ * and both follow: the strip keeps its drag region and its hairlines but
952
+ * lets the wallpaper through, so the top bar no longer reads as a block.
953
+ * Scoped to the body so the theme's own value returns on cleanup, and
954
+ * only in the desktop shell, whose preload marks the document — a plain
955
+ * browser never paints that strip.
956
+ */
957
+ const CAPTION_FILL = '--dsw-specific-sidebar-fill'
958
+ const applyCaptionFill = () => {
959
+ if (!('windowsTitlebar' in document.documentElement.dataset)) return
960
+ // Writing the body's style attribute is also what makes the shell's
961
+ // MutationObserver re-read its probe and re-publish the overlay colour.
962
+ if (document.body.style.getPropertyValue(CAPTION_FILL) !== 'transparent') {
963
+ document.body.style.setProperty(CAPTION_FILL, 'transparent')
964
+ }
965
+ }
966
+
967
+ /**
968
+ * DSH's composer is a contenteditable box, not a <textarea>. Shared by
969
+ * `isComposerBox` and the walk.
970
+ */
971
+ const EDITABLE_SELECTOR = 'textarea, [contenteditable="true"], [role="textbox"]'
972
+
973
+ /**
974
+ * Is this walked box the composer?
975
+ *
976
+ * The old test keyed on the box's TOP edge (`top >= 60 % of the frame`),
977
+ * which is exactly what broke the moment the composer was stretched: a
978
+ * tall draft pushes the top edge far up the window, the test failed, and
979
+ * the box fell through to the generic translucent panel treatment — the
980
+ * conversation showed straight through the input box.
981
+ *
982
+ * The composer is bottom-anchored chrome instead, so the test is
983
+ * anchored to its BOTTOM edge, with a cap on how tall it may be: the
984
+ * conversation column is taller than 70 % of the frame and would
985
+ * otherwise swallow the message canvas as well. The visibility test
986
+ * rejects a laid-out but hidden mirror of the editor (AutoSize helpers
987
+ * are commonly `visibility: hidden`), and an already-booked composer
988
+ * stays one, so a passing geometry change can never drop the recipe
989
+ * mid-expansion.
990
+ *
991
+ * `box` is the frame rect measured by the current repaint pass.
992
+ */
993
+ const isComposerBox = (el, rect, box) => {
994
+ if (originals.get(el)?.kind === 'composer') return true
995
+ if (el.querySelector(EDITABLE_SELECTOR) === null) return false
996
+ if (rect.height <= 0 || rect.height > box.height * 0.7) return false
997
+ if (rect.bottom < box.top + box.height * 0.6) return false
998
+ const cs = getComputedStyle(el)
999
+ if (cs.visibility === 'hidden' || cs.display === 'none') return false
1000
+ return Number(cs.opacity) > 0.05
1001
+ }
1002
+
1003
+ const repaintInner = () => {
1004
+ // A theme switch changes every surface's base color; captured bases
1005
+ // would otherwise keep painting the previous palette.
1006
+ const nextThemeKey = `${document.documentElement.style.colorScheme}|${document.body.dataset.dsDarkTheme ?? ''}`
1007
+ if (nextThemeKey !== themeKey) {
1008
+ restoreAll()
1009
+ themeKey = nextThemeKey
1010
+ }
1011
+ seen.clear()
1012
+ applyCaptionFill()
1013
+ if (wallpaperUrl !== null) applyBackgroundStack()
1014
+ const frameRect = measureFrameBox()
1015
+ const frameArea = Math.max(1, frameRect.width * frameRect.height)
1016
+ const tuned = settingsRef.current
1017
+ const alpha = Math.round(tuned.panelOpacity * 100)
1018
+ /**
1019
+ * The chrome keeps the same alpha as the panels (codeg drives every
1020
+ * structural surface from one slider) — the frost is what separates
1021
+ * the sidebar from the canvas, not a second opacity.
1022
+ */
1023
+ const chromeAlpha = alpha
1024
+
1025
+ /** Region separators: one hairline wherever two regions meet. */
1026
+ const line = 'color-mix(in srgb, currentColor 28%, transparent)'
1027
+
1028
+ /**
1029
+ * Square off the top-left corner of every big surface in the top band
1030
+ * of the window. On Windows the shell rounds the content column's
1031
+ * corner (`--dsh-windows-content-radius`, 16px) against the sidebar
1032
+ * fill; once the surfaces above the wallpaper are translucent that
1033
+ * radius reads as a wedge of bare frame colour next to the wallpaper.
1034
+ *
1035
+ * This is its own pass rather than part of `apply`: the corner belongs
1036
+ * to the content column, which the paint walk may skip entirely as a
1037
+ * content panel, and the fix must not depend on that classification.
1038
+ * Only wall-sized boxes qualify, so buttons and cards keep their shape.
1039
+ */
1040
+ const flattenTopLeftCorners = () => {
1041
+ const minArea = frameArea * 0.04
1042
+ const topLimit = frameRect.top + (frameRect.height || window.innerHeight) * 0.35
1043
+ const desired = new Set()
1044
+ let frontier = [...frame.children].filter((el) => el instanceof HTMLElement)
1045
+ for (let depth = 0; depth < 20 && frontier.length > 0; depth++) {
1046
+ const next = []
1047
+ for (const el of frontier) {
1048
+ if (host.contains(el)) continue
1049
+ const rect = el.getBoundingClientRect()
1050
+ // Prune: nothing inside a box smaller than the threshold can
1051
+ // itself be big enough, so the walk stays a few dozen elements.
1052
+ // Zero-area elements are exempt — a `display: contents` wrapper
1053
+ // measures 0x0 while holding the whole frame below it.
1054
+ if (rect.width * rect.height > 0 && rect.width * rect.height < minArea) continue
1055
+ const cs = getComputedStyle(el)
1056
+ const radius = Number.parseFloat(cs.borderTopLeftRadius) || 0
1057
+ const already = flattened.has(el)
1058
+ if ((radius >= 8 || already) && rect.top <= topLimit) {
1059
+ desired.add(el)
1060
+ if (!already) {
1061
+ flattened.set(el, el.style.borderTopLeftRadius)
1062
+ el.style.borderTopLeftRadius = '0px'
1063
+ }
1064
+ }
1065
+ next.push(...el.children)
1066
+ }
1067
+ frontier = next.filter((el) => el instanceof HTMLElement)
1068
+ }
1069
+ for (const [el, radius] of [...flattened]) {
1070
+ if (!desired.has(el) || !el.isConnected) {
1071
+ el.style.borderTopLeftRadius = radius
1072
+ flattened.delete(el)
1073
+ }
1074
+ }
1075
+ }
1076
+
1077
+ const apply = (el, kind) => {
1078
+ if (!(el instanceof HTMLElement)) return
1079
+ if (host.contains(el)) return
1080
+ seen.add(el)
1081
+ const cs = getComputedStyle(el)
1082
+ const barLike = kind === 'bar' || kind === 'bar-child' || kind === 'bar-inner'
1083
+ // The walk decides this (see `isComposerBox`) and books the box as
1084
+ // 'composer'; `apply` only has to honour it rather than re-derive a
1085
+ // guess of its own.
1086
+ const isComposer = kind === 'composer'
1087
+ let entry = originals.get(el)
1088
+ if (!entry) {
1089
+ const bgColor = cs.backgroundColor
1090
+ const bgImage = cs.backgroundImage
1091
+ const hasImage = bgImage && bgImage !== 'none'
1092
+ const opaque = colorAlpha(bgColor) >= 0.9
1093
+ // Runtime images (a plugin's blob: sprite, a chat attachment) are
1094
+ // content, never a paint surface. Only true chrome may be treated
1095
+ // through its background image.
1096
+ const chrome = kind === 'frame' || kind === 'sidebar' || barLike
1097
+ // `inner` and `composer` bypass the opacity gate: the visible
1098
+ // "blocks" are exactly the boxes with a *translucent* dark
1099
+ // background, and the composer box is one of them — a >= 0.9
1100
+ // gate can never reach them, which is why clearing them never
1101
+ // worked before.
1102
+ const bypass = kind === 'inner' || kind === 'composer'
1103
+ if (!bypass) {
1104
+ if (!opaque && !(chrome && hasImage)) return
1105
+ if (!opaque && bgImage.includes('blob:')) return
1106
+ } else if (kind === 'inner' && !opaque && !hasImage && cs.backdropFilter === 'none') {
1107
+ // Booking thousands of already-transparent boxes costs a repaint
1108
+ // pass per change for nothing: they have nothing to clear.
1109
+ return
1110
+ }
1111
+ entry = {
1112
+ backgroundColor: el.style.backgroundColor,
1113
+ backgroundImage: el.style.backgroundImage,
1114
+ backdropFilter: el.style.backdropFilter,
1115
+ backgroundAttachment: el.style.backgroundAttachment,
1116
+ backgroundSize: el.style.backgroundSize,
1117
+ backgroundRepeat: el.style.backgroundRepeat,
1118
+ backgroundPosition: el.style.backgroundPosition,
1119
+ borderTopLeftRadius: el.style.borderTopLeftRadius,
1120
+ borderRight: el.style.borderRight,
1121
+ borderBottom: el.style.borderBottom,
1122
+ base: opaque ? bgColor : 'var(--dsw-alias-bg-base)',
1123
+ kind,
1124
+ }
1125
+ originals.set(el, entry)
1126
+ }
1127
+ // The role changes between passes (a panel that moves into the chrome
1128
+ // band, a box that becomes the composer): the entry keeps the box's
1129
+ // ORIGINAL paint, never the role it was booked with. A stale role
1130
+ // would leave the wallpaper recipe painted on a box that is no
1131
+ // longer the composer.
1132
+ entry.kind = kind
1133
+ const useAlpha = kind === 'sidebar' || barLike ? chromeAlpha : alpha
1134
+ if (isComposer) {
1135
+ /**
1136
+ * The composer sits above the conversation, so plain transparency
1137
+ * would show the text behind it. Paint the wallpaper *itself* here
1138
+ * with `fixed` attachment so it lines up pixel-for-pixel with the
1139
+ * canvas — and then reproduce every layer the canvas shows above
1140
+ * that image, in paint order: the mask veil, then each ancestor
1141
+ * surface the walk made translucent.
1142
+ *
1143
+ * Painting the image under the veil alone is what left the input
1144
+ * box reading brighter/clearer than the conversation around it:
1145
+ * the canvas is the same image *and* the frame/column/panel tints
1146
+ * on top, so the composer showed about (1 - panelOpacity)^n more
1147
+ * of the wallpaper than the surface it sits on. The ancestor
1148
+ * colours are read from computed style — the exact pixels the
1149
+ * canvas composites — so the two recipes can never drift apart.
1150
+ */
1151
+ const tuned = settingsRef.current
1152
+ const fill = WP_FILL_STYLE[tuned.fillMode] ?? WP_FILL_STYLE.cover
1153
+ const veil = `color-mix(in srgb, var(--dsw-alias-bg-base) ${Math.round(tuned.maskOpacity * 100)}%, transparent)`
1154
+ const covers = []
1155
+ for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {
1156
+ // A nested composer wrapper already paints this same recipe;
1157
+ // stacking it again would composite the image onto itself.
1158
+ if (originals.get(node)?.kind === 'composer') continue
1159
+ const above = getComputedStyle(node).backgroundColor
1160
+ // Innermost ancestor first: that one paints last, on top.
1161
+ if (colorAlpha(above) > 0) covers.push(`linear-gradient(${above}, ${above})`)
1162
+ if (node === frame) break
1163
+ }
1164
+ const layers = covers.length + 2
1165
+ el.style.backgroundColor = 'transparent'
1166
+ el.style.backgroundImage = [...covers, `linear-gradient(${veil}, ${veil})`, 'var(--dshwp-image)'].join(', ')
1167
+ el.style.backgroundSize = [...covers.map(() => 'auto'), 'auto', fill.size].join(', ')
1168
+ el.style.backgroundRepeat = [...covers.map(() => 'no-repeat'), 'no-repeat', fill.repeat].join(', ')
1169
+ el.style.backgroundPosition = Array.from({ length: layers }, () => 'center').join(', ')
1170
+ // Only the wallpaper itself needs viewport-anchored painting (it
1171
+ // has to line up with the canvas); the tints and the veil are flat
1172
+ // colours, so keeping them out of fixed attachment spares the
1173
+ // compositor a stack of viewport-sized layers.
1174
+ el.style.backgroundAttachment = [...covers.map(() => 'scroll'), 'scroll', 'fixed'].join(', ')
1175
+ el.style.backdropFilter = 'none'
1176
+ } else if (kind === 'inner') {
1177
+ // Chrome descendants: colourless, so only the region tint and the
1178
+ // separator lines remain.
1179
+ el.style.backgroundColor = 'transparent'
1180
+ } else if (barLike) {
1181
+ // The top strip is fully transparent (codeg's ws-transparent-bg):
1182
+ // the image shows straight through, and the hairline below keeps
1183
+ // it readable instead of a frost. The descendants that actually
1184
+ // paint the strip's colour go transparent too — but they never
1185
+ // get a border, which is what produced stray hairlines before.
1186
+ el.style.backgroundColor = 'transparent'
1187
+ } else {
1188
+ el.style.backgroundColor = `color-mix(in srgb, ${entry.base} ${useAlpha}%, transparent)`
1189
+ }
1190
+ if (!isComposer && cs.backgroundImage !== 'none') el.style.backgroundImage = 'none'
1191
+ if (kind === 'sidebar') {
1192
+ // Frost without the dark tint: the blur keeps the sidebar
1193
+ // readable over the image, and without a colour-mix layer it no
1194
+ // longer reads as one big block (the user's "色块" was exactly
1195
+ // that tint over a bright wallpaper).
1196
+ el.style.backgroundColor = 'transparent'
1197
+ el.style.backdropFilter = WP_FROST
1198
+ el.style.borderRight = `1px solid ${line}`
1199
+ } else if (kind === 'bar') {
1200
+ el.style.backdropFilter = 'none'
1201
+ el.style.borderBottom = `1px solid ${line}`
1202
+ } else {
1203
+ el.style.backdropFilter = 'none'
1204
+ }
1205
+ }
1206
+
1207
+ /**
1208
+ * One column's translucent tint stack: the surfaces that span the
1209
+ * column and sit above the base tint. Collapsed into a single colour
1210
+ * — the innermost layer's colour at the combined alpha — because the
1211
+ * strip needs one hard-edged stop per column, not a stack.
1212
+ */
1213
+ const columnTint = (col) => {
1214
+ const colRect = col.getBoundingClientRect()
1215
+ let alpha = 0
1216
+ let css = null
1217
+ let frontier = [col]
1218
+ for (let depth = 0; depth < 6 && frontier.length > 0; depth++) {
1219
+ const next = []
1220
+ for (const el of frontier) {
1221
+ if (!(el instanceof HTMLElement)) continue
1222
+ const entry = originals.get(el)
1223
+ const rect = el.getBoundingClientRect()
1224
+ // Only a surface that spans the whole column counts: a card or
1225
+ // a message row is content, never the column's own tint.
1226
+ if (entry && rect.width >= colRect.width * 0.95 && (entry.kind === 'panel' || entry.kind === 'frame')) {
1227
+ const above = getComputedStyle(el).backgroundColor
1228
+ const a = colorAlpha(above)
1229
+ if (a > 0) {
1230
+ alpha = 1 - (1 - alpha) * (1 - a)
1231
+ css = above
1232
+ }
1233
+ }
1234
+ next.push(...el.children)
1235
+ }
1236
+ frontier = next
1237
+ }
1238
+ return css === null || alpha <= 0.001 ? null : withAlpha(css, Number(alpha.toFixed(3)))
1239
+ }
1240
+
1241
+ /**
1242
+ * The window's top strip — the titlebar band above the first laid-out
1243
+ * column — has no element of its own, so it showed only the base tint
1244
+ * and the wallpaper read brighter there than in the column beneath it.
1245
+ * Paint the base surface as a horizontal gradient that carries each
1246
+ * column's tint stack up across the strip above it: the top bar
1247
+ * continues whichever column it sits on instead of forming a band.
1248
+ * The base colour stays the element's own background-color, so
1249
+ * nothing below the strip changes.
1250
+ */
1251
+ const applyColumnTints = () => {
1252
+ const root = laidOutRoot()
1253
+ if (root === null) return
1254
+ const rootRect = root.getBoundingClientRect()
1255
+ // One layer per tinted column, hard-edged to that column's x-range:
1256
+ // the grid also holds full-width overlays and a thin splitter, and a
1257
+ // single shared gradient would let those split the stop order.
1258
+ const columns = []
1259
+ for (const col of root.children) {
1260
+ if (!(col instanceof HTMLElement)) continue
1261
+ const rect = col.getBoundingClientRect()
1262
+ // Columns only: a short child is a row (the top bar itself).
1263
+ if (rect.width <= 0 || rect.height < rootRect.height * 0.2 || rect.top <= rootRect.top) continue
1264
+ columns.push({ col, rect })
1265
+ }
1266
+ if (columns.length === 0) {
1267
+ root.style.backgroundImage = ''
1268
+ return
1269
+ }
1270
+ // Only the strip above the highest column is empty of paint: below
1271
+ // that line every column draws its own stack.
1272
+ const stripHeight = Math.max(0, Math.round(Math.min(...columns.map((c) => c.rect.top)) - rootRect.top))
1273
+ const layers = []
1274
+ for (const { col, rect } of columns) {
1275
+ const tint = columnTint(col)
1276
+ if (tint === null || stripHeight <= 0) continue
1277
+ const from = Math.round(rect.left - rootRect.left)
1278
+ const to = Math.round(rect.right - rootRect.left)
1279
+ layers.push(
1280
+ `linear-gradient(90deg, transparent 0 ${from}px, ${tint} ${from}px ${to}px, transparent ${to}px 100%)`
1281
+ )
1282
+ }
1283
+ root.style.backgroundImage = layers.join(', ')
1284
+ root.style.backgroundSize = layers.map(() => `100% ${stripHeight}px`).join(', ')
1285
+ root.style.backgroundRepeat = layers.map(() => 'no-repeat').join(', ')
1286
+ root.style.backgroundPosition = layers.map(() => 'left top').join(', ')
1287
+ }
1288
+
1289
+ flattenTopLeftCorners()
1290
+ apply(frame, 'frame')
1291
+ // Breadth-first walk over the app's own tree. Everything opaque that
1292
+ // sits above the wallpaper has to be made translucent, so the walk
1293
+ // errs on the side of visiting too much rather than too little: a
1294
+ // single opaque panel left behind hides the wallpaper completely.
1295
+ const minChildArea = frameRect.width * frameRect.height * 0.05
1296
+ const skipped = new Set()
1297
+ /** element -> depth inside a top bar (1 = child, 2 = grandchild) */
1298
+ const barContext = new Map()
1299
+ /** composer subtree: colourless descendants under its wallpaper paint */
1300
+ const composerContext = new Map()
1301
+ const walk = (root, depthLimit) => {
1302
+ let frontier = [...root.children]
1303
+ for (let depth = 0; depth < depthLimit && frontier.length > 0; depth++) {
1304
+ const next = []
1305
+ for (const el of frontier) {
1306
+ if (!(el instanceof HTMLElement)) continue
1307
+ // Only the app's own tree holds paint surfaces. DOM another
1308
+ // plugin appended to <body> (the desktop pet's portal) is off
1309
+ // limits — treating it turned the pet into a blank block.
1310
+ if (el.closest('#root') === null) {
1311
+ // One exception: chrome the shell renders OUTSIDE #root. DSH's
1312
+ // titlebar row sits above the app tree, and nothing inside
1313
+ // #root can make it translucent. The shape test is deliberately
1314
+ // strict — full width, short, flush with the top — and `apply`
1315
+ // only recolours genuinely opaque boxes, so a near miss costs
1316
+ // nothing.
1317
+ const outer = el.getBoundingClientRect()
1318
+ const isTopStrip =
1319
+ outer.height > 0 &&
1320
+ outer.height <= 96 &&
1321
+ outer.top <= frameRect.top + 8 &&
1322
+ outer.width >= frameRect.width * 0.6
1323
+ if (isTopStrip) apply(el, 'bar')
1324
+ continue
1325
+ }
1326
+ const kind = el === frame ? 'frame' : classify(el, frameRect)
1327
+ const barDepth = barContext.get(el) ?? 0
1328
+ const composerDepth = composerContext.get(el) ?? 0
1329
+ const probeRect = el.getBoundingClientRect()
1330
+ // Pure geometry, no classification: everything that lies wholly
1331
+ // inside the titlebar band or the sidebar column loses its own
1332
+ // colour. Classification kept missing these — the row stayed
1333
+ // painted while `classify` returned no kind — and the user's
1334
+ // rule is simply "lines, not blocks".
1335
+ const inChromeBand =
1336
+ probeRect.height > 0 &&
1337
+ (probeRect.bottom <= frameRect.top + 96 ||
1338
+ probeRect.right <= frameRect.left + frameRect.width * 0.35)
1339
+ const regionKind = kind === 'sidebar' || kind === 'frame' || kind === 'bar'
1340
+ // Recognised BEFORE the prune below: a one-line composer is
1341
+ // smaller than the prune threshold, and pruning its subtree is
1342
+ // what used to leave the translucent boxes inside the editor
1343
+ // untouched. `isComposerBox` is anchored to the box's bottom edge
1344
+ // (see its comment), so a stretched composer keeps the recipe.
1345
+ // Settings pages and dialogs are excluded even when they happen
1346
+ // to end in a text box — they are content, by decision.
1347
+ const isComposerHere =
1348
+ !regionKind &&
1349
+ !(kind === 'panel' && isContentPanel(el)) &&
1350
+ isComposerBox(el, probeRect, frameRect)
1351
+ if (kind === 'panel' && isContentPanel(el)) {
1352
+ // Settings pages and dialogs stay opaque by decision.
1353
+ skipped.add(el)
1354
+ } else if (barDepth > 0 || composerDepth > 0 || (inChromeBand && !regionKind)) {
1355
+ // Inside the chrome or the composer: give up the colour
1356
+ // entirely — that removes the stray blocks. The REGION
1357
+ // elements themselves are exempt: the sidebar keeps its tint
1358
+ // and frost, the bar keeps its hairline — only inner boxes
1359
+ // (panels and unclassified) go colourless.
1360
+ apply(el, 'inner')
1361
+ } else {
1362
+ // `apply` itself only touches opaque surfaces (and chrome
1363
+ // that carries a background image), so the paint decision
1364
+ // does not need a size threshold — and must not have one:
1365
+ // small nested panels are exactly what used to stay opaque
1366
+ // and hide the wallpaper.
1367
+ apply(el, kind ?? 'panel')
1368
+ }
1369
+ if (isComposerHere) {
1370
+ // Only the outermost surface of a composer stack paints the
1371
+ // recipe. The wrappers nested inside it are fully covered by
1372
+ // that paint, so painting them too only stacked three
1373
+ // full-viewport wallpapers on the compositor for identical
1374
+ // pixels; they go transparent instead.
1375
+ apply(el, composerDepth === 0 ? 'composer' : 'inner')
1376
+ }
1377
+ const barDepthNext =
1378
+ kind === 'bar' || kind === 'sidebar' ? 1 : barDepth > 0 && barDepth < 3 ? barDepth + 1 : 0
1379
+ const composerDepthNext =
1380
+ isComposerHere ? 1 : composerDepth > 0 && composerDepth < 3 ? composerDepth + 1 : 0
1381
+ if (barDepthNext > 0) {
1382
+ for (const child of el.children) barContext.set(child, barDepthNext)
1383
+ }
1384
+ if (composerDepthNext > 0) {
1385
+ // Grandchildren carry translucent backgrounds too — clearing
1386
+ // only direct children left a second veil inside the editor,
1387
+ // which read as a brightness mismatch against the canvas.
1388
+ for (const child of el.children) composerContext.set(child, composerDepthNext)
1389
+ }
1390
+ const area = probeRect.width * probeRect.height
1391
+ // Prune small laid-out boxes, but never a zero-area element:
1392
+ // `display: contents` wrappers measure 0x0 while holding the
1393
+ // entire application frame below them. The top band is exempt:
1394
+ // a full-width 40px titlebar row is only ~4 % of the frame and
1395
+ // pruning it is exactly why the top bar kept its own colour.
1396
+ if (area > 0 && area < minChildArea && probeRect.top > frameRect.top + 96) continue
1397
+ next.push(...el.children)
1398
+ }
1399
+ frontier = next
1400
+ }
1401
+ }
1402
+ walk(mountRoot, 30)
1403
+ // The window's top strip — titlebar height above the first laid-out
1404
+ // column — has no element of its own, so it showed the bare base tint
1405
+ // and the wallpaper therefore read brighter there than in the column
1406
+ // under it. Continue each column's own tint stack across that strip
1407
+ // so the top bar is part of the surface it sits on, not a band.
1408
+ applyColumnTints()
1409
+ // Only surfaces we deliberately skipped are reverted; one that merely
1410
+ // fell outside this pass keeps its paint (a transient miss must not
1411
+ // flip the whole layer on and off).
1412
+ for (const [el, orig] of [...originals]) {
1413
+ if (!el.isConnected) {
1414
+ originals.delete(el)
1415
+ } else if (skipped.has(el)) {
1416
+ restoreOne(el, orig)
1417
+ originals.delete(el)
1418
+ }
1419
+ }
1420
+ }
1421
+ repaintRef.current = repaint
1422
+
1423
+ let repaintTimer = null
1424
+ let frameHandle = null
1425
+ let lastLeadAt = 0
1426
+ /**
1427
+ * Repaint on both edges of a churn burst.
1428
+ *
1429
+ * Switching sessions replaces the conversation subtree in one commit, and
1430
+ * the freshly mounted panels — the "载入历史…" state included — still
1431
+ * carry their own opaque colours. A trailing-only debounce therefore left
1432
+ * the wallpaper hidden for its whole window: the flash the user sees
1433
+ * before it "refreshes" back. The leading repaint runs on the next frame,
1434
+ * before the browser paints the new content, and is throttled so a
1435
+ * streaming answer cannot turn into a repaint per token; the trailing one
1436
+ * then catches whatever mounted after it.
1437
+ */
1438
+ const scheduleRepaint = () => {
1439
+ const now = Date.now()
1440
+ if (frameHandle === null && now - lastLeadAt >= 150) {
1441
+ lastLeadAt = now
1442
+ frameHandle = requestAnimationFrame(() => {
1443
+ frameHandle = null
1444
+ repaint()
1445
+ })
1446
+ }
1447
+ if (repaintTimer !== null) return
1448
+ repaintTimer = setTimeout(() => {
1449
+ repaintTimer = null
1450
+ repaint()
1451
+ }, 500)
1452
+ }
1453
+
1454
+ const mount = async () => {
1455
+ try {
1456
+ const res = await fetch(`${API}/api/current`)
1457
+ if (!res.ok) {
1458
+ console.warn('dsh-wallpaper: wallpaper bytes unavailable', res.status)
1459
+ return
1460
+ }
1461
+ const blob = await res.blob()
1462
+ if (disposed) return
1463
+ wallpaperUrl = URL.createObjectURL(blob)
1464
+ // The image is a custom property consumed by the fixed body::before
1465
+ // layer, and <body> itself goes transparent so that layer is not
1466
+ // hidden behind an opaque body background.
1467
+ bodyBackground = {
1468
+ backgroundImage: mountRoot.style.backgroundImage,
1469
+ backgroundColor: mountRoot.style.backgroundColor,
1470
+ backgroundSize: mountRoot.style.backgroundSize,
1471
+ backgroundPosition: mountRoot.style.backgroundPosition,
1472
+ backgroundAttachment: mountRoot.style.backgroundAttachment,
1473
+ backgroundRepeat: mountRoot.style.backgroundRepeat,
1474
+ }
1475
+ document.documentElement.style.setProperty('--dshwp-image', `url("${wallpaperUrl}")`)
1476
+ mountRoot.style.backgroundImage = 'none'
1477
+ mountRoot.style.backgroundColor = 'transparent'
1478
+ applyBackgroundStack()
1479
+ repaint()
1480
+ const kinds = [...originals.values()].map((entry) => entry.kind)
1481
+ console.info('dsh-wallpaper: layer mounted', {
1482
+ surfaces: originals.size,
1483
+ kinds,
1484
+ settings: settingsRef.current,
1485
+ })
1486
+ // Child-list churn catches mounts (rails, panels); the interval
1487
+ // catches class/style changes without observing every attribute
1488
+ // write the chat stream produces.
1489
+ observer = new MutationObserver(scheduleRepaint)
1490
+ observer.observe(mountRoot, { childList: true, subtree: true })
1491
+ scanTimer = setInterval(scheduleRepaint, 3000)
1492
+ } catch (error) {
1493
+ /* wallpaper layer is best-effort; the app must never break */
1494
+ console.warn('dsh-wallpaper: layer mount failed', error)
1495
+ }
1496
+ }
1497
+
1498
+ void mount()
1499
+ return () => {
1500
+ disposed = true
1501
+ repaintRef.current = null
1502
+ if (repaintTimer !== null) clearTimeout(repaintTimer)
1503
+ if (frameHandle !== null) cancelAnimationFrame(frameHandle)
1504
+ if (scanTimer !== null) clearInterval(scanTimer)
1505
+ if (observer) observer.disconnect()
1506
+ restoreAll()
1507
+ originals.clear()
1508
+ if (bodyBackground !== null) {
1509
+ mountRoot.style.backgroundImage = bodyBackground.backgroundImage
1510
+ mountRoot.style.backgroundColor = bodyBackground.backgroundColor
1511
+ mountRoot.style.backgroundSize = bodyBackground.backgroundSize
1512
+ mountRoot.style.backgroundPosition = bodyBackground.backgroundPosition
1513
+ mountRoot.style.backgroundAttachment = bodyBackground.backgroundAttachment
1514
+ mountRoot.style.backgroundRepeat = bodyBackground.backgroundRepeat
1515
+ bodyBackground = null
1516
+ }
1517
+ document.documentElement.style.removeProperty('--dshwp-image')
1518
+ // Hand the caption strip back to the theme (the shell re-reads it on
1519
+ // this body mutation and restores the window-controls overlay).
1520
+ document.body.style.removeProperty(CAPTION_FILL)
1521
+ if (backgroundStyle !== null) {
1522
+ backgroundStyle.remove()
1523
+ backgroundStyle = null
1524
+ }
1525
+ if (wallpaperUrl) URL.revokeObjectURL(wallpaperUrl)
1526
+ if (frameOriginalPosition !== null) frame.style.position = frameOriginalPosition
1527
+ }
1528
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1529
+ }, [hostRef, enabled, version])
1530
+
1531
+ // Setting changes (opacity, mask, blur, fill) repaint in place: the image
1532
+ // bytes and the surface bookkeeping stay exactly as they are.
1533
+ useEffect(() => {
1534
+ if (repaintRef.current) repaintRef.current()
1535
+ }, [settings])
1536
+ }
1537
+
1538
+ // ─── Sync ───────────────────────────────────────────────────────────────
1539
+
1540
+ function useConfigSync(store) {
1541
+ useEffect(() => {
1542
+ let disposed = false
1543
+ const load = () => {
1544
+ apiJson('/api/config')
1545
+ .then((cfg) => {
1546
+ if (!disposed) store.setLoaded(cfg.config)
1547
+ })
1548
+ .catch(() => {
1549
+ if (!disposed) store.setLoaded(store.getSnapshot().config)
1550
+ })
1551
+ apiJson('/api/version')
1552
+ .then((data) => {
1553
+ if (!disposed) store.setWallpaperVersion(data.version)
1554
+ })
1555
+ .catch(() => {})
1556
+ }
1557
+ load()
1558
+ const onFocus = () => load()
1559
+ window.addEventListener('focus', onFocus)
1560
+ return () => {
1561
+ disposed = true
1562
+ window.removeEventListener('focus', onFocus)
1563
+ }
1564
+ }, [store])
1565
+ }
1566
+
1567
+ /** Poll the host wallpaper version so other windows' changes propagate. */
1568
+ function useWallpaperVersionSync(store, enabled) {
1569
+ useEffect(() => {
1570
+ if (!enabled) return
1571
+ let disposed = false
1572
+ const poll = () => {
1573
+ apiJson('/api/version')
1574
+ .then((data) => {
1575
+ if (!disposed) store.setWallpaperVersion(data.version)
1576
+ })
1577
+ .catch(() => {})
1578
+ }
1579
+ poll()
1580
+ const timer = setInterval(poll, 5000)
1581
+ return () => {
1582
+ disposed = true
1583
+ clearInterval(timer)
1584
+ }
1585
+ }, [store, enabled])
1586
+ }
1587
+
1588
+ // ─── Settings section (the wallpaper page) ──────────────────────────────
1589
+
1590
+ function CurrentWallpaper({ store, t }) {
1591
+ const config = useStore(store, (s) => s.config)
1592
+ const version = useStore(store, (s) => s.wallpaperVersion)
1593
+ const hasWallpaper = version !== 'none'
1594
+ const [url, setUrl] = useState(null)
1595
+
1596
+ useEffect(() => {
1597
+ if (!hasWallpaper) {
1598
+ setUrl(null)
1599
+ return
1600
+ }
1601
+ let cancelled = false
1602
+ let owned = null
1603
+ apiBlobUrl('/api/current')
1604
+ .then((u) => {
1605
+ if (cancelled) {
1606
+ URL.revokeObjectURL(u)
1607
+ return
1608
+ }
1609
+ owned = u
1610
+ setUrl(u)
1611
+ })
1612
+ .catch(() => {})
1613
+ return () => {
1614
+ cancelled = true
1615
+ if (owned) URL.revokeObjectURL(owned)
1616
+ }
1617
+ }, [hasWallpaper, version])
1618
+
1619
+ const source = config.wallpaperSourceUrl
1620
+ const label = source ? source.replace('https://wallhaven.cc/w/', '') : t('wallpaper.localImage')
1621
+ return h(
1622
+ 'div',
1623
+ { className: 'dshwp-current' },
1624
+ h(
1625
+ 'div',
1626
+ { className: 'dshwp-current-thumb' },
1627
+ url ? h('img', { src: url, alt: '' }) : h('div', { className: 'dshwp-card-empty' }, h(ImageIcon, { size: 20 }))
1628
+ ),
1629
+ h(
1630
+ 'div',
1631
+ { style: { minWidth: 0 } },
1632
+ h('div', { className: 'dshwp-srow-title' }, hasWallpaper ? t('wallpaper.current') : t('wallpaper.none')),
1633
+ h('div', { className: 'dshwp-srow-desc' },
1634
+ hasWallpaper ? `${label} · ${config.wallpaperSourceUrl ? t('wallpaper.credit') : ''}`.trim() : ''
1635
+ )
1636
+ )
1637
+ )
1638
+ }
1639
+
1640
+ function WallpaperSection({ store, t }) {
1641
+ const settings = useStore(store, (s) => s.display)
1642
+ const updateSettings = store.setDisplay
1643
+ const config = useStore(store, (s) => s.config)
1644
+ const version = useStore(store, (s) => s.wallpaperVersion)
1645
+ const [error, setError] = useState(null)
1646
+ const [notice, setNotice] = useState(null)
1647
+ const [uploading, setUploading] = useState(false)
1648
+ const fileInputRef = useRef(null)
1649
+
1650
+ const putConfig = useCallback(
1651
+ (patch) =>
1652
+ apiJson('/api/config', { method: 'PUT', body: JSON.stringify(patch) })
1653
+ .then((data) => store.setConfig(data.config))
1654
+ .catch((err) => setError(err.message)),
1655
+ [store]
1656
+ )
1657
+
1658
+ const refreshVersion = useCallback(async () => {
1659
+ try {
1660
+ const data = await apiJson('/api/version')
1661
+ store.setWallpaperVersion(data.version)
1662
+ } catch {
1663
+ /* the version poll covers transient failures */
1664
+ }
1665
+ }, [store])
1666
+
1667
+ const onUploadFile = async (file) => {
1668
+ if (!file) return
1669
+ setError(null)
1670
+ setNotice(null)
1671
+ setUploading(true)
1672
+ try {
1673
+ if (file.size > MAX_WP_BYTES_CLIENT + 1) {
1674
+ throw new Error(
1675
+ t('wallpaper.tooLarge', {
1676
+ actual: formatBytes(file.size),
1677
+ limit: formatBytes(MAX_WP_BYTES_CLIENT),
1678
+ })
1679
+ )
1680
+ }
1681
+ const res = await fetch(`${API}/api/upload`, {
1682
+ method: 'POST',
1683
+ headers: { 'content-type': file.type || 'application/octet-stream' },
1684
+ body: file,
1685
+ })
1686
+ const data = await res.json().catch(() => null)
1687
+ if (!res.ok || !data?.ok) throw new Error(data?.error?.message || `HTTP ${res.status}`)
1688
+ await putConfig({ wallpaperEnabled: true, wallpaperSourceUrl: null })
1689
+ await refreshVersion()
1690
+ setNotice(t('wallpaper.appliedToast'))
1691
+ } catch (err) {
1692
+ setError(err.message)
1693
+ } finally {
1694
+ setUploading(false)
1695
+ }
1696
+ }
1697
+
1698
+ return h(
1699
+ 'div',
1700
+ { className: 'dshwp-section' },
1701
+ h('style', STYLE_ATTRS, styles),
1702
+ h(
1703
+ 'div',
1704
+ { className: 'dshwp-group' },
1705
+ h(
1706
+ SettingsRow,
1707
+ { title: t('settings.wallpaper'), description: t('settings.wallpaperHint') },
1708
+ h(Switch, {
1709
+ on: config.wallpaperEnabled,
1710
+ onChange: (v) => putConfig({ wallpaperEnabled: v }),
1711
+ label: t('settings.wallpaper'),
1712
+ })
1713
+ ),
1714
+ h(
1715
+ SettingsRow,
1716
+ {
1717
+ title: t('wallpaper.fill'),
1718
+ description: t('settings.fillHint'),
1719
+ },
1720
+ h(
1721
+ 'select',
1722
+ {
1723
+ className: 'dshwp-input32',
1724
+ value: settings.fillMode,
1725
+ 'aria-label': t('wallpaper.fill'),
1726
+ onChange: (event) => updateSettings({ fillMode: event.target.value }),
1727
+ },
1728
+ ...WP_FILL_MODES.map((mode) =>
1729
+ h('option', { key: mode, value: mode }, t(`wallpaper.fill.${mode}`))
1730
+ )
1731
+ )
1732
+ ),
1733
+ h(
1734
+ SettingsRow,
1735
+ {
1736
+ title: t('wallpaper.mask'),
1737
+ description: t('settings.maskHint'),
1738
+ },
1739
+ h('input', {
1740
+ type: 'range',
1741
+ min: WP_RANGE.maskOpacity.min,
1742
+ max: WP_RANGE.maskOpacity.max,
1743
+ step: WP_RANGE.maskOpacity.step,
1744
+ value: settings.maskOpacity,
1745
+ className: 'dshwp-range',
1746
+ style: { width: 148 },
1747
+ 'aria-label': t('wallpaper.mask'),
1748
+ onChange: (event) => updateSettings({ maskOpacity: clampSetting('maskOpacity', event.target.value) }),
1749
+ }),
1750
+ h('span', { className: 'dshwp-muted' }, `${Math.round(settings.maskOpacity * 100)}%`)
1751
+ ),
1752
+ h(
1753
+ SettingsRow,
1754
+ { title: t('wallpaper.blur'), description: t('settings.blurHint') },
1755
+ h('input', {
1756
+ type: 'range',
1757
+ min: WP_RANGE.imageBlur.min,
1758
+ max: WP_RANGE.imageBlur.max,
1759
+ step: WP_RANGE.imageBlur.step,
1760
+ value: settings.imageBlur,
1761
+ className: 'dshwp-range',
1762
+ style: { width: 148 },
1763
+ 'aria-label': t('wallpaper.blur'),
1764
+ onChange: (event) => updateSettings({ imageBlur: clampSetting('imageBlur', event.target.value) }),
1765
+ }),
1766
+ h('span', { className: 'dshwp-muted' }, `${settings.imageBlur}px`)
1767
+ ),
1768
+ h(
1769
+ SettingsRow,
1770
+ {
1771
+ title: t('wallpaper.panel'),
1772
+ description: t('settings.panelHint'),
1773
+ },
1774
+ h('input', {
1775
+ type: 'range',
1776
+ min: WP_RANGE.panelOpacity.min,
1777
+ max: WP_RANGE.panelOpacity.max,
1778
+ step: WP_RANGE.panelOpacity.step,
1779
+ value: settings.panelOpacity,
1780
+ className: 'dshwp-range',
1781
+ style: { width: 148 },
1782
+ 'aria-label': t('wallpaper.panel'),
1783
+ onChange: (event) => updateSettings({ panelOpacity: clampSetting('panelOpacity', event.target.value) }),
1784
+ }),
1785
+ h('span', { className: 'dshwp-muted' }, `${Math.round(settings.panelOpacity * 100)}%`)
1786
+ ),
1787
+ h(
1788
+ SettingsRow,
1789
+ { title: t('wallpaper.current'), last: true },
1790
+ h(
1791
+ 'div',
1792
+ { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' } },
1793
+ h(Boundary, null, h(CurrentWallpaper, { store, t })),
1794
+ h(
1795
+ 'button',
1796
+ {
1797
+ type: 'button',
1798
+ className: 'dshwp-btn32',
1799
+ disabled: uploading,
1800
+ onClick: () => fileInputRef.current?.click(),
1801
+ },
1802
+ uploading ? h(Spinner) : t('wallpaper.upload')
1803
+ ),
1804
+ version !== 'none'
1805
+ ? h(
1806
+ 'button',
1807
+ {
1808
+ type: 'button',
1809
+ className: 'dshwp-btn32',
1810
+ onClick: () => {
1811
+ setError(null)
1812
+ apiJson('/api/clear', { method: 'POST' })
1813
+ .then(() => apiJson('/api/config'))
1814
+ .then((data) => {
1815
+ store.setConfig(data.config)
1816
+ return refreshVersion()
1817
+ })
1818
+ .catch((err) => setError(err.message))
1819
+ },
1820
+ },
1821
+ t('wallpaper.remove')
1822
+ )
1823
+ : null
1824
+ )
1825
+ ),
1826
+ h('input', {
1827
+ ref: fileInputRef,
1828
+ type: 'file',
1829
+ accept: 'image/jpeg,image/png,image/webp',
1830
+ style: { display: 'none' },
1831
+ onChange: (event) => {
1832
+ void onUploadFile(event.target.files?.[0])
1833
+ event.target.value = ''
1834
+ },
1835
+ }),
1836
+ error ? h('p', { className: 'dshwp-error', style: { marginTop: 8 } }, error) : null,
1837
+ notice ? h('p', { className: 'dshwp-ok', style: { marginTop: 8 } }, notice) : null
1838
+ ),
1839
+ h(Boundary, null, h(WallpaperMarket, { store, t }))
1840
+ )
1841
+ }
1842
+
1843
+ // ─── Overlay root (the layer itself) ────────────────────────────────────
1844
+
1845
+ function OverlayRoot({ store }) {
1846
+ const hostRef = useRef(null)
1847
+ const config = useStore(store, (s) => s.config)
1848
+ const version = useStore(store, (s) => s.wallpaperVersion)
1849
+ // The layer lives here and reads these live, so the section's sliders
1850
+ // repaint the running layer without going through the Host.
1851
+ const settings = useStore(store, (s) => s.display)
1852
+
1853
+ useConfigSync(store)
1854
+ useWallpaperVersionSync(store, config.wallpaperEnabled)
1855
+ useWallpaperLayer(hostRef, config.wallpaperEnabled, settings, version)
1856
+
1857
+ return h(
1858
+ 'div',
1859
+ { ref: hostRef, style: { display: 'contents' } },
1860
+ h('style', STYLE_ATTRS, styles)
1861
+ )
1862
+ }
1863
+
1864
+ // ─── Plugin entry ───────────────────────────────────────────────────────
1865
+
1866
+ return {
1867
+ inject: ['slots', 'locale'],
1868
+ apply(ctx) {
1869
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'wallpaper: dictionaries')
1870
+ const t = ctx.locale.bind(NS)
1871
+ const store = createStore()
1872
+
1873
+ ctx.slots.inject('shell.overlay', () =>
1874
+ ctx.slots.register(
1875
+ {
1876
+ name: 'shell.overlay',
1877
+ id: 'wallpaper.overlay',
1878
+ locale: NS,
1879
+ inject: () => ({ store, t }),
1880
+ },
1881
+ OverlayRoot
1882
+ )
1883
+ )
1884
+
1885
+ // Settings › 壁纸 — the whole feature lives on this page.
1886
+ ctx.slots.inject('settings.section', () =>
1887
+ ctx.slots.register(
1888
+ {
1889
+ name: 'settings.section',
1890
+ id: 'dsh-wallpaper',
1891
+ order: 41,
1892
+ label: () => t('settings.nav'),
1893
+ locale: NS,
1894
+ inject: () => ({ store, t }),
1895
+ },
1896
+ WallpaperSection
1897
+ )
1898
+ )
1899
+ },
1900
+ }
1901
+ },
1902
+ })