@ash-qw/dsh-theme-prts 2.0.0 → 2.1.1

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.
@@ -0,0 +1,92 @@
1
+ import { DEFAULT_PREFERENCES, PRTS_STORAGE_KEY, loadPreferences, normalizePreferences } from './preferences.js'
2
+
3
+ // Host snapshots are authoritative. This cache must never become a migration source.
4
+ const NATIVE_CACHE_KEY = 'dsh.ui.prts.host-cache.v1'
5
+ export function createNativePreferences({ storage, onChange }) {
6
+ let current = loadPreferences(storage)
7
+ let legacy = null
8
+ try {
9
+ const raw = JSON.parse(storage?.getItem(PRTS_STORAGE_KEY) || 'null')
10
+ if (raw && Number.isInteger(raw.version) && raw.version >= 1 && raw.version <= 12) legacy = normalizePreferences(raw)
11
+ } catch {}
12
+ try {
13
+ const cache = JSON.parse(storage?.getItem(NATIVE_CACHE_KEY) || 'null')
14
+ if (cache) { current = normalizePreferences(cache); legacy = null }
15
+ } catch {}
16
+ let form, unsubscribe, disposed = false, busy = false, revision = 0
17
+ let pending = {}, inFlight = {}, failed = {}, phase = 'idle', migrationAttempted = false
18
+ const emit = () => { if (!disposed) onChange(current, { phase, revision: ++revision }) }
19
+ const cache = value => { try { storage?.setItem(NATIVE_CACHE_KEY, JSON.stringify(value)) } catch {} }
20
+ const snapshot = () => { try { return form?.getSnapshot() } catch { return undefined } }
21
+ const writable = s => s?.status === 'ready' && s.writable && s.mode !== 'memory'
22
+ function receive() {
23
+ if (disposed) return
24
+ const s = snapshot()
25
+ if (s?.status !== 'ready') { emit(); return }
26
+ const host = normalizePreferences(s.value)
27
+ if (!s.value?.preferencesInitialized && legacy && !migrationAttempted && writable(s)) {
28
+ migrationAttempted = true
29
+ pending = { ...legacy, ...pending }
30
+ }
31
+ current = normalizePreferences({ ...host, ...inFlight, ...pending })
32
+ if (s.value?.preferencesInitialized) {
33
+ legacy = null
34
+ cache(host)
35
+ // Once the Host has settings, an obsolete browser must not re-migrate them.
36
+ try { storage?.removeItem(PRTS_STORAGE_KEY) } catch {}
37
+ }
38
+ emit()
39
+ void flush()
40
+ }
41
+ async function flush() {
42
+ if (disposed || busy || !Object.keys(pending).length) return
43
+ const s = snapshot()
44
+ if (!writable(s)) { failed = { ...failed, ...pending }; pending = {}; phase = 'error'; emit(); return }
45
+ busy = true
46
+ const patch = pending
47
+ inFlight = patch
48
+ pending = {}
49
+ phase = 'saving'; emit()
50
+ try {
51
+ const ops = Object.entries(patch).map(([key, value]) => ({ op: 'set', path: [key], value }))
52
+ ops.push({ op: 'set', path: ['preferencesInitialized'], value: true })
53
+ // Fixed revision: conflicts are recovered by DSH, never blindly replayed.
54
+ const accepted = await form.mutate(ops, s.revision)
55
+ if (disposed) return
56
+ if (accepted !== true) throw Error('Host refused settings mutation')
57
+ for (const key of Object.keys(patch)) delete failed[key]
58
+ phase = Object.keys(failed).length ? 'error' : 'saved'
59
+ } catch {
60
+ if (disposed) return
61
+ failed = { ...failed, ...patch }
62
+ for (const key of Object.keys(pending)) delete failed[key]
63
+ phase = 'error'
64
+ } finally {
65
+ busy = false
66
+ inFlight = {}
67
+ if (!disposed) receive()
68
+ }
69
+ }
70
+ return {
71
+ get value() { return current },
72
+ bind(next) {
73
+ unsubscribe?.(); form = next
74
+ unsubscribe = form.subscribe(receive)
75
+ receive()
76
+ return () => { unsubscribe?.(); unsubscribe = null; form = null }
77
+ },
78
+ update(next) {
79
+ if (disposed) return
80
+ const normalized = normalizePreferences(next)
81
+ for (const key of Object.keys(DEFAULT_PREFERENCES)) {
82
+ if (normalized[key] !== current[key]) { pending[key] = normalized[key]; delete failed[key] }
83
+ }
84
+ if (!Object.keys(pending).length) return
85
+ current = normalized
86
+ phase = 'saving'; emit()
87
+ void flush()
88
+ },
89
+ retry() { pending = { ...failed, ...pending }; failed = {}; void flush() },
90
+ dispose() { disposed = true; unsubscribe?.() },
91
+ }
92
+ }
@@ -0,0 +1,137 @@
1
+ // Ring geometry adapted from React Bits CometDial (David Haz).
2
+ // See src/vendor/react-bits/LICENSE.md and THIRD_PARTY_NOTICES.md.
3
+ export const BACKGROUND_OPACITY_CONTROLS = Object.freeze([
4
+ { key: 'sidebarOpacity', label: '会话列表侧栏', property: '--prts-sidebar-opacity', value: 94, blurKey: 'sidebarBlur', blurProperty: '--prts-sidebar-blur', blurDefault: 8 },
5
+ { key: 'railOpacity', label: '设置侧栏', property: '--prts-rail-opacity', value: 70, blurKey: 'railBlur', blurProperty: '--prts-rail-blur', blurDefault: 12 },
6
+ { key: 'bakerOpacity', label: 'Baker 气泡', property: '--prts-baker-opacity', value: 50, blurKey: 'bakerBlur', blurProperty: '--prts-baker-blur', blurDefault: 8, theme: 'deck-chat' },
7
+ { key: 'classicUserOpacity', label: '经典用户消息', property: '--prts-classic-user-opacity', value: 50, blurKey: 'classicUserBlur', blurProperty: '--prts-classic-user-blur', blurDefault: 8, theme: 'native' },
8
+ ])
9
+ const dialClamp = value => Math.min(100, Math.max(0, Math.round(value)))
10
+ const dialPoint = angle => [100 + Math.cos(angle * Math.PI / 180) * 80, 100 + Math.sin(angle * Math.PI / 180) * 80]
11
+ const dialArc = (start, end) => {
12
+ const [x, y] = dialPoint(start), [ex, ey] = dialPoint(end)
13
+ return `M ${x} ${y} A 80 80 0 ${end - start > 180 ? 1 : 0} 1 ${ex} ${ey}`
14
+ }
15
+ export function opacityDialMarkup() {
16
+ const controls = BACKGROUND_OPACITY_CONTROLS.map(({key,label,value,blurDefault}) => {
17
+ const sample = key === 'bakerOpacity' ? '<span>这是一条回复</span><i></i>'
18
+ : key === 'classicUserOpacity' ? '<span>这是我发送的消息</span><i></i>'
19
+ : key === 'railOpacity' ? '<b>◇</b><b>☼</b><b>⚙</b>' : '<span>工作区</span><i></i><i></i>'
20
+ return `<div data-prts-opacity-control="${key}">
21
+ <div data-prts-opacity-heading><strong>${label}</strong><button type="button" data-prts-opacity-reset data-prts-opacity-reset-tooltip="恢复到 ${value}%" aria-label="恢复${label}透明度到 ${value}%"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 10a7.5 7.5 0 1 1 .7 5.2M4.5 4.5V10H10"/></svg></button></div>
22
+ <div data-prts-opacity-dial><svg viewBox="0 0 200 200" role="slider" tabindex="0" aria-label="${label}透明度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${value}" aria-valuetext="${value}%"><circle data-dial-focus cx="100" cy="100" r="97"/><path data-dial-track d="${dialArc(110,430)}"/><path data-dial-lit/><path data-dial-tail/><circle data-dial-head r="8"/></svg><label data-prts-opacity-number><input type="number" min="0" max="100" step="1" value="${value}" aria-label="${label}透明度百分比"><span>%</span></label></div>
23
+ <div data-prts-material-preview="${key}" aria-hidden="true"><div data-prts-material-preview-surface>${sample}</div></div>
24
+ <div data-prts-blur-control hidden><label><span>模糊</span><input type="range" min="0" max="32" step="1" value="${blurDefault}" aria-label="${label}模糊度"></label><output>${blurDefault}px</output><button type="button" data-prts-blur-reset title="恢复到 ${blurDefault}px" aria-label="恢复${label}模糊度到 ${blurDefault}px"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 10a7.5 7.5 0 1 1 .7 5.2M4.5 4.5V10H10"/></svg></button></div>
25
+ <small data-prts-opacity-note></small>
26
+ </div>`
27
+ }).join('')
28
+ return `<fieldset data-prts-opacity-settings hidden><legend>材质透明度与模糊度</legend><small>透明度数值越高越透明;模糊度以像素计。调整时预览,松手后保存。</small><div data-prts-opacity-grid>${controls}</div></fieldset>`
29
+ }
30
+ export function mountOpacityDials({ section, window, onChange }) {
31
+ const root = section.ownerDocument.documentElement
32
+ const controls = BACKGROUND_OPACITY_CONTROLS.map(definition => {
33
+ const container = section.querySelector(`[data-prts-opacity-control="${definition.key}"]`)
34
+ const svg = container.querySelector('[data-prts-opacity-dial] svg'), input = container.querySelector('[data-prts-opacity-number] input'), tail = svg.querySelector('[data-dial-tail]')
35
+ const blurControl = container.querySelector('[data-prts-blur-control]')
36
+ const blurInput = blurControl.querySelector('input'), blurOutput = blurControl.querySelector('output')
37
+ const materialPreview = container.querySelector('[data-prts-material-preview]')
38
+ let value = definition.value, saved = value, grip = null, timer
39
+ let blurValue = definition.blurDefault, savedBlur = blurValue
40
+ const paint = next => {
41
+ const previous = value
42
+ value = dialClamp(next)
43
+ const angle = 110 + value * 3.2, [x,y] = dialPoint(angle)
44
+ svg.querySelector('[data-dial-lit]').setAttribute('d', value ? dialArc(110,angle) : '')
45
+ const head = svg.querySelector('[data-dial-head]');head.setAttribute('cx', x);head.setAttribute('cy', y)
46
+ svg.setAttribute('aria-valuenow', value);svg.setAttribute('aria-valuetext', `${value}%`)
47
+ input.value = value
48
+ window.clearTimeout(timer)
49
+ const reach = Math.min(65, Math.abs(value - previous) * 3.2)
50
+ tail.setAttribute('d', reach && value !== previous ? dialArc(Math.max(110, value > previous ? angle-reach : angle), Math.min(430, value > previous ? angle : angle+reach)) : '')
51
+ tail.style.opacity = reach ? '.5' : '0'
52
+ if (reach) timer = window.setTimeout(() => {tail.style.opacity = '0'}, 160)
53
+ }
54
+ const preview = next => { paint(next);root.style.setProperty(definition.property, `${100 - value}%`) }
55
+ const commit = () => { if (value !== saved) { saved = value;onChange(definition.key, (100 - value) / 100) } }
56
+ const position = event => {
57
+ const b = svg.getBoundingClientRect()
58
+ const x = (event.clientX-b.left)/b.width*200-100, y = (event.clientY-b.top)/b.height*200-100
59
+ if (Math.hypot(x,y) < 35) return value
60
+ let angle = (Math.atan2(y,x)*180/Math.PI-110+720)%360
61
+ // Keep a drag on its own side of the dead zone, avoiding 100 -> 0 jumps.
62
+ if (angle > 320) angle = grip?.side === 'high' ? 320 : grip?.side === 'low' ? 0 : angle < 340 ? 320 : 0
63
+ else if (grip) grip.side = angle > 160 ? 'high' : 'low'
64
+ return angle/3.2
65
+ }
66
+ const down = event => {
67
+ if (event.button > 0 || grip) return
68
+ event.preventDefault();svg.focus({preventScroll:true})
69
+ grip = { id: event.pointerId, side: null }
70
+ svg.setPointerCapture?.(event.pointerId);container.setAttribute('data-dragging','')
71
+ preview(position(event))
72
+ }
73
+ const move = event => { if (grip?.id === event.pointerId) preview(position(event)) }
74
+ const finish = (event, cancel = false) => {
75
+ if (!grip || (event.pointerId !== undefined && grip.id !== event.pointerId)) return
76
+ const id = grip.id;grip = null;container.removeAttribute('data-dragging')
77
+ if (svg.hasPointerCapture?.(id)) svg.releasePointerCapture(id)
78
+ if (cancel) preview(saved);else commit()
79
+ }
80
+ const up = event => finish(event)
81
+ const cancel = event => finish(event,true)
82
+ const key = event => {
83
+ if (event.key === 'Escape') { if(grip){event.preventDefault();event.stopPropagation();finish(event,true)}return }
84
+ const step = event.shiftKey ? 10 : 1
85
+ const next = {ArrowRight:value+step,ArrowUp:value+step,ArrowLeft:value-step,ArrowDown:value-step,PageUp:value+10,PageDown:value-10,Home:0,End:100}[event.key]
86
+ if (next === undefined) return
87
+ event.preventDefault();preview(next);commit()
88
+ }
89
+ const change = () => { const n = input.valueAsNumber;if(!Number.isFinite(n)){paint(saved);return}preview(n);commit() }
90
+ const reset = () => { preview(definition.value);commit() }
91
+ const paintBlur = next => {
92
+ blurValue = Math.min(32, Math.max(0, Math.round(next)))
93
+ blurInput.value = blurValue
94
+ blurOutput.value = `${blurValue}px`
95
+ materialPreview.style.setProperty(definition.blurProperty, `${blurValue}px`)
96
+ }
97
+ const previewBlur = next => {
98
+ paintBlur(next)
99
+ if (root.hasAttribute('data-dsh-prts')) root.style.setProperty(definition.blurProperty, `${blurValue}px`)
100
+ }
101
+ const commitBlur = () => { if (blurValue !== savedBlur) { savedBlur = blurValue;onChange(definition.blurKey, blurValue) } }
102
+ const blurChange = () => { previewBlur(blurInput.valueAsNumber);commitBlur() }
103
+ const blurCancel = event => { if (event.key === 'Escape') { event.preventDefault();previewBlur(savedBlur);blurInput.blur() } }
104
+ const resetBlur = () => { previewBlur(definition.blurDefault);commitBlur() }
105
+ const resetButton = container.querySelector('[data-prts-opacity-reset]')
106
+ const blurResetButton = blurControl.querySelector('[data-prts-blur-reset]')
107
+ const bindings = [[svg,'pointerdown',down],[svg,'pointermove',move],[svg,'pointerup',up],[svg,'pointercancel',cancel],[svg,'lostpointercapture',cancel],[svg,'keydown',key],[input,'change',change],[resetButton,'click',reset],[blurInput,'input',event=>previewBlur(event.target.valueAsNumber)],[blurInput,'change',blurChange],[blurInput,'keydown',blurCancel],[blurResetButton,'click',resetBlur],[window,'blur',cancel]]
108
+ for(const [el,type,fn] of bindings)el.addEventListener(type,fn)
109
+ paint(value)
110
+ return {
111
+ update(preferences, active) {
112
+ container.hidden = Boolean(definition.theme && preferences.conversationStyle !== definition.theme)
113
+ saved = 100 - Math.round((preferences[definition.key] ?? (100 - definition.value) / 100) * 100)
114
+ if (!grip && section.ownerDocument.activeElement !== input) paint(saved)
115
+ savedBlur = preferences[definition.blurKey] ?? definition.blurDefault
116
+ if (section.ownerDocument.activeElement !== blurInput) paintBlur(savedBlur)
117
+ blurControl.hidden = !active
118
+ container.querySelector('[data-prts-opacity-note]').textContent = active ? '' : '当前未启用此材质'
119
+ },
120
+ dispose() { if(grip)finish({},true);window.clearTimeout(timer);for(const [el,type,fn] of bindings)el.removeEventListener(type,fn) },
121
+ }
122
+ })
123
+ return {
124
+ update(preferences, state) {
125
+ const full = preferences.backgroundMode === 'full' && Boolean(state.full)
126
+ const split = preferences.backgroundMode === 'split'
127
+ section.querySelector('[data-prts-opacity-settings]').hidden = preferences.backgroundMode === 'off'
128
+ const conversationImage = full || (split && Boolean(state.conversation))
129
+ const sidebarImage = full || (split && Boolean(state.sidebar))
130
+ const active = [sidebarImage, sidebarImage,
131
+ conversationImage && preferences.conversationStyle === 'deck-chat',
132
+ conversationImage && preferences.conversationStyle === 'native']
133
+ controls.forEach((control,i)=>control.update(preferences,active[i]))
134
+ },
135
+ dispose() {for(const control of controls)control.dispose()},
136
+ }
137
+ }
@@ -1,3 +1,4 @@
1
+ import { SCHEME_COMMIT_PROGRESS } from './theme-controller-v2.js'
1
2
  import { createThemeSettingsOverlay, themeSettingsMarkup } from './theme-settings-workbench.js'
2
3
 
3
4
  const PHONE_RAIL_BREAKPOINT = 640
@@ -45,7 +46,7 @@ export function createOperationsShell({
45
46
  onSchemeToggle = () => {},
46
47
  onSchemeDragStart = () => undefined,
47
48
  onThemeDisable = () => {},
48
- customEmblemStore, avatarStore,
49
+ customEmblemStore, avatarStore, backgroundStore,
49
50
  onPreferenceChange = () => {},
50
51
  onPreferencePreview = () => {},
51
52
  onVisualReset = () => {},
@@ -83,9 +84,9 @@ export function createOperationsShell({
83
84
  try { return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true } catch { return false }
84
85
  }
85
86
 
86
- function phoneRailGesture() {
87
+ function phoneRailGesture(pointerType) {
87
88
  if (railMode !== 'overlay' || Number(window.innerWidth) > PHONE_RAIL_BREAKPOINT) return false
88
- return railPointerQuery?.matches === true
89
+ return pointerType === 'touch' || railPointerQuery?.matches === true
89
90
  || railHoverQuery?.matches === true
90
91
  || Number(window.navigator?.maxTouchPoints) > 0
91
92
  }
@@ -105,13 +106,16 @@ export function createOperationsShell({
105
106
 
106
107
  function armRailGesture() {
107
108
  railHoldTimer = undefined
108
- if (!railPointer || !phoneRailGesture()) return
109
+ if (!railPointer || !phoneRailGesture('touch')) return
109
110
  railPointer.armed = true
110
111
  railLauncher?.setAttribute('data-prts-rail-gesture-active', '')
111
112
  }
112
113
 
114
+ let railInputType
113
115
  function onRailPointerDown(event) {
114
- if (!phoneRailGesture() || railPointer || (event.button ?? 0) !== 0) return
116
+ railInputType = event.pointerType || 'mouse'
117
+ if (railInputType !== 'touch') return
118
+ if (!phoneRailGesture(event.pointerType) || railPointer || (event.button ?? 0) !== 0) return
115
119
  const pointerId = event.pointerId ?? 1
116
120
  railPointer = {
117
121
  pointerId,
@@ -159,7 +163,9 @@ export function createOperationsShell({
159
163
  }
160
164
 
161
165
  function onRailClick(event) {
162
- if (phoneRailGesture() && Number(event?.detail) > 0) {
166
+ const inputType = event?.pointerType || railInputType
167
+ railInputType = undefined
168
+ if (phoneRailGesture(inputType) && inputType === 'touch' && Number(event?.detail) > 0) {
163
169
  event.preventDefault?.()
164
170
  return
165
171
  }
@@ -262,9 +268,19 @@ export function createOperationsShell({
262
268
  }, 1000)
263
269
  }
264
270
 
271
+ function flushSchemeProgress(pointer) {
272
+ if (pointer.frame !== undefined) window.cancelAnimationFrame(pointer.frame)
273
+ pointer.frame = undefined
274
+ if (pointer.progress !== undefined) {
275
+ pointer.gesture?.update?.(pointer.progress)
276
+ pointer.progress = undefined
277
+ }
278
+ }
279
+
265
280
  function settleSchemePointer({ commit = false } = {}) {
266
281
  const pointer = schemePointer
267
282
  if (!pointer) return
283
+ flushSchemeProgress(pointer)
268
284
  schemePointer = undefined
269
285
  try { schemeToggle?.releasePointerCapture?.(pointer.pointerId) } catch {}
270
286
  if (!pointer.dragging) return
@@ -313,19 +329,25 @@ export function createOperationsShell({
313
329
  }
314
330
  }
315
331
  event.preventDefault?.()
316
- pointer.gesture?.update?.(schemePointerProgress(event))
332
+ pointer.progress = schemePointerProgress(event)
333
+ if (pointer.frame === undefined) pointer.frame = window.requestAnimationFrame(() => {
334
+ pointer.frame = undefined
335
+ if (schemePointer === pointer) flushSchemeProgress(pointer)
336
+ })
317
337
  }
318
338
 
319
339
  function onSchemePointerUp(event) {
320
340
  const pointer = schemePointer
321
341
  if (!pointer || (event.pointerId ?? 1) !== pointer.pointerId) return
322
- const commit = pointer.dragging && schemePointerProgress(event) >= .5
342
+ if (pointer.dragging) pointer.progress = schemePointerProgress(event)
343
+ const commit = pointer.dragging && schemePointerProgress(event) >= SCHEME_COMMIT_PROGRESS
323
344
  settleSchemePointer({ commit })
324
345
  }
325
346
 
326
347
  function cancelSchemePointer() {
327
348
  const pointer = schemePointer
328
349
  if (!pointer) return
350
+ if (pointer.frame !== undefined) window.cancelAnimationFrame(pointer.frame)
329
351
  if (pointer.dragging) {
330
352
  try { pointer.gesture?.cancel?.() } catch {}
331
353
  }
@@ -353,7 +375,7 @@ export function createOperationsShell({
353
375
  const overlay = railMode === 'overlay'
354
376
  const open = overlay && root.hasAttribute('data-prts-rail-open')
355
377
  railLauncher.setAttribute('aria-expanded', String(open))
356
- const closedLabel = phoneRailGesture() ? '长按并向下滑动打开 P.R.T.S. 导航' : '打开 P.R.T.S. 导航'
378
+ const closedLabel = phoneRailGesture() ? '打开 P.R.T.S. 导航(触摸时长按并下滑)' : '打开 P.R.T.S. 导航'
357
379
  railLauncher.setAttribute('aria-label', open ? '关闭 P.R.T.S. 导航' : closedLabel)
358
380
  if (overlay && !open) {
359
381
  navRail.setAttribute('aria-hidden', 'true')
@@ -418,12 +440,16 @@ export function createOperationsShell({
418
440
  }
419
441
 
420
442
  function onResponsiveChange() {
443
+ railInputType = undefined
421
444
  clearRailGesture()
422
445
  setRailViewport(window.innerWidth)
423
446
  syncRailState()
424
447
  }
425
448
 
449
+ let railPhoneQuery
426
450
  function bindResponsiveQueries() {
451
+ railPhoneQuery = window.matchMedia?.('(max-width: 640px)')
452
+ railPhoneQuery?.addEventListener?.('change', onResponsiveChange)
427
453
  railViewportQuery = window.matchMedia?.('(min-width: 1180px)')
428
454
  railPointerQuery = window.matchMedia?.('(pointer: coarse)')
429
455
  railHoverQuery = window.matchMedia?.('(hover: none)')
@@ -455,7 +481,7 @@ export function createOperationsShell({
455
481
  trigger: shell.querySelector('[data-prts-rail-brand]'),
456
482
  panel: shell.querySelector('[data-prts-theme-settings]'),
457
483
  backdrop: shell.querySelector('[data-prts-settings-backdrop]'),
458
- customEmblemStore, avatarStore,
484
+ customEmblemStore, avatarStore, backgroundStore,
459
485
  onPreferenceChange,
460
486
  onPreferencePreview,
461
487
  onResetVisual: onVisualReset,
@@ -507,6 +533,9 @@ export function createOperationsShell({
507
533
  schemeToggle?.removeEventListener('lostpointercapture', onSchemeLostPointerCapture)
508
534
  window.removeEventListener?.('blur', cancelSchemePointer)
509
535
  themeDisable?.removeEventListener('click', disableTheme)
536
+ railPhoneQuery?.removeEventListener?.('change', onResponsiveChange)
537
+ railPhoneQuery = undefined
538
+ railInputType = undefined
510
539
  railViewportQuery?.removeEventListener?.('change', onResponsiveChange)
511
540
  railPointerQuery?.removeEventListener?.('change', onResponsiveChange)
512
541
  railHoverQuery?.removeEventListener?.('change', onResponsiveChange)
@@ -24,6 +24,17 @@ export const DEFAULT_PREFERENCES = Object.freeze({
24
24
  glass: 'standard',
25
25
  motion: 'system',
26
26
  bootAnimation: true,
27
+ composerGlass: false,
28
+ backgroundMode: 'off',
29
+ backgroundDim: .45,
30
+ sidebarOpacity: .06,
31
+ railOpacity: .3,
32
+ bakerOpacity: .5,
33
+ classicUserOpacity: .5,
34
+ sidebarBlur: 8,
35
+ railBlur: 12,
36
+ bakerBlur: 8,
37
+ classicUserBlur: 8,
27
38
  cursor: 'prts',
28
39
  conversationParticleDensity: 'sparse',
29
40
  heroParticleDensity: 'light',
@@ -48,6 +59,7 @@ export const DEFAULT_PREFERENCES = Object.freeze({
48
59
  sessionFlowSpeed: SESSION_FLOW_SPEED_DEFAULT,
49
60
  conversationStyle: 'native',
50
61
  bakerGroupAvatars: true,
62
+ bakerLoading: false,
51
63
  conversationEmblem: true,
52
64
  })
53
65
 
@@ -85,12 +97,12 @@ export const PARTICLE_DETAIL_LEVELS = Object.freeze({
85
97
  precise: Object.freeze({ conversationParticleDensity: 'dense', heroParticleDensity: 'ultra' }),
86
98
  })
87
99
  export const PREFERENCE_GROUPS = Object.freeze({
88
- background: Object.freeze(['texture']),
100
+ background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'sidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
89
101
  particles: Object.freeze(['conversationParticleDensity', 'heroParticleDensity', 'particleTraversalSpeed']),
90
- material: Object.freeze(['glass']),
102
+ material: Object.freeze(['glass', 'composerGlass']),
91
103
  accessibility: Object.freeze(['motion', 'bootAnimation', 'cursor']),
92
104
  navigation: Object.freeze(['railDefaultHidden', 'sessionPreview', 'previewOngoing', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning', 'previewReplyLimit']),
93
- conversation: Object.freeze(['sessionFlow', 'sessionGlow', 'sessionFlowPalette', 'sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront', 'sessionFlowSpeed', 'conversationStyle', 'bakerGroupAvatars', 'conversationEmblem']),
105
+ conversation: Object.freeze(['sessionFlow', 'sessionGlow', 'sessionFlowPalette', 'sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront', 'sessionFlowSpeed', 'conversationStyle', 'bakerGroupAvatars', 'bakerLoading', 'conversationEmblem']),
94
106
  })
95
107
 
96
108
  const TEXTURES = new Set(['off', 'restrained', 'full'])
@@ -134,6 +146,12 @@ function colorOr(value, fallback) {
134
146
  return typeof value === 'string' && /^#[\da-f]{6}$/i.test(value) ? value.toLowerCase() : fallback
135
147
  }
136
148
 
149
+ function materialBlurOr(value, fallback) {
150
+ return typeof value === 'number' && Number.isFinite(value)
151
+ ? Math.min(32, Math.max(0, Math.round(value)))
152
+ : fallback
153
+ }
154
+
137
155
  function particleTraversalSpeedOr(value, fallback) {
138
156
  const numeric = Number(value)
139
157
  if (!Number.isFinite(numeric)) return fallback
@@ -189,6 +207,18 @@ export function normalizePreferences(value) {
189
207
  texture: enumOr(input.texture, TEXTURES, DEFAULT_PREFERENCES.texture),
190
208
  glass: normalizeGlass(input),
191
209
  motion: input.motion === 'full' ? 'system' : enumOr(input.motion, MOTIONS, DEFAULT_PREFERENCES.motion),
210
+ bakerLoading: input.bakerLoading === true,
211
+ backgroundMode: ['off', 'split', 'full'].includes(input.backgroundMode) ? input.backgroundMode : 'off',
212
+ sidebarOpacity: typeof input.sidebarOpacity === 'number' && Number.isFinite(input.sidebarOpacity) ? Math.min(1, Math.max(0, input.sidebarOpacity)) : .06,
213
+ railOpacity: typeof input.railOpacity === 'number' && Number.isFinite(input.railOpacity) ? Math.min(1, Math.max(0, input.railOpacity)) : .3,
214
+ bakerOpacity: typeof input.bakerOpacity === 'number' && Number.isFinite(input.bakerOpacity) ? Math.min(1, Math.max(0, input.bakerOpacity)) : .5,
215
+ classicUserOpacity: typeof input.classicUserOpacity === 'number' && Number.isFinite(input.classicUserOpacity) ? Math.min(1, Math.max(0, input.classicUserOpacity)) : .5,
216
+ sidebarBlur: materialBlurOr(input.sidebarBlur, DEFAULT_PREFERENCES.sidebarBlur),
217
+ railBlur: materialBlurOr(input.railBlur, DEFAULT_PREFERENCES.railBlur),
218
+ bakerBlur: materialBlurOr(input.bakerBlur, DEFAULT_PREFERENCES.bakerBlur),
219
+ classicUserBlur: materialBlurOr(input.classicUserBlur, DEFAULT_PREFERENCES.classicUserBlur),
220
+ backgroundDim: typeof input.backgroundDim === 'number' && Number.isFinite(input.backgroundDim) ? Math.min(.9, Math.max(0, input.backgroundDim)) : .45,
221
+ composerGlass: booleanOr(input.composerGlass, DEFAULT_PREFERENCES.composerGlass),
192
222
  bootAnimation: booleanOr(input.bootAnimation, DEFAULT_PREFERENCES.bootAnimation),
193
223
  cursor: input.cursor === 'system' ? 'system' : DEFAULT_PREFERENCES.cursor,
194
224
  conversationParticleDensity: enumOr(legacyDensities.conversation, PARTICLE_DENSITIES, particlePreset.conversationParticleDensity),
@@ -1,8 +1,8 @@
1
1
  export function createSettingsPage(React, meta = {}) {
2
2
  const h = React.createElement
3
3
 
4
- return function PrtsPluginSettingsPage({ useStore, updatePreference }) {
5
- const { preferences } = useStore(state => ({ preferences: state.preferences }))
4
+ return function PrtsPluginSettingsPage({ useStore, updatePreference, retryPersistence }) {
5
+ const { preferences, persistence } = useStore(state => ({ preferences: state.preferences, persistence: state.status?.settingsPersistence }))
6
6
  const safeMode = Boolean(meta.safeMode)
7
7
  const enabled = Boolean(preferences.enabled) && !safeMode
8
8
  const bootAnimation = preferences.bootAnimation !== false
@@ -40,6 +40,11 @@ export function createSettingsPage(React, meta = {}) {
40
40
  safeMode && h('p', { className: 'prts-plugin-settings__safe', role: 'status' },
41
41
  '安全模式已暂停主题。移除地址中的 ?prts-safe=1 后即可恢复。',
42
42
  ),
43
+ persistence?.phase === 'error' && h('div', { role: 'alert' },
44
+ h('p', null, '设置未保存到 DSH。请检查连接或持久化支持;重试会重新提交本次修改。'),
45
+ h('button', { type: 'button', onClick: retryPersistence }, '重试保存'),
46
+ ),
47
+ persistence?.phase === 'saving' && h('p', { role: 'status' }, '正在保存…'),
43
48
  h('div', { className: 'prts-plugin-settings__list' },
44
49
  settingRow({
45
50
  key: 'enabled',
@@ -1,6 +1,8 @@
1
1
  import { createCursorAdapter } from './cursor-adapter.js'
2
2
  import { normalizePreferences } from './preferences.js'
3
3
 
4
+ export const SCHEME_COMMIT_PROGRESS = .15
5
+
4
6
  const ROOT_ATTRIBUTES = [
5
7
  'data-dsh-prts',
6
8
  'data-prts-scheme',
@@ -14,6 +16,7 @@ const ROOT_ATTRIBUTES = [
14
16
  'data-prts-session-flow-palette',
15
17
  'data-prts-session-flow-speed',
16
18
  'data-prts-conversation-style',
19
+ 'data-prts-background-mode',
17
20
  ]
18
21
  const SESSION_FLOW_COLOR_PROPERTIES = {
19
22
  '--prts-session-flow-back': 'sessionFlowColorBack',
@@ -112,6 +115,12 @@ export function createThemeController({ document, window, cssText, service, curs
112
115
  }
113
116
 
114
117
  function clearThemeState() {
118
+ root.removeAttribute('data-prts-baker-loading')
119
+ root.style.removeProperty('--prts-background-shade')
120
+ for (const name of ['sidebar', 'rail', 'baker']) root.style.removeProperty(`--prts-${name}-opacity`)
121
+ root.style.removeProperty('--prts-classic-user-opacity')
122
+ for (const name of ['sidebar', 'rail', 'baker']) root.style.removeProperty(`--prts-${name}-blur`)
123
+ root.style.removeProperty('--prts-classic-user-blur')
115
124
  for (const attribute of ROOT_ATTRIBUTES) root.removeAttribute(attribute)
116
125
  for (const property of Object.keys(SESSION_FLOW_COLOR_PROPERTIES)) root.style.removeProperty(property)
117
126
  clearTransitionState()
@@ -237,11 +246,23 @@ export function createThemeController({ document, window, cssText, service, curs
237
246
  function updateRevealHud(session) {
238
247
  if (!session || session.settled) return
239
248
  const percentage = Math.round(revealProgress(session.progress) * 100)
240
- if (session.hudEdge) session.hudEdge.dataset.prtsSchemeRevealSide = 'left'
241
- root.style.setProperty('--prts-scheme-control-progress', String(session.progress))
242
- root.style.setProperty('--prts-scheme-control-meter-height', `${revealProgress(session.progress) * 48}px`)
243
- root.style.setProperty('--prts-scheme-control-value-image', revealValueImageUrl(percentage))
244
- root.toggleAttribute(TRANSITION_ARMED_ATTRIBUTE, session.interactive && session.progress >= .5)
249
+ if (session.hudEdge && session.hudEdge.dataset.prtsSchemeRevealSide !== 'left') session.hudEdge.dataset.prtsSchemeRevealSide = 'left'
250
+ if (session.hudPercentage !== percentage) {
251
+ session.hudPercentage = percentage
252
+ const image = revealValueImageUrl(percentage)
253
+ if (session.hudAnimation?.effect?.setKeyframes) {
254
+ const frame = {
255
+ backgroundImage: image + ',linear-gradient(#ffd400,#ffd400)',
256
+ backgroundSize: '24px 11px,2px ' + (percentage * .48) + 'px',
257
+ }
258
+ session.hudAnimation.effect.setKeyframes([frame, frame])
259
+ } else {
260
+ root.style.setProperty('--prts-scheme-control-meter-height', `${percentage * .48}px`)
261
+ root.style.setProperty('--prts-scheme-control-value-image', image)
262
+ }
263
+ }
264
+ const armed = session.interactive && session.progress >= SCHEME_COMMIT_PROGRESS
265
+ if (root.hasAttribute(TRANSITION_ARMED_ATTRIBUTE) !== armed) root.toggleAttribute(TRANSITION_ARMED_ATTRIBUTE, armed)
245
266
  }
246
267
 
247
268
  function releaseRevealHud(session) {
@@ -354,11 +375,16 @@ export function createThemeController({ document, window, cssText, service, curs
354
375
  ], options, '::view-transition-new(prts-scheme-grid)')
355
376
  const mark = revealNamedAnimation([
356
377
  { offset: 0, translate: revealGroupTranslate(0), rotate: '45deg' },
357
- { offset: .49, translate: revealGroupTranslate(session.geometry.width * .49), rotate: '45deg' },
358
- { offset: .51, translate: revealGroupTranslate(session.geometry.width * .51), rotate: '225deg' },
378
+ { offset: SCHEME_COMMIT_PROGRESS - .01, translate: revealGroupTranslate(session.geometry.width * (SCHEME_COMMIT_PROGRESS - .01)), rotate: '45deg' },
379
+ { offset: SCHEME_COMMIT_PROGRESS + .01, translate: revealGroupTranslate(session.geometry.width * (SCHEME_COMMIT_PROGRESS + .01)), rotate: '225deg' },
359
380
  { offset: 1, translate: revealGroupTranslate(session.geometry.width), rotate: '225deg' },
360
381
  ], options, '::view-transition-group(prts-scheme-mark)')
361
382
  session.scrubAnimations = [clip, edge, node, grid, gridPhase, mark]
383
+ session.hudAnimation = revealNamedAnimation([{}], options, '::view-transition-new(prts-scheme-node)')
384
+ session.hudAnimation.pause?.()
385
+ session.hudAnimation.currentTime = 0
386
+ session.hudPercentage = undefined
387
+ updateRevealHud(session)
362
388
  for (const animation of session.scrubAnimations) {
363
389
  animation.pause?.()
364
390
  animation.currentTime = session.progress * session.duration
@@ -370,11 +396,10 @@ export function createThemeController({ document, window, cssText, service, curs
370
396
  if (!session || session.settled) return
371
397
  session.progress = revealProgress(value)
372
398
  session.revealX = session.progress * session.geometry.width
373
- root.style.setProperty('--prts-scheme-reveal-x', `${session.revealX}px`)
399
+ if (!session.scrubAnimations) root.style.setProperty('--prts-scheme-reveal-x', `${session.revealX}px`)
374
400
  if (session.scrubAnimations) {
375
401
  try {
376
402
  for (const animation of session.scrubAnimations) {
377
- animation.pause?.()
378
403
  animation.currentTime = session.progress * session.duration
379
404
  }
380
405
  } catch {}
@@ -395,12 +420,19 @@ export function createThemeController({ document, window, cssText, service, curs
395
420
  for (const animation of session?.scrubAnimations || []) {
396
421
  try { animation?.cancel?.() } catch {}
397
422
  }
398
- if (session) session.scrubAnimations = undefined
423
+ session?.hudAnimation?.cancel?.()
424
+ if (session) {
425
+ session.scrubAnimations = undefined
426
+ session.hudAnimation = undefined
427
+ session.hudPercentage = undefined
428
+ }
399
429
  }
400
430
 
401
431
  function animateRevealTo(session, value, duration, easing) {
402
432
  if (!session || session.settled) return Promise.resolve(false)
403
433
  cancelRevealAnimation(session)
434
+ // Transfer the last HUD value once before the release animation takes over.
435
+ updateRevealHud(session)
404
436
  const targetProgress = revealProgress(value)
405
437
  const targetX = revealTargetX(session, targetProgress)
406
438
  if (!(duration > 0)) {
@@ -442,8 +474,8 @@ export function createThemeController({ document, window, cssText, service, curs
442
474
  { backgroundSize: `2px ${targetProgress * 48}px` },
443
475
  ], options, '::view-transition-new(prts-scheme-node)')
444
476
  const mark = revealNamedAnimation([
445
- { translate: revealGroupTranslate(session.revealX), rotate: session.progress >= .5 ? '225deg' : '45deg' },
446
- { translate: revealGroupTranslate(targetX), rotate: targetProgress >= .5 ? '225deg' : '45deg' },
477
+ { translate: revealGroupTranslate(session.revealX), rotate: session.progress >= SCHEME_COMMIT_PROGRESS ? '225deg' : '45deg' },
478
+ { translate: revealGroupTranslate(targetX), rotate: targetProgress >= SCHEME_COMMIT_PROGRESS ? '225deg' : '45deg' },
447
479
  ], options, '::view-transition-group(prts-scheme-mark)')
448
480
  animations = [clip, edge, node, grid, gridPhase, nodeProgress, mark]
449
481
  } catch {
@@ -601,7 +633,7 @@ export function createThemeController({ document, window, cssText, service, curs
601
633
  let settled = false
602
634
  return {
603
635
  update(value) { if (!settled) progress = revealProgress(value) },
604
- finish(commit = progress >= .5) {
636
+ finish(commit = progress >= SCHEME_COMMIT_PROGRESS) {
605
637
  if (settled) return root.dataset.prtsScheme
606
638
  settled = true
607
639
  return commit ? setTheme(target, { animate: false }) : source
@@ -734,6 +766,13 @@ export function createThemeController({ document, window, cssText, service, curs
734
766
  root.dataset.prtsSessionFlowSpeed = String(preferences.sessionFlowSpeed)
735
767
  for (const [property, key] of Object.entries(SESSION_FLOW_COLOR_PROPERTIES)) root.style.setProperty(property, preferences[key])
736
768
  root.dataset.prtsConversationStyle = preferences.conversationStyle
769
+ root.toggleAttribute('data-prts-baker-loading', preferences.bakerLoading === true)
770
+ root.dataset.prtsBackgroundMode = preferences.backgroundMode
771
+ root.style.setProperty('--prts-background-shade', `${preferences.backgroundDim * 100}%`)
772
+ for (const name of ['sidebar', 'rail', 'baker']) root.style.setProperty(`--prts-${name}-opacity`, `${preferences[`${name}Opacity`] * 100}%`)
773
+ root.style.setProperty('--prts-classic-user-opacity', `${preferences.classicUserOpacity * 100}%`)
774
+ for (const name of ['sidebar', 'rail', 'baker']) root.style.setProperty(`--prts-${name}-blur`, `${preferences[`${name}Blur`]}px`)
775
+ root.style.setProperty('--prts-classic-user-blur', `${preferences.classicUserBlur}px`)
737
776
  refresh()
738
777
  },
739
778
  sync,