@ash-qw/dsh-theme-prts 1.4.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1 +1 @@
1
- export { PACKAGE_ID, THEME_PLUGIN_ID, inject, apply } from '../src/index.js'
1
+ export { PACKAGE_ID, THEME_PLUGIN_ID, Config, inject, apply } from '../src/index.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ash-qw/dsh-theme-prts",
3
- "version": "1.4.2",
3
+ "version": "2.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Unofficial Arknights P.R.T.S.-inspired UI theme for DeepSeek Harness Web and the official desktop client",
@@ -69,14 +69,16 @@
69
69
  },
70
70
  "engines": {
71
71
  "node": ">=20.9.0",
72
- "dsh": "0.2.0-rc.2"
72
+ "dsh": "0.2.1-rc.2"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@playwright/test": "1.55.1",
76
+ "esbuild": "^0.28.2",
76
77
  "jsdom": "24.1.3",
77
78
  "markdown-it": "^14.3.2"
78
79
  },
79
80
  "dependencies": {
81
+ "@deepseek-ai/schemastery": "~3.18.4",
80
82
  "sharp": "^0.35.5"
81
83
  }
82
84
  }
@@ -106,6 +106,36 @@ export function createAssistantGlassAdapter({ document, window, avatarImage = ''
106
106
  let observationRoot
107
107
  let groupAvatars = true
108
108
  let avatarsDirty = true
109
+ let loadingTransitions = false
110
+ const streamingSteps = new Set()
111
+ const revealAnimations = new Map()
112
+ function cancelReveal(step) {
113
+ const animations = revealAnimations.get(step)
114
+ revealAnimations.delete(step)
115
+ for (const animation of animations ?? []) animation.cancel()
116
+ }
117
+ function revealResponse(step, surface) {
118
+ cancelReveal(step)
119
+ if (!loadingTransitions || !surface?.animate || !surface.isConnected
120
+ || document.documentElement.getAttribute('data-prts-motion') === 'reduced'
121
+ || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
122
+ || step.closest('[hidden], [data-turn-process-hidden="true"]')) return
123
+ const bounds = surface.getBoundingClientRect()
124
+ if (!bounds.width || !bounds.height) return
125
+ const animations = [surface.animate([
126
+ { width: '78px', height: '42px', overflow: 'clip' },
127
+ { width: `${bounds.width}px`, height: `${bounds.height}px`, overflow: 'clip' },
128
+ ], { duration: 220, easing: 'cubic-bezier(.2,.7,.2,1)' })]
129
+ for (const child of surface.children) {
130
+ if (!child.animate) continue
131
+ animations.push(child.animate([{ opacity: 0 }, { opacity: 1 }],
132
+ { duration: 160, delay: 80, fill: 'backwards', easing: 'ease-out' }))
133
+ }
134
+ revealAnimations.set(step, animations)
135
+ void Promise.all(animations.map(animation => animation.finished)).catch(() => {}).then(() => {
136
+ if (revealAnimations.get(step) === animations) revealAnimations.delete(step)
137
+ })
138
+ }
109
139
  const avatarSteps = new Set()
110
140
  const emptySteps = new Set()
111
141
  function refreshAvatars() {
@@ -210,6 +240,8 @@ export function createAssistantGlassAdapter({ document, window, avatarImage = ''
210
240
  }
211
241
 
212
242
  function releaseStep(step) {
243
+ streamingSteps.delete(step)
244
+ cancelReveal(step)
213
245
  const previousSurface = stepSurfaces.get(step)
214
246
  stepSurfaces.delete(step)
215
247
  ownedSteps.delete(step)
@@ -241,6 +273,13 @@ export function createAssistantGlassAdapter({ document, window, avatarImage = ''
241
273
  surface.setAttribute(ASSISTANT_SURFACE_ATTRIBUTE, kind)
242
274
  ownedSurfaces.add(surface)
243
275
  stepSurfaces.set(step, surface)
276
+ const streaming = surface.parentElement?.hasAttribute('data-streaming') === true
277
+ const wasStreaming = streamingSteps.has(step)
278
+ if (streaming) { streamingSteps.add(step); cancelReveal(step) }
279
+ else {
280
+ streamingSteps.delete(step)
281
+ if (wasStreaming) revealResponse(step, surface)
282
+ }
244
283
  }
245
284
 
246
285
  function scan() {
@@ -301,7 +340,9 @@ export function createAssistantGlassAdapter({ document, window, avatarImage = ''
301
340
  }
302
341
 
303
342
  return {
304
- start({ groupAvatars: enabled = true } = {}) {
343
+ start({ groupAvatars: enabled = true, loading = false } = {}) {
344
+ loadingTransitions = loading
345
+ if (!loading) for (const step of [...revealAnimations.keys()]) cancelReveal(step)
305
346
  if (!document) return
306
347
  if (groupAvatars !== enabled) {
307
348
  groupAvatars = enabled
@@ -324,7 +365,7 @@ export function createAssistantGlassAdapter({ document, window, avatarImage = ''
324
365
  childList: true,
325
366
  characterData: true,
326
367
  attributes: true,
327
- attributeFilter: ['class', 'data-chat-flow-kind', 'data-chat-group-part', 'data-markdown', 'data-slot', 'data-chat-turn', 'hidden', 'data-turn-process-hidden', 'data-turn-process-member', 'data-turn-process-answer'],
368
+ attributeFilter: ['data-streaming', 'class', 'data-chat-flow-kind', 'data-chat-group-part', 'data-markdown', 'data-slot', 'data-chat-turn', 'hidden', 'data-turn-process-hidden', 'data-turn-process-member', 'data-turn-process-answer'],
328
369
  })
329
370
  },
330
371
  dispose() {
@@ -335,6 +376,8 @@ export function createAssistantGlassAdapter({ document, window, avatarImage = ''
335
376
  else window?.clearTimeout?.(scanFrame)
336
377
  }
337
378
  scanFrame = undefined
379
+ for (const step of [...revealAnimations.keys()]) cancelReveal(step)
380
+ streamingSteps.clear()
338
381
  removeMarkers()
339
382
  for (const step of avatarSteps) step.removeAttribute('data-prts-avatar-hidden')
340
383
  avatarSteps.clear()
@@ -57,6 +57,13 @@ function markPopup(popup, kind) {
57
57
  const card = popup.closest?.('[data-trigger-menu], [class*="mufS8W_card"]')
58
58
  mark(card, MENU_ATTRIBUTE, kind)
59
59
  for (const nested of popup.querySelectorAll?.('[role="menu"], [role="listbox"], [data-trigger-menu], [class*="mufS8W_card"]') ?? []) {
60
+ // The model picker replaces its contents with a scrolling role=menu.
61
+ // That list is content inside the material shell, not another popup.
62
+ if (kind === 'model' && popup.hasAttribute('data-menu-material')
63
+ && !nested.hasAttribute('data-menu-material')) {
64
+ nested.removeAttribute(MENU_ATTRIBUTE)
65
+ continue
66
+ }
60
67
  mark(nested, MENU_ATTRIBUTE, kind)
61
68
  }
62
69
  }
@@ -111,7 +118,17 @@ export function createComposerGlassAdapter({ document, window }) {
111
118
  }
112
119
 
113
120
  function onMutations(mutations) {
114
- if (mutations.some(mutationTouchesComposer)) queueScan()
121
+ if (!mutations.some(mutationTouchesComposer)) return
122
+ const popupSelector = '[role="menu"], [role="listbox"], [data-trigger-menu]'
123
+ const openingPopup = mutations.some(mutation =>
124
+ (mutation.type === 'attributes' && ['aria-expanded', 'aria-controls'].includes(mutation.attributeName))
125
+ || [...(mutation.addedNodes ?? [])].some(node => node.nodeType === 1
126
+ && (node.matches?.(popupSelector) || node.querySelector?.(popupSelector))),
127
+ )
128
+ // Classify new popups in the mutation microtask, before the first paint.
129
+ // Waiting for another animation frame can expose their native material.
130
+ if (openingPopup) scan()
131
+ else queueScan()
115
132
  }
116
133
 
117
134
  function queueScan() {
@@ -185,6 +202,7 @@ export function createComposerGlassAdapter({ document, window }) {
185
202
  if (trigger.getAttribute('aria-expanded') !== 'true') continue
186
203
  const popup = controlledPopup(document, trigger)
187
204
  ?? localPopup(trigger)
205
+ ?? document.querySelector('[role="menu"]:has([class*="cubgiG_item"])')
188
206
  markPopup(popup, 'preset')
189
207
  }
190
208
 
@@ -217,6 +235,7 @@ export function createComposerGlassAdapter({ document, window }) {
217
235
  ?? document.querySelector('[data-slot="main.conversation"], [data-slot="conversation"]')
218
236
  if (!observationRoot) return
219
237
  observer = new Observer(onMutations)
238
+ if (observationRoot !== document.body) observer.observe(document.body, { childList: true })
220
239
  observer.observe(observationRoot, {
221
240
  subtree: true,
222
241
  childList: true,
@@ -0,0 +1,85 @@
1
+ // Keep host content in place; mount upstream GlassSurface only as a background.
2
+ export function createComposerGlassSurface({document, window, mount}) {
3
+ const surfaces = new Map()
4
+ let observer, resize, frame
5
+ function remove(card, entry) {
6
+ window.clearTimeout(entry.resizeTimer)
7
+ resize?.unobserve(card)
8
+ entry.unmount()
9
+ entry.container.remove()
10
+ card.removeAttribute('data-prts-composer-glass-active')
11
+ surfaces.delete(card)
12
+ }
13
+ function sync() {
14
+ frame = undefined
15
+ const cards = new Set(document.querySelectorAll('[data-composer-card], [data-prts-composer-fallback]'))
16
+ for (const [card, entry] of surfaces) if (!cards.has(card)) remove(card, entry)
17
+ for (const card of cards) {
18
+ // Use layout dimensions, unaffected by host entrance/resize transforms.
19
+ const w = card.offsetWidth
20
+ const h = card.offsetHeight
21
+ if (!w || !h) continue
22
+ let entry = surfaces.get(card)
23
+ if (entry?.width === w && entry?.height === h) continue
24
+ const points = [
25
+ [3,0],[w/2-42,0],[w/2-37,5],[w/2+37,5],[w/2+42,0],
26
+ [w-3,0],[w,3],[w,h-3],[w-3,h],[3,h],[0,h-3],[0,3],
27
+ ].map(([x,y])=>[x+16,y+16].join(',')).join(' ')
28
+ const outline = '<polygon fill="white" points="'+points+'"/>'
29
+ const shape = 'data:image/svg+xml,' + encodeURIComponent(
30
+ '<svg xmlns="http://www.w3.org/2000/svg" width="'+(w+32)+'" height="'+(h+32)+'" viewBox="0 0 '+(w+32)+' '+(h+32)+'">'+outline+'</svg>',
31
+ )
32
+ if (!entry) {
33
+ const container = document.createElement('div')
34
+ container.setAttribute('data-prts-composer-glass-surface', '')
35
+ container.setAttribute('aria-hidden', 'true')
36
+ card.prepend(container)
37
+ entry = {container, shape, width:w, height:h, unmount:mount(container, {shape})}
38
+ surfaces.set(card, entry)
39
+ card.setAttribute('data-prts-composer-glass-active', '')
40
+ resize?.observe(card)
41
+ } else if (entry.shape !== shape) {
42
+ entry.shape = shape
43
+ entry.width = w
44
+ entry.height = h
45
+ // CSS owns the live outline. Rebuild the costly refraction map once
46
+ // resizing settles, instead of decoding a new image on every frame.
47
+ window.clearTimeout(entry.resizeTimer)
48
+ entry.resizeTimer = window.setTimeout(() => {
49
+ entry.resizeTimer = undefined
50
+ entry.unmount.update({shape:entry.shape})
51
+ }, 100)
52
+ }
53
+ }
54
+ }
55
+ function queue() {
56
+ if (frame === undefined) frame = window.requestAnimationFrame(sync)
57
+ }
58
+ return {
59
+ start() {
60
+ if (!mount) return
61
+ document.documentElement.setAttribute('data-prts-composer-enhanced', '')
62
+ if (!observer) {
63
+ resize = new window.ResizeObserver(queue)
64
+ observer = new window.MutationObserver(records => {
65
+ const selector = '[data-composer-card], [data-prts-composer-fallback]'
66
+ if (records.some(record => record.type === 'attributes'
67
+ || [...record.addedNodes, ...record.removedNodes].some(node =>
68
+ node.nodeType === 1 && (node.matches(selector) || node.querySelector(selector))))) queue()
69
+ })
70
+ observer.observe(document.body, {childList:true, subtree:true, attributes:true, attributeFilter:['data-composer-card','data-prts-composer-fallback']})
71
+ }
72
+ sync()
73
+ },
74
+ dispose() {
75
+ document.documentElement.removeAttribute('data-prts-composer-enhanced')
76
+ observer?.disconnect()
77
+ observer = undefined
78
+ resize?.disconnect()
79
+ window.cancelAnimationFrame?.(frame)
80
+ frame = undefined
81
+ for (const [card, entry] of surfaces) remove(card, entry)
82
+ resize = undefined
83
+ },
84
+ }
85
+ }
@@ -0,0 +1,140 @@
1
+ import { opacityDialMarkup, mountOpacityDials } from './opacity-dial.js'
2
+ const BACKGROUND_ENDPOINT = '/prts-theme-api/backgrounds'
3
+ const backgroundLabels = { sidebar: '侧边栏', conversation: '会话正文', full: '完整背景' }
4
+ function backgroundImageUrl(entry) {
5
+ return /^[a-f0-9-]{36}$/.test(entry?.id ?? '') ? `${BACKGROUND_ENDPOINT}/image/${entry.id}` : null
6
+ }
7
+ export function createBackgroundStore({ window, document }) {
8
+ let state = { revision: -1, sidebar: null, conversation: null, full: null }, disposed = false, events, refreshing
9
+ const listeners = new Set()
10
+ const abort = window.AbortController ? new window.AbortController() : null
11
+ const apply = value => {
12
+ if (disposed || value.revision < state.revision) return
13
+ state = value
14
+ for (const slot of Object.keys(backgroundLabels)) {
15
+ const url = backgroundImageUrl(state[slot])
16
+ const root = document.documentElement
17
+ root.toggleAttribute(`data-prts-background-${slot}`, Boolean(url))
18
+ if (url) root.style.setProperty(`--prts-background-${slot}`, `url("${url}")`)
19
+ else root.style.removeProperty(`--prts-background-${slot}`)
20
+ }
21
+ for (const listener of listeners) listener(state)
22
+ }
23
+ const request = async data => {
24
+ const response = await window.fetch(BACKGROUND_ENDPOINT + '/configuration', {
25
+ credentials: 'same-origin', signal: abort?.signal,
26
+ ...(data === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json', 'x-prts-request': '1' }, body: JSON.stringify(data) }),
27
+ })
28
+ const value = await response.json()
29
+ if (!response.ok) throw Error(value.error || '背景服务暂不可用')
30
+ apply(value); return state
31
+ }
32
+ const refresh = () => refreshing ?? (refreshing = request().finally(() => { refreshing = null }))
33
+ if (window.fetch) void refresh().catch(() => {}).then(() => {
34
+ if (disposed || !window.EventSource) return
35
+ events = new window.EventSource(BACKGROUND_ENDPOINT + '/events')
36
+ events.onmessage = event => {
37
+ try {
38
+ const revision = JSON.parse(event.data).revision
39
+ if (revision > state.revision) void refresh().then(() => {
40
+ if (!disposed && revision > state.revision) return refresh()
41
+ }).catch(() => {})
42
+ } catch {}
43
+ }
44
+ })
45
+ const focus = () => { if (window.fetch) void refresh().catch(() => {}) }
46
+ window.addEventListener('focus', focus)
47
+ return {
48
+ snapshot: () => state, refresh,
49
+ subscribe(fn) { listeners.add(fn); fn(state); return () => listeners.delete(fn) },
50
+ async save(slot, image, revision = state.revision) {
51
+ try { return await request({ slot, image, revision }) }
52
+ catch (error) { await refresh().catch(() => {}); throw error }
53
+ },
54
+ dispose() {
55
+ disposed = true; abort?.abort(); events?.close(); listeners.clear(); window.removeEventListener('focus', focus)
56
+ for (const slot of Object.keys(backgroundLabels)) {
57
+ document.documentElement.removeAttribute(`data-prts-background-${slot}`)
58
+ document.documentElement.style.removeProperty(`--prts-background-${slot}`)
59
+ }
60
+ },
61
+ }
62
+ }
63
+ export function backgroundMarkup() {
64
+ return `<section data-prts-background-settings>
65
+ <header><strong>会话背景</strong><small>分区使用不同图片,或让一张完整背景贯穿侧边栏与正文。</small></header>
66
+ <div data-prts-background-modes role="group" aria-label="背景模式">
67
+ ${[['off','默认背景','使用主题原有底色'],['split','分区背景','侧栏与正文独立配置'],['full','完整背景','一张图片贯穿整个界面']].map(([value,label,hint]) => `<button type="button" data-prts-setting-key="backgroundMode" data-prts-setting-value="${value}" aria-pressed="false"><span data-prts-background-diagram="${value}" aria-hidden="true"><i></i><i></i></span><span><strong>${label}</strong><small>${hint}</small></span></button>`).join('')}
68
+ </div>
69
+ <div data-prts-background-images hidden>${Object.entries(backgroundLabels).map(([slot,label]) => `<article data-prts-background-card="${slot}" hidden>
70
+ <div data-prts-background-card-heading><strong>${label}</strong><button type="button" data-prts-background-remove="${slot}" aria-label="移除${label}图片" hidden>移除</button></div>
71
+ <button type="button" data-prts-background-upload="${slot}" aria-label="上传或替换${label}"><span data-prts-background-thumbnail="${slot}" aria-hidden="true"></span><span data-prts-background-caption="${slot}">+ 选择图片</span></button>
72
+ <input type="file" data-prts-background-file="${slot}" accept="image/png,image/jpeg,image/webp,image/avif" hidden>
73
+ </article>`).join('')}</div>
74
+ <label data-prts-background-shade hidden><span>背景淡化 <output></output></span><input type="range" min="0" max="90" step="5" aria-label="背景淡化程度"><small>调高后背景更淡,接近当前主题底色。</small></label>
75
+ ${opacityDialMarkup()}
76
+ <small data-prts-background-help>支持 PNG / JPEG / WebP / AVIF · 最大 10 MB<br>图片居中铺满,切换模式保留素材,并在当前 DSH 实例同步。</small><p data-prts-background-status role="status"></p>
77
+ </section>`
78
+ }
79
+ export function mountBackgroundSettings({ panel, window, store, onChange }) {
80
+ const section = panel.querySelector('[data-prts-background-settings]')
81
+ if (!section || !store) return { update() {}, dispose() {} }
82
+ let disposed = false, busy = false, preferences = {}, state = store.snapshot()
83
+ const status = section.querySelector('[data-prts-background-status]')
84
+ const range = section.querySelector('input[type="range"]')
85
+ const dials = mountOpacityDials({ section, window, onChange })
86
+ function render() {
87
+ dials.update(preferences, state)
88
+ const mode = preferences.backgroundMode || 'off'
89
+ section.querySelector('[data-prts-background-images]').hidden = mode === 'off'
90
+ section.querySelector('[data-prts-background-shade]').hidden = mode === 'off'
91
+ range.value = String(Math.round((preferences.backgroundDim ?? .45) * 100))
92
+ section.querySelector('output').textContent = `${range.value}%`
93
+ for (const slot of Object.keys(backgroundLabels)) {
94
+ const url = backgroundImageUrl(state[slot])
95
+ section.querySelector(`[data-prts-background-card="${slot}"]`).hidden = mode === 'off' || (mode === 'full') !== (slot === 'full')
96
+ section.querySelector(`[data-prts-background-upload="${slot}"]`).toggleAttribute('data-has-image', Boolean(url))
97
+ section.querySelector(`[data-prts-background-thumbnail="${slot}"]`).style.backgroundImage = url ? `url("${url}")` : ''
98
+ section.querySelector(`[data-prts-background-caption="${slot}"]`).textContent = url ? '替换图片' : '+ 选择图片'
99
+ section.querySelector(`[data-prts-background-remove="${slot}"]`).hidden = !url
100
+ }
101
+ for (const button of section.querySelectorAll('[data-prts-background-upload], [data-prts-background-remove]')) button.disabled = busy
102
+ }
103
+ async function save(slot, file) {
104
+ if (busy) return
105
+ if (file && file.size > 10 * 1024 * 1024) { status.textContent = '请上传不超过 10 MB 的图片'; return }
106
+ const revision = state.revision
107
+ busy = true; status.textContent = '正在保存背景…'; render()
108
+ try {
109
+ let image = null
110
+ if (file) image = await new Promise((resolve, reject) => {
111
+ const reader = new window.FileReader()
112
+ reader.onload = () => resolve(String(reader.result).split(',')[1])
113
+ reader.onerror = () => reject(Error('无法读取图片'))
114
+ reader.readAsDataURL(file)
115
+ })
116
+ if (disposed) return
117
+ await store.save(slot, image, revision)
118
+ if (!disposed) status.textContent = file ? '背景已保存,并同步到其他页面。' : '图片已移除。'
119
+ } catch (error) { if (!disposed) status.textContent = error.message }
120
+ finally { busy = false; if (!disposed) render() }
121
+ }
122
+ const click = event => {
123
+ const upload = event.target.closest('[data-prts-background-upload]')
124
+ if (upload) section.querySelector(`[data-prts-background-file="${upload.dataset.prtsBackgroundUpload}"]`).click()
125
+ const remove = event.target.closest('[data-prts-background-remove]')
126
+ if (remove) void save(remove.dataset.prtsBackgroundRemove, null)
127
+ }
128
+ const change = event => {
129
+ if (event.target === range) onChange('backgroundDim', Number(range.value) / 100)
130
+ const slot = event.target.dataset.prtsBackgroundFile
131
+ if (slot && event.target.files?.[0]) { void save(slot, event.target.files[0]); event.target.value = '' }
132
+ }
133
+ const input = event => { if (event.target === range) section.querySelector('output').textContent = `${range.value}%` }
134
+ section.addEventListener('click', click); section.addEventListener('change', change); section.addEventListener('input', input)
135
+ const unsubscribe = store.subscribe(value => { state = value; render() })
136
+ return {
137
+ update(value) { preferences = value; render() },
138
+ dispose() { disposed = true; dials.dispose(); unsubscribe(); section.removeEventListener('click', click); section.removeEventListener('change', change); section.removeEventListener('input', input) },
139
+ }
140
+ }
@@ -1,7 +1,10 @@
1
+ import { createBackgroundStore } from './custom-background.js'
2
+ import { createNativePreferences } from './native-preferences.js'
3
+ import { createComposerGlassSurface } from './composer-glass-surface.js'
1
4
  import { createAvatarStore } from './custom-avatar.js'
2
5
  import { registerEmblemToolPreview } from './emblem-tool-preview.js'
3
6
  import { createCustomEmblemStore } from './custom-emblem.js'
4
- import { DEFAULT_PREFERENCES, isSafeMode, loadPreferences, persistPreferences, resetPreferenceGroup, updatePreferenceValue } from './preferences.js'
7
+ import { DEFAULT_PREFERENCES, isSafeMode, loadPreferences, resetPreferenceGroup, updatePreferenceValue } from './preferences.js'
5
8
  import { createHostGeometryAdapter } from './host-geometry-adapter.js'
6
9
  import { createComposerGlassAdapter } from './composer-glass-adapter.js'
7
10
  import { createAssistantGlassAdapter } from './assistant-glass-adapter.js'
@@ -44,6 +47,7 @@ export function applyPrtsPlugin(ctx, environment) {
44
47
  version = 'development',
45
48
  startupTimings,
46
49
  } = environment
50
+ const composerSurface = createComposerGlassSurface({document, window, React, mount: environment.mountComposerGlassSurface})
47
51
  const safeMode = isSafeMode(window?.location?.search ?? '')
48
52
  const stopEmblemPreview = safeMode ? null : registerEmblemToolPreview(ctx, React)
49
53
  const storage = window?.localStorage
@@ -65,6 +69,7 @@ export function applyPrtsPlugin(ctx, environment) {
65
69
  sync()
66
70
  },
67
71
  })
72
+ const backgroundStore = createBackgroundStore({window,document})
68
73
  const avatarStore = createAvatarStore({window,document})
69
74
  const customEmblemStore = createCustomEmblemStore({ window, emblems: assets.emblems, preview: entry => particleField.previewEmblem?.(entry), onChange: value => particleField.setCustomEmblemConfiguration?.(value) })
70
75
  const startup = createPrtsStartupSequence({
@@ -123,7 +128,7 @@ export function applyPrtsPlugin(ctx, environment) {
123
128
 
124
129
  const sync = () => {
125
130
  revision += 1
126
- for (const actions of bindings) actions.sync({ ...preferences }, { ...status }, revision)
131
+ for (const actions of bindings) actions.sync({ ...preferences }, { ...status, settingsPersistence }, revision)
127
132
  }
128
133
 
129
134
  const cancelMountFrame = () => {
@@ -183,6 +188,7 @@ export function applyPrtsPlugin(ctx, environment) {
183
188
  conversationControls.dispose()
184
189
  layoutDiagnostics.dispose()
185
190
  sidebarControls.dispose()
191
+ composerSurface.dispose()
186
192
  floatingGlass.dispose()
187
193
  theme.apply({ ...preferences, enabled: false })
188
194
  }
@@ -194,7 +200,7 @@ export function applyPrtsPlugin(ctx, environment) {
194
200
  }
195
201
  if (!themeAlreadyApplied) theme.apply(preferences)
196
202
  operations ||= createOperationsShell({
197
- document, window, assets, adapter, hostGeometry, customEmblemStore, avatarStore,
203
+ document, window, assets, adapter, hostGeometry, customEmblemStore, avatarStore, backgroundStore,
198
204
  onSchemeToggle(next, interaction) {
199
205
  return theme.setTheme(next, { ...interaction, animate: true })
200
206
  },
@@ -253,41 +259,37 @@ export function applyPrtsPlugin(ctx, environment) {
253
259
  }
254
260
  particleField.update(preferences)
255
261
  composerGlass.start()
256
- if (preferences.conversationStyle === 'deck-chat') assistantGlass.start({ groupAvatars: preferences.bakerGroupAvatars })
262
+ if (preferences.conversationStyle === 'deck-chat') assistantGlass.start({ groupAvatars: preferences.bakerGroupAvatars, loading: preferences.bakerLoading })
257
263
  else assistantGlass.dispose()
258
264
  toBottom.start()
259
265
  conversationControls.start()
260
266
  layoutDiagnostics.start()
261
267
  sidebarControls.start(preferences)
268
+ if (preferences.composerGlass || preferences.backgroundMode !== 'off') composerSurface.start()
269
+ else composerSurface.dispose()
262
270
  floatingGlass.start()
263
271
  }
264
272
 
265
- const persistAndApply = next => {
266
- const wasEnabled = Boolean(preferences.enabled)
267
- const result = persistPreferences(storage, next)
268
- preferences = result.preferences
269
- settingsPersistence = {
270
- phase: result.persisted ? 'saved' : 'error',
271
- revision: settingsPersistence.revision + 1,
272
- }
273
- const shouldPlayStartup = !safeMode && !wasEnabled && preferences.enabled && preferences.bootAnimation
274
- if (shouldPlayStartup) particleField.setStartupActive(true)
275
- applyVisualState()
276
- sync()
277
- if (shouldPlayStartup && !startup.play()) particleField.setStartupActive(false)
278
- }
279
-
280
-
281
- const retryPersistence = () => {
282
- const result = persistPreferences(storage, preferences)
283
- preferences = result.preferences
284
- settingsPersistence = {
285
- phase: result.persisted ? 'saved' : 'error',
286
- revision: settingsPersistence.revision + 1,
287
- }
288
- operations?.update(preferences, status, settingsPersistence)
289
- sync()
290
- }
273
+ const nativePreferences = createNativePreferences({
274
+ storage,
275
+ onChange(next, persistence) {
276
+ const changed = JSON.stringify(preferences) !== JSON.stringify(next)
277
+ const wasEnabled = preferences.enabled
278
+ preferences = next
279
+ settingsPersistence = persistence
280
+ if (changed) {
281
+ const play = !safeMode && !wasEnabled && preferences.enabled && preferences.bootAnimation
282
+ if (play) particleField.setStartupActive(true)
283
+ applyVisualState()
284
+ if (play && !startup.play()) particleField.setStartupActive(false)
285
+ }
286
+ operations?.update(preferences, status, settingsPersistence)
287
+ sync()
288
+ },
289
+ })
290
+ preferences = nativePreferences.value
291
+ const persistAndApply = next => nativePreferences.update(next)
292
+ const retryPersistence = () => nativePreferences.retry()
291
293
  const statusSource = createHarnessStatusSource({
292
294
  connection,
293
295
  onChange(next) {
@@ -345,11 +347,19 @@ export function applyPrtsPlugin(ctx, environment) {
345
347
  } else {
346
348
  applyVisualState()
347
349
  }
350
+ const bindNativePreferences = settingsCtx => {
351
+ const forms = contextService(settingsCtx, 'configForms')
352
+ if (forms) settingsCtx.effect(() => nativePreferences.bind(forms.get('theme-prts')))
353
+ }
354
+ if (typeof ctx.inject === 'function') ctx.inject(['configForms'], bindNativePreferences)
355
+ else bindNativePreferences(ctx)
348
356
  ctx.effect(() => () => {
349
357
  if (typeof stopEmblemPreview === 'function') stopEmblemPreview()
358
+ nativePreferences.dispose()
350
359
  statusSource.dispose()
351
360
  customEmblemStore.dispose()
352
361
  avatarStore.dispose()
362
+ backgroundStore.dispose()
353
363
  particleField.dispose()
354
364
  startup.dispose({ restoreFocus: false })
355
365
  stopStartupReadyWatch()
@@ -361,13 +371,14 @@ export function applyPrtsPlugin(ctx, environment) {
361
371
  conversationControls.dispose()
362
372
  layoutDiagnostics.dispose()
363
373
  sidebarControls.dispose()
374
+ composerSurface.dispose()
364
375
  floatingGlass.dispose()
365
376
  if (typeof stopThemeSync === 'function') stopThemeSync()
366
377
  theme.dispose()
367
378
  bindings.clear()
368
379
  }, 'dsh-theme-prts: visual cleanup')
369
380
 
370
- const initialStoreState = { preferences, status, revision }
381
+ const initialStoreState = { preferences, status: { ...status, settingsPersistence }, revision }
371
382
  const settingsStore = createPrtsUiStore(defineStore, initialStoreState)
372
383
  const PrtsSettingsPage = createSettingsPage(React, {
373
384
  emblem: assets.emblem,
@@ -378,6 +389,7 @@ export function applyPrtsPlugin(ctx, environment) {
378
389
  bindings.add(actions)
379
390
  sync()
380
391
  return {
392
+ retryPersistence,
381
393
  updatePreference(key, value) { persistAndApply(updatePreferenceValue(preferences, key, value)) },
382
394
  resetPreferenceGroup(group) {
383
395
  persistAndApply(resetPreferenceGroup(preferences, group))
@@ -410,6 +422,21 @@ export function apply(ctx) {
410
422
  window,
411
423
  React,
412
424
  defineStore,
425
+ mountComposerGlassSurface(container, props) {
426
+ const { createRoot } = require('react-dom/client')
427
+ const root = createRoot(container)
428
+ const update = props => root.render(React.createElement(PrtsGlassSurface.default, {
429
+ width: '100%', height: '100%',
430
+ // Gentle edge refraction; a fully calm center avoids shifting input content.
431
+ distortionScale: -18, redOffset: 0, greenOffset: 1, blueOffset: 2,
432
+ opacity: 1, backgroundOpacity: 0.5, displace: 5,
433
+ ...props,
434
+ }))
435
+ update(props)
436
+ const dispose = () => root.unmount()
437
+ dispose.update = update
438
+ return dispose
439
+ },
413
440
  cssText: PRTS_CSS,
414
441
  assets: PRTS_ASSETS,
415
442
  version: PRTS_VERSION,