@ash-qw/dsh-theme-prts 0.1.91

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.
Files changed (50) hide show
  1. package/LICENSE +24 -0
  2. package/README.en.md +103 -0
  3. package/README.md +107 -0
  4. package/THIRD_PARTY_NOTICES.md +48 -0
  5. package/cordis.patch.yml +4 -0
  6. package/lib/client.js +7466 -0
  7. package/lib/index.js +3 -0
  8. package/package.json +64 -0
  9. package/src/assets/faction-emblems/lungmen.png +0 -0
  10. package/src/assets/faction-emblems/penguin-logistics.png +0 -0
  11. package/src/assets/faction-emblems/reunion.png +0 -0
  12. package/src/assets/faction-emblems/rhine-lab.png +0 -0
  13. package/src/assets/faction-emblems/rhodes-island.png +0 -0
  14. package/src/assets/prts-original-avatar.png +0 -0
  15. package/src/assets/rhodes-dsh.svg +6 -0
  16. package/src/assets/topographic-map.svg +72 -0
  17. package/src/assets/topographic-tech.svg +19 -0
  18. package/src/client/assistant-glass-adapter.js +231 -0
  19. package/src/client/composer-glass-adapter.js +210 -0
  20. package/src/client/conversation-control-adapter.js +109 -0
  21. package/src/client/conversation-scale-adapter.js +2243 -0
  22. package/src/client/facility-geometry.js +60 -0
  23. package/src/client/facility-vector.js +230 -0
  24. package/src/client/floating-glass-adapter.js +177 -0
  25. package/src/client/host-geometry-adapter.js +123 -0
  26. package/src/client/index.js +359 -0
  27. package/src/client/layout-stability-diagnostics.js +162 -0
  28. package/src/client/operations-shell-v2.js +248 -0
  29. package/src/client/particle-field-adapter.js +1840 -0
  30. package/src/client/preferences.js +239 -0
  31. package/src/client/rc7-adapter.js +83 -0
  32. package/src/client/resize-shield-adapter.js +64 -0
  33. package/src/client/settings-page.js +62 -0
  34. package/src/client/sidebar-control-adapter.js +317 -0
  35. package/src/client/startup-sequence.js +366 -0
  36. package/src/client/status-projection.js +79 -0
  37. package/src/client/terrain-motion-adapter.js +188 -0
  38. package/src/client/theme-controller-v2.js +134 -0
  39. package/src/client/theme-controller.js +119 -0
  40. package/src/client/theme-settings-workbench.js +583 -0
  41. package/src/client/to-bottom-adapter.js +85 -0
  42. package/src/client/ui-store.js +23 -0
  43. package/src/index.js +4 -0
  44. package/src/styles/conversation-scale.css +471 -0
  45. package/src/styles/facility-vector.css +292 -0
  46. package/src/styles/prts.css +1223 -0
  47. package/src/styles/refinement.css +429 -0
  48. package/src/styles/stabilization.css +161 -0
  49. package/src/styles/startup-sequence.css +496 -0
  50. package/src/styles/theme-settings-workbench.css +803 -0
@@ -0,0 +1,583 @@
1
+ import { CONVERSATION_SCALE_FOCUS_CONTRAST_DEFAULT, CONVERSATION_SCALE_FOCUS_CONTRAST_MAX, CONVERSATION_SCALE_FOCUS_CONTRAST_MIN, CONVERSATION_SCALE_FOCUS_CONTRAST_STEP, resolveParticleDetail } from './preferences.js'
2
+ import { resolveConversationScaleLengths } from './conversation-scale-adapter.js'
3
+
4
+ const SETTINGS_FOCUSABLE = [
5
+ 'button:not([disabled])',
6
+ 'input:not([disabled])',
7
+ 'summary',
8
+ '[href]',
9
+ '[tabindex]:not([tabindex="-1"])',
10
+ ].join(',')
11
+
12
+ const PRESET_OPTIONS = [
13
+ ['standard-tactical', '标准战术', '完整底纹 / 标准玻璃 / 系统动效'],
14
+ ['clear-glass', '清晰玻璃', '克制底纹 / 清晰玻璃 / 系统动效'],
15
+ ['quiet-reading', '静谧阅读', '克制底纹 / 柔和玻璃 / 减少动效'],
16
+ ]
17
+
18
+ const COMMON_CONTROLS = [
19
+ ['texture', '环境底纹', [['off', '关闭'], ['restrained', '克制'], ['full', '完整']]],
20
+ ['glass', '玻璃材质', [['off', '关闭'], ['soft', '柔和'], ['standard', '标准'], ['clear', '清晰']]],
21
+ ['motion', '动态效果', [['system', '跟随系统'], ['reduced', '减少']]],
22
+ ]
23
+
24
+ const PARTICLE_OPTIONS = [
25
+ ['compact', '精简', 7],
26
+ ['standard', '标准', 12],
27
+ ['precise', '精细', 18],
28
+ ]
29
+
30
+ function settingButtons(key, label, options) {
31
+ const note = key === 'glass'
32
+ ? '<small data-prts-setting-note data-prts-transparency-status hidden>系统当前要求降低透明度</small>'
33
+ : key === 'motion'
34
+ ? '<small data-prts-setting-note data-prts-effective-motion hidden>系统当前要求减少动态</small>'
35
+ : ''
36
+ const sample = value => key === 'texture' || key === 'glass'
37
+ ? `<i data-prts-option-sample="${key}" data-prts-option-sample-value="${value}" aria-hidden="true"></i>`
38
+ : ''
39
+ return `<div data-prts-setting-row="${key}">
40
+ <span><strong>${label}</strong>${note}</span>
41
+ <div role="group" aria-label="${label}">${options.map(([value, text]) => `<button type="button" data-prts-setting-key="${key}" data-prts-setting-value="${value}">${sample(value)}<span>${text}</span></button>`).join('')}</div>
42
+ </div>`
43
+ }
44
+
45
+ function presetMarkup() {
46
+ return PRESET_OPTIONS.map(([value, title, description]) => `<button type="button" data-prts-setting-key="preset" data-prts-setting-value="${value}" data-prts-preset-card>
47
+ <strong>${title}</strong><span>${description}</span>
48
+ </button>`).join('')
49
+ }
50
+
51
+ function particleMarkup() {
52
+ return PARTICLE_OPTIONS.map(([value, label, count]) => `<button type="button" role="radio" data-prts-setting-key="particleDetail" data-prts-setting-value="${value}">
53
+ <i data-prts-particle-detail-preview aria-hidden="true">${Array.from({ length: count }, () => '<b></b>').join('')}</i><span>${label}</span>
54
+ </button>`).join('')
55
+ }
56
+
57
+ function calibrationMarkup() {
58
+ return `<div data-prts-scale-calibration data-prts-calibration-mode="hidden" data-prts-calibration-reason="unmeasured">
59
+ <div data-prts-scale-calibration-viewport>
60
+ <div data-prts-scale-calibration-canvas aria-hidden="true">
61
+ <i data-prts-scale-calibration-sidebar></i>
62
+ <span data-prts-scale-calibration-measure><i></i><output data-prts-scale-calibration-value>96 px</output></span>
63
+ <span data-prts-scale-calibration-rail>${Array.from({ length: 9 }, (_, index) => `<i style="--prts-calibration-tick:${index}"></i>`).join('')}</span>
64
+ <i data-prts-scale-calibration-gap></i>
65
+ <span data-prts-scale-calibration-content><i></i><small>会话内容区</small></span>
66
+ </div>
67
+ </div>
68
+ <div data-prts-scale-calibration-meta>
69
+ <span data-prts-scale-calibration-scale hidden>缩放预览</span>
70
+ <output data-prts-scale-calibration-status aria-live="polite">当前视图:几何测量中</output>
71
+ </div>
72
+ </div>`
73
+ }
74
+
75
+ export function themeSettingsMarkup() {
76
+ return `<div data-prts-settings-backdrop hidden aria-hidden="true"></div>
77
+ <dialog id="prts-theme-settings" data-prts-theme-settings aria-label="P.R.T.S. 主题设置" aria-hidden="true">
78
+ <div data-prts-settings-frame>
79
+ <header data-prts-theme-settings-header>
80
+ <span><small>P.R.T.S. / 外观</small><strong>主题设置</strong></span>
81
+ <div data-prts-settings-summary aria-live="polite">
82
+ <span data-prts-settings-preset-status>标准战术</span>
83
+ <span data-prts-persistence-status hidden></span>
84
+ </div>
85
+ <button type="button" data-prts-theme-settings-close aria-label="关闭主题设置">×</button>
86
+ </header>
87
+
88
+ <div data-prts-theme-settings-scroll>
89
+ <section data-prts-settings-presets>
90
+ <header><span><small>快速配置</small><strong>视觉预设</strong></span></header>
91
+ <div>${presetMarkup()}</div>
92
+ </section>
93
+
94
+ <section data-prts-settings-common>
95
+ <header><span><small>常用设置</small><strong>主题表现</strong></span></header>
96
+ <div data-prts-settings-common-grid>${COMMON_CONTROLS.map(([key, label, options]) => settingButtons(key, label, options)).join('')}</div>
97
+ </section>
98
+
99
+ <details data-prts-settings-advanced>
100
+ <summary><span><small>精细调整</small><strong>粒子与会话刻度</strong></span><output data-prts-advanced-summary>标准 / 96 px</output></summary>
101
+ <div data-prts-settings-advanced-content>
102
+ <section data-prts-particle-detail>
103
+ <header><strong>粒子精度</strong><span>同时调整会话与新会话徽记</span></header>
104
+ <div role="radiogroup" aria-label="粒子精度">${particleMarkup()}</div>
105
+ <span data-prts-particle-error role="alert" hidden>粒子资源加载异常,主题已使用静态后备效果</span>
106
+ </section>
107
+ <section data-prts-scale-adjustment>
108
+ <header><strong>会话刻度</strong><span>调整位置距离与悬停焦点的视觉区分</span></header>
109
+ ${calibrationMarkup()}
110
+ <label data-prts-setting-row="conversationScaleMaxDistance" data-prts-setting-range-row>
111
+ <span>最大距离</span>
112
+ <div><input type="range" min="16" max="240" step="8" value="96" data-prts-setting-range data-prts-setting-key="conversationScaleMaxDistance" aria-label="会话刻度最大距离"><output data-prts-scale-distance-output>96 px</output></div>
113
+ </label>
114
+ <label data-prts-setting-row="conversationScaleFocusContrast" data-prts-setting-range-row>
115
+ <span>聚焦区分度</span>
116
+ <div data-prts-scale-focus-control>
117
+ <span data-prts-scale-focus-preview aria-hidden="true">${Array.from({ length: 5 }, (_, index) => `<i data-prts-scale-focus-preview-tick="${index}"></i>`).join('')}</span>
118
+ <input type="range" min="0" max="100" step="10" value="70" data-prts-setting-range data-prts-setting-key="conversationScaleFocusContrast" aria-label="会话刻度聚焦区分度">
119
+ <output data-prts-scale-focus-output>70</output>
120
+ </div>
121
+ </label>
122
+ </section>
123
+ </div>
124
+ </details>
125
+
126
+ <footer data-prts-settings-actions>
127
+ <div data-prts-persistence-error hidden role="alert">
128
+ <span><strong>仅本次会话生效</strong><small>无法写入本地设置,当前视觉效果仍会保留。</small></span>
129
+ <button type="button" data-prts-retry-save>重试保存</button>
130
+ </div>
131
+ <div data-prts-reset-zone>
132
+ <span><strong>恢复主题默认</strong><small>保留主题开关和启动动画设置</small></span>
133
+ <button type="button" data-prts-reset-visual>恢复默认</button>
134
+ <div data-prts-reset-confirm hidden role="group" aria-label="确认恢复主题默认">
135
+ <span>确认恢复全部主题参数?</span>
136
+ <button type="button" data-prts-reset-confirm-action>确认恢复</button>
137
+ <button type="button" data-prts-reset-cancel>取消</button>
138
+ </div>
139
+ </div>
140
+ </footer>
141
+ </div>
142
+ </div>
143
+ </dialog>`
144
+ }
145
+
146
+ export function createThemeSettingsOverlay({
147
+ document,
148
+ trigger,
149
+ panel,
150
+ backdrop,
151
+ onOpen = () => {},
152
+ onPreferenceChange = () => {},
153
+ onResetVisual = () => {},
154
+ onRetrySave = () => {},
155
+ getConversationScalePreview = () => ({ visible: false, reason: 'unmeasured', mode: 'hidden' }),
156
+ }) {
157
+ const window = document?.defaultView
158
+ const transparencyMedia = window?.matchMedia?.('(prefers-reduced-transparency: reduce)')
159
+ const motionMedia = window?.matchMedia?.('(prefers-reduced-motion: reduce)')
160
+ const nativeModal = typeof panel?.showModal === 'function' && typeof panel?.close === 'function'
161
+ let isOpen = false
162
+ let previousFocus
163
+ let currentPreferences
164
+ let currentStatus
165
+ let currentPersistence = { phase: 'idle', revision: 0 }
166
+ let scaleDistanceDraft
167
+ let scaleDistanceDirty = false
168
+ let scaleFocusContrastDraft
169
+ let scaleFocusContrastDirty = false
170
+ let calibrationResizeObserver
171
+ let calibrationSettleTimer
172
+ let calibrationFrame
173
+ let calibrationViewportWidth
174
+ let calibrationFactor
175
+ let savedTimer
176
+ let feedbackTimer
177
+ let feedbackTarget
178
+ let renderedPersistenceRevision = -1
179
+
180
+ function focusable() {
181
+ return [...(panel?.querySelectorAll?.(SETTINGS_FOCUSABLE) ?? [])].filter(node => !node.hidden && !node.disabled && node.closest('[hidden]') === null)
182
+ }
183
+
184
+ function reducedMotion() {
185
+ return document?.documentElement?.dataset?.prtsMotion === 'reduced' || motionMedia?.matches
186
+ }
187
+
188
+ function showPanel() {
189
+ if (nativeModal) {
190
+ if (!panel.open) panel.showModal()
191
+ backdrop.hidden = true
192
+ return
193
+ }
194
+ panel.setAttribute('open', '')
195
+ panel.setAttribute('data-prts-dialog-fallback', '')
196
+ backdrop.hidden = false
197
+ backdrop.setAttribute('data-prts-settings-visible', '')
198
+ }
199
+
200
+ function hidePanel() {
201
+ if (nativeModal) {
202
+ if (panel.open) panel.close()
203
+ } else {
204
+ panel.removeAttribute('open')
205
+ panel.removeAttribute('data-prts-dialog-fallback')
206
+ backdrop.removeAttribute('data-prts-settings-visible')
207
+ backdrop.hidden = true
208
+ }
209
+ }
210
+
211
+ function setResetConfirmation(visible) {
212
+ const confirmation = panel?.querySelector?.('[data-prts-reset-confirm]')
213
+ const reset = panel?.querySelector?.('[data-prts-reset-visual]')
214
+ if (confirmation) confirmation.hidden = !visible
215
+ if (reset) reset.hidden = visible
216
+ }
217
+
218
+ function setOpen(next, restoreFocus = true) {
219
+ if (!panel || !backdrop || !trigger) return false
220
+ const value = Boolean(next)
221
+ if (value === isOpen) return isOpen
222
+ if (!value) {
223
+ commitScaleDistance()
224
+ commitScaleFocusContrast()
225
+ }
226
+ isOpen = value
227
+ trigger.setAttribute('aria-expanded', String(isOpen))
228
+ document.documentElement.toggleAttribute('data-prts-settings-open', isOpen)
229
+ panel.setAttribute('aria-hidden', String(!isOpen))
230
+ if (isOpen) {
231
+ previousFocus = document.activeElement
232
+ onOpen()
233
+ showPanel()
234
+ panel.setAttribute('data-prts-settings-visible', '')
235
+ renderScaleCalibration()
236
+ ;(panel.querySelector('[data-prts-theme-settings-close]') ?? focusable()[0] ?? panel).focus?.()
237
+ } else {
238
+ panel.removeAttribute('data-prts-settings-visible')
239
+ hidePanel()
240
+ setResetConfirmation(false)
241
+ if (restoreFocus) (trigger.isConnected ? trigger : previousFocus)?.focus?.()
242
+ }
243
+ return isOpen
244
+ }
245
+
246
+ function normalizeScaleDistance(value) {
247
+ const numeric = Number(value)
248
+ if (!Number.isFinite(numeric)) return 96
249
+ return Math.min(240, Math.max(16, Math.round(numeric / 8) * 8))
250
+ }
251
+
252
+ function currentScaleDistance() {
253
+ return normalizeScaleDistance(scaleDistanceDirty ? scaleDistanceDraft : currentPreferences?.conversationScaleMaxDistance)
254
+ }
255
+
256
+ function normalizeScaleFocusContrast(value) {
257
+ const numeric = Number(value)
258
+ if (!Number.isFinite(numeric)) return CONVERSATION_SCALE_FOCUS_CONTRAST_DEFAULT
259
+ const clamped = Math.min(CONVERSATION_SCALE_FOCUS_CONTRAST_MAX, Math.max(CONVERSATION_SCALE_FOCUS_CONTRAST_MIN, numeric))
260
+ return Math.round(clamped / CONVERSATION_SCALE_FOCUS_CONTRAST_STEP) * CONVERSATION_SCALE_FOCUS_CONTRAST_STEP
261
+ }
262
+
263
+ function currentScaleFocusContrast() {
264
+ return normalizeScaleFocusContrast(scaleFocusContrastDirty ? scaleFocusContrastDraft : currentPreferences?.conversationScaleFocusContrast)
265
+ }
266
+
267
+ function calibrationStatus(state, value) {
268
+ if (state?.mode === 'configured') return `当前视图:使用配置上限 / ${Math.round(Number(state.left) || value)} px`
269
+ if (state?.simulated) return `模拟布局:使用配置上限 / ${value} px`
270
+ if (state?.mode === 'centered') return `当前视图:自动居中 / 距侧栏 ${Math.round(Number(state.left) || 0)} px`
271
+ const reasons = {
272
+ drawer: '抽屉布局', phone: '手机布局', turns: '消息不足', corridor: '会话走廊不足',
273
+ preview: '会话预览空间不足', unmeasured: '模拟布局',
274
+ }
275
+ return `当前视图:隐藏 / ${reasons[state?.reason] || '空间不足'}`
276
+ }
277
+
278
+ function setText(node, value) {
279
+ if (node && node.textContent !== value) node.textContent = value
280
+ }
281
+
282
+ function setCalibrationResizeState(active) {
283
+ for (const node of [panel, backdrop]) {
284
+ if (!node || node.hasAttribute('data-prts-resizing') === active) continue
285
+ node.toggleAttribute('data-prts-resizing', active)
286
+ }
287
+ }
288
+
289
+ function cancelCalibrationFrame() {
290
+ if (calibrationFrame === undefined) return
291
+ window?.cancelAnimationFrame?.(calibrationFrame)
292
+ window?.clearTimeout?.(calibrationFrame)
293
+ calibrationFrame = undefined
294
+ }
295
+
296
+ function measureScaleCalibration() {
297
+ calibrationFrame = undefined
298
+ if (!isOpen) return
299
+ const viewport = panel?.querySelector?.('[data-prts-scale-calibration-viewport]')
300
+ const canvas = panel?.querySelector?.('[data-prts-scale-calibration-canvas]')
301
+ const calibration = panel?.querySelector?.('[data-prts-scale-calibration]')
302
+ const scaleNote = panel?.querySelector?.('[data-prts-scale-calibration-scale]')
303
+ if (!viewport || !canvas || !calibration) return
304
+ const available = calibrationViewportWidth || viewport.clientWidth
305
+ const factor = available > 0 ? Math.min(1, available / 360) : 1
306
+ const roundedFactor = Number(factor.toFixed(4))
307
+ const value = currentScaleDistance()
308
+ let state
309
+ try { state = getConversationScalePreview(value) } catch { state = { visible: false, reason: 'unmeasured', mode: 'hidden' } }
310
+
311
+ if (roundedFactor !== calibrationFactor) {
312
+ calibrationFactor = roundedFactor
313
+ canvas.style.transform = `scale(${roundedFactor})`
314
+ viewport.style.height = `${Math.ceil(94 * roundedFactor)}px`
315
+ }
316
+ const scaled = factor < .999
317
+ if (calibration.hasAttribute('data-prts-calibration-scaled') !== scaled) calibration.toggleAttribute('data-prts-calibration-scaled', scaled)
318
+ if (scaleNote && scaleNote.hidden !== !scaled) scaleNote.hidden = !scaled
319
+ const mode = state?.simulated ? 'simulated' : state?.mode || 'hidden'
320
+ const reason = state?.reason || 'unmeasured'
321
+ if (calibration.dataset.prtsCalibrationMode !== mode) calibration.dataset.prtsCalibrationMode = mode
322
+ if (calibration.dataset.prtsCalibrationReason !== reason) calibration.dataset.prtsCalibrationReason = reason
323
+ setText(panel.querySelector('[data-prts-scale-calibration-status]'), calibrationStatus(state, value))
324
+ }
325
+
326
+ function scheduleCalibrationMeasurement() {
327
+ if (!isOpen) return
328
+ cancelCalibrationFrame()
329
+ calibrationFrame = window?.requestAnimationFrame?.(measureScaleCalibration)
330
+ ?? window?.setTimeout?.(measureScaleCalibration, 16)
331
+ }
332
+
333
+ function scheduleScalePreviewRefresh(entries = []) {
334
+ const viewportEntry = entries.find?.(entry => entry.target?.matches?.('[data-prts-scale-calibration-viewport]'))
335
+ const observedWidth = Number(viewportEntry?.contentRect?.width)
336
+ if (Number.isFinite(observedWidth) && observedWidth > 0) {
337
+ calibrationViewportWidth = observedWidth
338
+ }
339
+ if (!isOpen) return
340
+ setCalibrationResizeState(true)
341
+ if (calibrationSettleTimer !== undefined) window?.clearTimeout?.(calibrationSettleTimer)
342
+ calibrationSettleTimer = window?.setTimeout?.(() => {
343
+ calibrationSettleTimer = undefined
344
+ setCalibrationResizeState(false)
345
+ renderScaleCalibration()
346
+ }, 160)
347
+ }
348
+
349
+ function renderScaleCalibration() {
350
+ if (!isOpen) return
351
+ if (!panel || !currentPreferences) return
352
+ const value = currentScaleDistance()
353
+ const contrast = currentScaleFocusContrast()
354
+ const lengths = resolveConversationScaleLengths(contrast)
355
+ const calibration = panel.querySelector('[data-prts-scale-calibration]')
356
+ const distanceRange = panel.querySelector('input[data-prts-setting-range][data-prts-setting-key="conversationScaleMaxDistance"]')
357
+ const contrastRange = panel.querySelector('input[data-prts-setting-range][data-prts-setting-key="conversationScaleFocusContrast"]')
358
+ const distanceOutput = panel.querySelector('[data-prts-scale-distance-output]')
359
+ const contrastOutput = panel.querySelector('[data-prts-scale-focus-output]')
360
+ const calibrationValue = panel.querySelector('[data-prts-scale-calibration-value]')
361
+ if (distanceRange && distanceRange.value !== String(value)) distanceRange.value = String(value)
362
+ if (contrastRange && contrastRange.value !== String(contrast)) contrastRange.value = String(contrast)
363
+ setText(distanceOutput, `${value} px`)
364
+ setText(contrastOutput, String(contrast))
365
+ setText(calibrationValue, `${value} px`)
366
+ for (const tick of panel.querySelectorAll('[data-prts-scale-focus-preview-tick]')) {
367
+ const length = lengths[Number(tick.dataset.prtsScaleFocusPreviewTick)] ?? lengths.at(-1)
368
+ const width = `${length}px`
369
+ if (tick.style.width !== width) tick.style.width = width
370
+ }
371
+ const detail = resolveParticleDetail(currentPreferences)
372
+ const detailLabels = { compact: '精简', standard: '标准', precise: '精细' }
373
+ const advanced = panel.querySelector('[data-prts-advanced-summary]')
374
+ setText(advanced, `${detailLabels[detail]} / 区分度 ${contrast} / ${value} px`)
375
+ if (!calibration) return
376
+ const distance = `${value}px`
377
+ if (calibration.style.getPropertyValue('--prts-calibration-distance') !== distance) {
378
+ calibration.style.setProperty('--prts-calibration-distance', distance)
379
+ }
380
+ scheduleCalibrationMeasurement()
381
+ }
382
+
383
+ function commitScaleDistance() {
384
+ if (!scaleDistanceDirty || !currentPreferences) return false
385
+ const value = currentScaleDistance()
386
+ scaleDistanceDirty = false
387
+ scaleDistanceDraft = value
388
+ if (value !== normalizeScaleDistance(currentPreferences.conversationScaleMaxDistance)) onPreferenceChange('conversationScaleMaxDistance', value)
389
+ else renderScaleCalibration()
390
+ return true
391
+ }
392
+
393
+ function commitScaleFocusContrast() {
394
+ if (!scaleFocusContrastDirty || !currentPreferences) return false
395
+ const value = currentScaleFocusContrast()
396
+ scaleFocusContrastDirty = false
397
+ scaleFocusContrastDraft = value
398
+ if (value !== normalizeScaleFocusContrast(currentPreferences.conversationScaleFocusContrast)) onPreferenceChange('conversationScaleFocusContrast', value)
399
+ else renderScaleCalibration()
400
+ return true
401
+ }
402
+
403
+ function renderPersistence() {
404
+ const status = panel?.querySelector?.('[data-prts-persistence-status]')
405
+ const error = panel?.querySelector?.('[data-prts-persistence-error]')
406
+ if (!status || !error) return
407
+ const phase = currentPersistence?.phase || 'idle'
408
+ const revision = Number(currentPersistence?.revision) || 0
409
+ const failed = phase === 'error'
410
+ error.hidden = !failed
411
+ if (phase === 'saved' && revision === renderedPersistenceRevision) return
412
+ if (savedTimer !== undefined) window?.clearTimeout?.(savedTimer)
413
+ savedTimer = undefined
414
+ status.hidden = phase !== 'saved'
415
+ status.textContent = phase === 'saved' ? '已保存' : ''
416
+ if (phase === 'saved') {
417
+ renderedPersistenceRevision = revision
418
+ savedTimer = window?.setTimeout?.(() => { status.hidden = true; savedTimer = undefined }, reducedMotion() ? 500 : 1200)
419
+ }
420
+ }
421
+
422
+ function renderState() {
423
+ if (!panel || !currentPreferences) return
424
+ const particleDetail = resolveParticleDetail(currentPreferences)
425
+ for (const button of panel.querySelectorAll('button[data-prts-setting-key]')) {
426
+ const key = button.dataset.prtsSettingKey
427
+ const actual = key === 'particleDetail' ? particleDetail : currentPreferences[key]
428
+ const selected = String(actual) === button.dataset.prtsSettingValue
429
+ button.classList.toggle('is-selected', selected)
430
+ button.setAttribute('aria-pressed', String(selected))
431
+ if (button.getAttribute('role') === 'radio') button.setAttribute('aria-checked', String(selected))
432
+ }
433
+ const presetLabels = { 'standard-tactical': '标准战术', 'clear-glass': '清晰玻璃', 'quiet-reading': '静谧阅读', custom: '自定义' }
434
+ const preset = presetLabels[currentPreferences.preset] || '自定义'
435
+ const presetStatus = panel.querySelector('[data-prts-settings-preset-status]')
436
+ if (presetStatus) presetStatus.textContent = preset
437
+ const motionStatus = panel.querySelector('[data-prts-effective-motion]')
438
+ if (motionStatus) motionStatus.hidden = !(currentPreferences.motion === 'system' && motionMedia?.matches)
439
+ const transparency = panel.querySelector('[data-prts-transparency-status]')
440
+ if (transparency) transparency.hidden = !(currentPreferences.glass !== 'off' && transparencyMedia?.matches)
441
+ const particleError = panel.querySelector('[data-prts-particle-error]')
442
+ if (particleError) particleError.hidden = currentStatus?.particle?.phase !== 'error'
443
+ if (!scaleDistanceDirty) scaleDistanceDraft = normalizeScaleDistance(currentPreferences.conversationScaleMaxDistance)
444
+ if (!scaleFocusContrastDirty) scaleFocusContrastDraft = normalizeScaleFocusContrast(currentPreferences.conversationScaleFocusContrast)
445
+ renderScaleCalibration()
446
+ renderPersistence()
447
+ }
448
+
449
+ function markFeedback(target) {
450
+ if (!target) return
451
+ if (feedbackTimer !== undefined) window?.clearTimeout?.(feedbackTimer)
452
+ feedbackTarget?.removeAttribute?.('data-prts-setting-feedback')
453
+ feedbackTarget = target
454
+ target.setAttribute('data-prts-setting-feedback', '')
455
+ feedbackTimer = window?.setTimeout?.(() => {
456
+ target.removeAttribute('data-prts-setting-feedback')
457
+ if (feedbackTarget === target) feedbackTarget = undefined
458
+ feedbackTimer = undefined
459
+ }, 300)
460
+ }
461
+
462
+ function onPanelClick(event) {
463
+ if (event.target === panel) { setOpen(false); return }
464
+ const target = event.target?.closest?.('button')
465
+ if (!target) return
466
+ if (target.hasAttribute('data-prts-theme-settings-close')) { setOpen(false); return }
467
+ if (target.hasAttribute('data-prts-setting-key')) {
468
+ const raw = target.dataset.prtsSettingValue
469
+ markFeedback(target)
470
+ onPreferenceChange(target.dataset.prtsSettingKey, raw === 'true' ? true : raw === 'false' ? false : raw)
471
+ return
472
+ }
473
+ if (target.hasAttribute('data-prts-retry-save')) { onRetrySave(); return }
474
+ if (target.hasAttribute('data-prts-reset-visual')) { setResetConfirmation(true); return }
475
+ if (target.hasAttribute('data-prts-reset-cancel')) { setResetConfirmation(false); return }
476
+ if (target.hasAttribute('data-prts-reset-confirm-action')) {
477
+ scaleDistanceDirty = false
478
+ scaleDistanceDraft = undefined
479
+ scaleFocusContrastDirty = false
480
+ scaleFocusContrastDraft = undefined
481
+ setResetConfirmation(false)
482
+ onResetVisual()
483
+ }
484
+ }
485
+
486
+ function onPanelInput(event) {
487
+ const target = event.target
488
+ if (!target?.matches?.('input[data-prts-setting-range]')) return
489
+ const value = Number(target.value)
490
+ if (!Number.isFinite(value)) return
491
+ if (target.dataset.prtsSettingKey === 'conversationScaleMaxDistance') {
492
+ scaleDistanceDraft = value
493
+ scaleDistanceDirty = true
494
+ } else if (target.dataset.prtsSettingKey === 'conversationScaleFocusContrast') {
495
+ scaleFocusContrastDraft = value
496
+ scaleFocusContrastDirty = true
497
+ } else {
498
+ return
499
+ }
500
+ renderScaleCalibration()
501
+ }
502
+
503
+ function onKeydown(event) {
504
+ if (!isOpen) return
505
+ if (event.key === 'Escape') { event.preventDefault(); setOpen(false); return }
506
+ if (nativeModal || event.key !== 'Tab') return
507
+ const nodes = focusable()
508
+ if (!nodes.length) return
509
+ const first = nodes[0]
510
+ const last = nodes.at(-1)
511
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
512
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
513
+ }
514
+
515
+ function onCancel(event) { event.preventDefault(); setOpen(false) }
516
+ const toggle = () => setOpen(!isOpen)
517
+ const closeFromBackdrop = () => setOpen(false)
518
+ const renderMediaState = () => renderState()
519
+ const commitFromControl = event => {
520
+ const target = event.target
521
+ if (!target?.matches?.('input[data-prts-setting-range]')) return
522
+ if (target.dataset.prtsSettingKey === 'conversationScaleMaxDistance') commitScaleDistance()
523
+ if (target.dataset.prtsSettingKey === 'conversationScaleFocusContrast') commitScaleFocusContrast()
524
+ }
525
+
526
+ trigger?.addEventListener('click', toggle)
527
+ panel?.addEventListener('click', onPanelClick)
528
+ panel?.addEventListener('cancel', onCancel)
529
+ panel?.addEventListener('input', onPanelInput)
530
+ panel?.addEventListener('change', commitFromControl)
531
+ panel?.addEventListener('pointerup', commitFromControl)
532
+ panel?.addEventListener('focusout', commitFromControl)
533
+ backdrop?.addEventListener('click', closeFromBackdrop)
534
+ document.addEventListener('keydown', onKeydown)
535
+ transparencyMedia?.addEventListener?.('change', renderMediaState)
536
+ motionMedia?.addEventListener?.('change', renderMediaState)
537
+ if (typeof window?.ResizeObserver === 'function') {
538
+ calibrationResizeObserver = new window.ResizeObserver(scheduleScalePreviewRefresh)
539
+ const viewport = panel?.querySelector?.('[data-prts-scale-calibration-viewport]')
540
+ if (viewport) calibrationResizeObserver.observe(viewport)
541
+ }
542
+
543
+ return {
544
+ update(preferences, status, persistenceState = currentPersistence) {
545
+ currentPreferences = preferences
546
+ currentStatus = status
547
+ currentPersistence = persistenceState ?? { phase: 'idle', revision: 0 }
548
+ if (!scaleDistanceDirty) scaleDistanceDraft = normalizeScaleDistance(preferences?.conversationScaleMaxDistance)
549
+ if (!scaleFocusContrastDirty) scaleFocusContrastDraft = normalizeScaleFocusContrast(preferences?.conversationScaleFocusContrast)
550
+ renderState()
551
+ },
552
+ refreshScalePreview: renderScaleCalibration,
553
+ scheduleScalePreviewRefresh,
554
+ open: () => setOpen(true),
555
+ close: () => setOpen(false),
556
+ toggle,
557
+ dispose() {
558
+ isOpen = false
559
+ hidePanel()
560
+ if (savedTimer !== undefined) window?.clearTimeout?.(savedTimer)
561
+ if (feedbackTimer !== undefined) window?.clearTimeout?.(feedbackTimer)
562
+ if (calibrationSettleTimer !== undefined) window?.clearTimeout?.(calibrationSettleTimer)
563
+ calibrationSettleTimer = undefined
564
+ cancelCalibrationFrame()
565
+ setCalibrationResizeState(false)
566
+ feedbackTarget?.removeAttribute?.('data-prts-setting-feedback')
567
+ trigger?.setAttribute('aria-expanded', 'false')
568
+ trigger?.removeEventListener('click', toggle)
569
+ panel?.removeEventListener('click', onPanelClick)
570
+ panel?.removeEventListener('cancel', onCancel)
571
+ panel?.removeEventListener('input', onPanelInput)
572
+ panel?.removeEventListener('change', commitFromControl)
573
+ panel?.removeEventListener('pointerup', commitFromControl)
574
+ panel?.removeEventListener('focusout', commitFromControl)
575
+ backdrop?.removeEventListener('click', closeFromBackdrop)
576
+ document.removeEventListener('keydown', onKeydown)
577
+ transparencyMedia?.removeEventListener?.('change', renderMediaState)
578
+ motionMedia?.removeEventListener?.('change', renderMediaState)
579
+ calibrationResizeObserver?.disconnect()
580
+ document.documentElement.removeAttribute('data-prts-settings-open')
581
+ },
582
+ }
583
+ }
@@ -0,0 +1,85 @@
1
+ export const TO_BOTTOM_ATTRIBUTE = 'data-prts-to-bottom'
2
+
3
+ function isToBottomButton(node) {
4
+ return node?.tagName === 'BUTTON'
5
+ && [...(node.classList ?? [])].some(token => token.endsWith('_toBottom'))
6
+ }
7
+
8
+ export function createToBottomAdapter({ document, window }) {
9
+ let observer
10
+ let operation
11
+ const owned = new Set()
12
+ const pending = new Set()
13
+
14
+ function collectButtons(node) {
15
+ if (node?.nodeType !== 1) return
16
+ if (node.tagName === 'BUTTON') pending.add(node)
17
+ for (const button of node.querySelectorAll?.('button') ?? []) pending.add(button)
18
+ }
19
+
20
+ function process(button) {
21
+ if (button?.isConnected && operation?.contains(button) && isToBottomButton(button)) {
22
+ button.setAttribute(TO_BOTTOM_ATTRIBUTE, '')
23
+ owned.add(button)
24
+ return
25
+ }
26
+ button?.removeAttribute?.(TO_BOTTOM_ATTRIBUTE)
27
+ owned.delete(button)
28
+ }
29
+
30
+ function scan() {
31
+ for (const button of pending) process(button)
32
+ pending.clear()
33
+ for (const button of [...owned]) {
34
+ if (!button.isConnected) process(button)
35
+ }
36
+ }
37
+
38
+ function queueScan() {
39
+ scan()
40
+ }
41
+
42
+ function onMutations(mutations) {
43
+ for (const mutation of mutations) {
44
+ if (mutation.type === 'attributes') collectButtons(mutation.target)
45
+ for (const node of mutation.addedNodes ?? []) collectButtons(node)
46
+ for (const node of mutation.removedNodes ?? []) {
47
+ if (node?.nodeType !== 1) continue
48
+ for (const button of owned) {
49
+ if (button === node || node.contains?.(button)) pending.add(button)
50
+ }
51
+ }
52
+ }
53
+ if (pending.size) queueScan()
54
+ }
55
+
56
+ function clear() {
57
+ for (const button of owned) button.removeAttribute?.(TO_BOTTOM_ATTRIBUTE)
58
+ owned.clear()
59
+ pending.clear()
60
+ }
61
+
62
+ return {
63
+ start() {
64
+ if (!document) return
65
+ operation = document.querySelector('[data-prts-region="operation"]')
66
+ if (!operation) return
67
+ collectButtons(operation)
68
+ scan()
69
+ if (observer || !window?.MutationObserver) return
70
+ observer = new window.MutationObserver(onMutations)
71
+ observer.observe(operation, {
72
+ subtree: true,
73
+ childList: true,
74
+ attributes: true,
75
+ attributeFilter: ['class'],
76
+ })
77
+ },
78
+ dispose() {
79
+ observer?.disconnect()
80
+ observer = undefined
81
+ clear()
82
+ operation = undefined
83
+ },
84
+ }
85
+ }