@ash-qw/dsh-theme-prts 2.2.0 → 2.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ash-qw/dsh-theme-prts",
3
- "version": "2.2.0",
3
+ "version": "2.3.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",
@@ -75,7 +75,7 @@
75
75
  "@playwright/test": "1.55.1",
76
76
  "esbuild": "^0.28.2",
77
77
  "jsdom": "24.1.3",
78
- "markdown-it": "^14.3.2"
78
+ "markdown-it": "^14.3.3"
79
79
  },
80
80
  "dependencies": {
81
81
  "@deepseek-ai/schemastery": "~3.18.4",
@@ -14,6 +14,8 @@ function closestWithClassSuffix(node, suffix) {
14
14
  function classify(button, operation) {
15
15
  // The host's process disclosure owns its transparent, full-width hit area.
16
16
  if (button.hasAttribute('data-turn-process')) return null
17
+ // Notification headers belong to the card surface, not standalone buttons.
18
+ if (button.parentElement?.matches('[data-turn-trigger]')) return null
17
19
  // Changed-file cards own one outer surface; their header and rows stay native.
18
20
  if (button.closest('[data-changed-files]')) return null
19
21
  // File previews are transparent hit targets; the enclosing card owns its material and outline.
@@ -63,7 +63,7 @@ export function createBackgroundStore({ window, document }) {
63
63
  },
64
64
  }
65
65
  }
66
- export function backgroundMarkup() {
66
+ export function backgroundMarkup(heroContrastControl = '') {
67
67
  return `<section data-prts-background-settings>
68
68
  <header><strong>会话背景</strong><small>分区使用不同图片,或让一张完整背景贯穿侧边栏与正文。</small></header>
69
69
  <div data-prts-background-modes role="group" aria-label="背景模式">
@@ -75,6 +75,7 @@ export function backgroundMarkup() {
75
75
  <input type="file" data-prts-background-file="${slot}" accept="image/png,image/jpeg,image/webp,image/avif" hidden>
76
76
  </article>`).join('')}</div>
77
77
  <label data-prts-background-shade hidden><span>背景淡化 <output></output></span><input type="range" min="0" max="90" step="5" aria-label="背景淡化程度"><small>调高后背景更淡,接近当前主题底色。</small></label>
78
+ <div data-prts-background-hero-setting hidden>${heroContrastControl}</div>
78
79
  ${opacityDialMarkup()}
79
80
  <small data-prts-background-help>支持 PNG / JPEG / WebP / AVIF · 最大 10 MB<br>图片居中铺满,切换模式保留素材,并在当前 DSH 实例同步。</small><p data-prts-background-status role="status"></p>
80
81
  </section>`
@@ -1,3 +1,4 @@
1
+ import { createThemeTransfer } from './theme-transfer.js'
1
2
  import { createBackgroundStore } from './custom-background.js'
2
3
  import { createNativePreferences } from './native-preferences.js'
3
4
  import { createComposerGlassSurface } from './composer-glass-surface.js'
@@ -91,6 +92,7 @@ export function applyPrtsPlugin(ctx, environment) {
91
92
  cssText,
92
93
  service: themeService,
93
94
  cursorAssets: assets.cursor,
95
+ onSchemeChange() { particleField.repaintScheme() },
94
96
  onTransitionStateChange(active) {
95
97
  particleField.setSchemeTransitionActive(active)
96
98
  },
@@ -111,6 +113,7 @@ export function applyPrtsPlugin(ctx, environment) {
111
113
  const adapter = createRc7Adapter({ document })
112
114
  const hostGeometry = createHostGeometryAdapter({ document, window })
113
115
  const connection = contextService(ctx, 'connection')
116
+ let themeTransfer
114
117
  let operations
115
118
  let mountObserver
116
119
  let mountFrame
@@ -201,6 +204,9 @@ export function applyPrtsPlugin(ctx, environment) {
201
204
  if (!themeAlreadyApplied) theme.apply(preferences)
202
205
  operations ||= createOperationsShell({
203
206
  document, window, assets, adapter, hostGeometry, customEmblemStore, avatarStore, backgroundStore,
207
+ themeTransfer: themeTransfer ||= createThemeTransfer({ window, getPreferences: () => nativePreferences.value,
208
+ savePreferences: (value, revision) => nativePreferences.replace(value, revision),
209
+ checkWritable: () => nativePreferences.checkWritable(), checkExport: () => nativePreferences.checkReadable(), themeVersion: version }),
204
210
  onSchemeToggle(next, interaction) {
205
211
  return theme.setTheme(next, { ...interaction, animate: true })
206
212
  },
@@ -347,6 +353,7 @@ export function applyPrtsPlugin(ctx, environment) {
347
353
  } else {
348
354
  applyVisualState()
349
355
  }
356
+ const stopBackgroundRedraw = backgroundStore.subscribe(() => { if (!safeMode) particleField.update(preferences) })
350
357
  const bindNativePreferences = settingsCtx => {
351
358
  const forms = contextService(settingsCtx, 'configForms')
352
359
  if (forms) settingsCtx.effect(() => nativePreferences.bind(forms.get('theme-prts')))
@@ -359,6 +366,7 @@ export function applyPrtsPlugin(ctx, environment) {
359
366
  statusSource.dispose()
360
367
  customEmblemStore.dispose()
361
368
  avatarStore.dispose()
369
+ stopBackgroundRedraw()
362
370
  backgroundStore.dispose()
363
371
  particleField.dispose()
364
372
  startup.dispose({ restoreFocus: false })
@@ -69,6 +69,37 @@ export function createNativePreferences({ storage, onChange }) {
69
69
  }
70
70
  return {
71
71
  get value() { return current },
72
+ checkReadable() {
73
+ const s = snapshot()
74
+ if (disposed || s?.status !== 'ready') throw Error('请等待 DSH 设置加载完成')
75
+ if (busy || Object.keys(pending).length || Object.keys(failed).length) throw Error('请先等待当前设置保存成功')
76
+ return s.revision
77
+ },
78
+ checkWritable() {
79
+ const revision = this.checkReadable()
80
+ if (!writable(snapshot())) throw Error('当前连接无法保存主题设置')
81
+ return revision
82
+ },
83
+ async replace(next, expectedRevision) {
84
+ this.checkWritable()
85
+ const s = snapshot()
86
+ if (s.revision !== expectedRevision) throw Error('设置已在其他页面更新,请重新导入')
87
+ const normalized = normalizePreferences(next)
88
+ busy = true
89
+ phase = 'saving'; emit()
90
+ try {
91
+ const ops = Object.entries(normalized).map(([key, value]) => ({ op: 'set', path: [key], value }))
92
+ ops.push({ op: 'set', path: ['preferencesInitialized'], value: true })
93
+ if (await form.mutate(ops, s.revision) !== true) throw Error('DSH 未接受导入设置,请重试')
94
+ phase = 'saved'
95
+ } catch (error) {
96
+ phase = 'error'
97
+ throw error
98
+ } finally {
99
+ busy = false
100
+ receive()
101
+ }
102
+ },
72
103
  bind(next) {
73
104
  unsubscribe?.(); form = next
74
105
  unsubscribe = form.subscribe(receive)
@@ -46,7 +46,7 @@ export function createOperationsShell({
46
46
  onSchemeToggle = () => {},
47
47
  onSchemeDragStart = () => undefined,
48
48
  onThemeDisable = () => {},
49
- customEmblemStore, avatarStore, backgroundStore,
49
+ customEmblemStore, avatarStore, backgroundStore, themeTransfer,
50
50
  onPreferenceChange = () => {},
51
51
  onPreferencePreview = () => {},
52
52
  onVisualReset = () => {},
@@ -481,7 +481,7 @@ export function createOperationsShell({
481
481
  trigger: shell.querySelector('[data-prts-rail-brand]'),
482
482
  panel: shell.querySelector('[data-prts-theme-settings]'),
483
483
  backdrop: shell.querySelector('[data-prts-settings-backdrop]'),
484
- customEmblemStore, avatarStore, backgroundStore,
484
+ customEmblemStore, avatarStore, backgroundStore, themeTransfer,
485
485
  onPreferenceChange,
486
486
  onPreferencePreview,
487
487
  onResetVisual: onVisualReset,
@@ -432,7 +432,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
432
432
  const densityProfile = density => PARTICLE_DENSITY_PROFILES[density] ?? PARTICLE_DENSITY_PROFILES.standard
433
433
  const particleRadius = density => densityProfile(density ?? activeDensity()).radius
434
434
  const fieldEnabled = () => heroActive || (preferences.conversationStyle !== 'deck-chat' && preferences.conversationEmblem !== false)
435
- const canRenderFrame = () => fieldEnabled() && !disposed && documentVisible && canvasVisible && !hydrationPaused && !startupPaused && !schemeTransitionPaused && !resizing
435
+ const canRenderFrame = (paintOnly = false) => fieldEnabled() && !disposed && documentVisible && canvasVisible && !hydrationPaused && !startupPaused && (paintOnly || !schemeTransitionPaused) && !resizing
436
436
  const layoutWidth = () => width
437
437
  const layoutHeight = () => height
438
438
  const layoutShiftX = () => 0
@@ -1134,12 +1134,25 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1134
1134
  resizeTimer = window.setTimeout?.(finishResizeTransaction, RESIZE_SETTLE_MS)
1135
1135
  }
1136
1136
 
1137
+ function resizeHeroTransition(nextSize) {
1138
+ // Keep live positions and velocities; the spring follows the new destination.
1139
+ // Snapping to particle.target here would restore the old hero mid-explosion.
1140
+ clearResizeState()
1141
+ pointerActive = false
1142
+ resizeCommits += applyFieldSize(nextSize) ? 1 : 0
1143
+ }
1144
+
1137
1145
  function onOperationResize(entries = []) {
1138
1146
  if (!operation || !canvas) return
1139
1147
  resizeObservations += 1
1140
1148
  const entry = entries.find(candidate => candidate.target === operation) ?? entries[0]
1141
1149
  const contentRect = entry?.contentRect
1142
1150
  pendingResizeSize = contentRect ? sizeFromRect(contentRect) : readOperationSize()
1151
+ if (transition?.fromHero && !phoneStatic()) {
1152
+ resizeHeroTransition(pendingResizeSize)
1153
+ updateFrameGate()
1154
+ return
1155
+ }
1143
1156
  if (phoneStatic()) {
1144
1157
  const nextSize = pendingResizeSize
1145
1158
  clearResizeState()
@@ -1289,21 +1302,30 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1289
1302
  if (queuedDensity !== undefined && (queuedDensity !== particles.length || force)) startDensityReassembly(queuedDensity, force)
1290
1303
  }
1291
1304
 
1292
- function draw(timestamp) {
1305
+ function heroContrastStrength() {
1306
+ const root = document.documentElement
1307
+ const imageVisible = preferences.backgroundMode === 'full'
1308
+ ? root.hasAttribute('data-prts-background-full')
1309
+ : preferences.backgroundMode === 'split' && root.hasAttribute('data-prts-background-conversation')
1310
+ if (preferences.heroContrast === false || !imageVisible) return 0
1311
+ return heroActive ? 1 : transition?.fromHero ? 1 - easeInOut(transition.progress) : 0
1312
+ }
1313
+
1314
+ function draw(timestamp, paintOnly = false) {
1293
1315
  frame = undefined
1294
1316
  if (!ambient || !canvas) return
1295
- if (!canRenderFrame()) return
1317
+ if (!canRenderFrame(paintOnly)) return
1296
1318
  if (geometryDirty) {
1297
1319
  syncCanvasBackingStore()
1298
1320
  geometryDirty = false
1299
1321
  }
1300
- if (transition) {
1322
+ if (transition && !paintOnly) {
1301
1323
  transitionStartedAt ??= timestamp
1302
1324
  transition.progress = clamp((timestamp - transitionStartedAt) / TRANSITION_MS, 0, 1)
1303
1325
  if (transition.progress >= 1) completeTransition()
1304
1326
  }
1305
1327
 
1306
- advanceDensity(timestamp)
1328
+ if (!paintOnly) advanceDensity(timestamp)
1307
1329
  const reducedMotion = motionReduced()
1308
1330
  const staticPhone = phoneStatic()
1309
1331
  const staticField = reducedMotion || staticPhone
@@ -1322,7 +1344,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1322
1344
  }
1323
1345
  const frameGeometry = { activeLayout, staticField, transitionLayout }
1324
1346
  const { center, size: fieldSize } = activeLayout
1325
- const follow = reducedMotion ? 1 : 0.035
1347
+ const follow = paintOnly ? 0 : reducedMotion ? 1 : 0.035
1326
1348
  glowX += (center.x - glowX) * follow
1327
1349
  glowY += (center.y - glowY) * follow
1328
1350
  if (glow) glow.style.transform = 'translate3d(calc(' + glowX.toFixed(2) + 'px - 50%), calc(' + glowY.toFixed(2) + 'px - 50%), 0)'
@@ -1337,19 +1359,31 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1337
1359
  const damping = heroPhysics ? 0.86 : 0.84
1338
1360
  const pointerForce = heroPhysics ? 2.2 : 2.7
1339
1361
  const lightScheme = document.documentElement.dataset.prtsScheme === 'light'
1340
- const baseAlpha = lightScheme ? 0.66 : 0.72
1341
- const radiusScale = lightScheme ? 1.12 : 1
1362
+ const contrast = heroContrastStrength()
1363
+ if (contrast > 0 && context.createRadialGradient) {
1364
+ const origin = heroActive ? center : transition.fromCenter
1365
+ const radius = (heroActive ? fieldSize : transition.fromSize) * .78
1366
+ const shade = lightScheme ? '248,250,252' : '7,14,22'
1367
+ const gradient = context.createRadialGradient(origin.x, origin.y, 0, origin.x, origin.y, radius)
1368
+ gradient.addColorStop(0, `rgba(${shade},${.58 * contrast})`)
1369
+ gradient.addColorStop(.42, `rgba(${shade},${.42 * contrast})`)
1370
+ gradient.addColorStop(1, `rgba(${shade},0)`)
1371
+ context.fillStyle = gradient
1372
+ context.fillRect(origin.x - radius, origin.y - radius, radius * 2, radius * 2)
1373
+ }
1374
+ const baseAlpha = (lightScheme ? 0.66 : 0.72) + .16 * contrast
1375
+ const radiusScale = (lightScheme ? 1.12 : 1) * (1 + .08 * contrast)
1342
1376
  const particleBatches = typeof context.moveTo === 'function' ? new Map() : null
1343
1377
 
1344
1378
  for (const particle of particles) {
1345
1379
  const target = targetPosition(particle, frameGeometry)
1346
- if (staticField) {
1380
+ if (staticField && !paintOnly) {
1347
1381
  particle.x = target.x
1348
1382
  particle.y = target.y
1349
1383
  particle.vx = 0
1350
1384
  particle.vy = 0
1351
1385
  }
1352
- if (!staticField) {
1386
+ if (!staticField && !paintOnly) {
1353
1387
  particle.vx += (target.x - particle.x) * spring
1354
1388
  particle.vy += (target.y - particle.y) * spring
1355
1389
 
@@ -1412,6 +1446,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1412
1446
  canvas.setAttribute('data-prts-particle-ready', '')
1413
1447
  canvas.style.visibility = ''
1414
1448
  }
1449
+ if (paintOnly) return
1415
1450
  const explicitMotion = Boolean(densityTransition || transition)
1416
1451
  const animatedField = !staticField && context && particles.length
1417
1452
  if (explicitMotion) {
@@ -1550,7 +1585,15 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1550
1585
  }
1551
1586
  transitionStartedAt = undefined
1552
1587
  pointerActive = false
1553
- scheduleFrame()
1588
+ // The initial message may have paused hydration before syncHeroState ran.
1589
+ // Let this short exit finish uninterrupted by subsequent response updates.
1590
+ cancelHydrationResume()
1591
+ hydrationPaused = false
1592
+ mutationBurstScore = 0
1593
+ if (mutationBurstTimer !== undefined) window.clearTimeout?.(mutationBurstTimer)
1594
+ mutationBurstTimer = undefined
1595
+ if (pendingResizeSize) resizeHeroTransition(pendingResizeSize)
1596
+ updateFrameGate()
1554
1597
  }
1555
1598
 
1556
1599
  function syncHeroState() {
@@ -1612,7 +1655,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1612
1655
  }
1613
1656
 
1614
1657
  function registerContentMutations(mutations) {
1615
- if (!fieldEnabled() || phoneStatic()) return
1658
+ if (!fieldEnabled() || phoneStatic() || transition?.fromHero) return
1616
1659
  let weight = mutations.length
1617
1660
  for (const mutation of mutations) {
1618
1661
  for (const node of [...mutation.addedNodes, ...mutation.removedNodes]) {
@@ -1780,6 +1823,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1780
1823
  if (context && particles.length) {
1781
1824
  syncCanvasBackingStore()
1782
1825
  geometryDirty = false
1826
+ cancelFrame()
1783
1827
  draw(Number(window.performance?.now?.()) || 0)
1784
1828
  } else {
1785
1829
  canvas.setAttribute('data-prts-particle-ready', '')
@@ -1847,6 +1891,11 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1847
1891
  startupPaused = next
1848
1892
  updateFrameGate()
1849
1893
  },
1894
+ repaintScheme() {
1895
+ cancelFrame()
1896
+ draw(Number(window.performance?.now?.()) || 0, true)
1897
+ if (!schemeTransitionPaused) scheduleFrame()
1898
+ },
1850
1899
  setSchemeTransitionActive(active) {
1851
1900
  const next = Boolean(active)
1852
1901
  if (schemeTransitionPaused === next) return
@@ -1888,10 +1937,13 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1888
1937
 
1889
1938
  findMount()
1890
1939
  if (!fieldEnabled()) resetTraversal()
1891
- const previousActiveDensity = heroActive
1940
+ const previousRequestedDensity = heroActive
1892
1941
  ? previousPreferences.heroParticleDensity
1893
1942
  : previousPreferences.conversationParticleDensity
1894
- const activeDensityChanged = previousActiveDensity !== undefined && previousActiveDensity !== activeDensity()
1943
+ // Compare effective densities on both sides: sparse/light hero settings
1944
+ // already render at standard density, including after a focus refresh.
1945
+ const previousActiveDensity = heroActive ? effectiveHeroDensity(previousRequestedDensity) : previousRequestedDensity
1946
+ const activeDensityChanged = previousRequestedDensity !== undefined && previousActiveDensity !== activeDensity()
1895
1947
  reportState({ requestedPattern: 'orthogonal' })
1896
1948
  if (particles.length && activeDensityChanged) scheduleDensityReassembly(targetParticleCount(), true)
1897
1949
  if (preservedTraversalCoordinate !== undefined && scroller && !heroActive) reanchorTraversal(preservedTraversalCoordinate)
@@ -1929,6 +1981,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1929
1981
  enabled: fieldEnabled(),
1930
1982
  mounted: Boolean(ambient?.isConnected && canvas?.isConnected),
1931
1983
  hero: heroActive,
1984
+ heroContrast: heroContrastStrength(),
1932
1985
  side,
1933
1986
  emblemKey: (heroActive || transition?.fromHero ? heroEmblem() : emblemForSegment(segment))?.key,
1934
1987
  nextEmblemKey: transition ? emblemForSegment(transition.targetSegment)?.key : null,
@@ -27,6 +27,7 @@ export const DEFAULT_PREFERENCES = Object.freeze({
27
27
  composerGlass: false,
28
28
  backgroundMode: 'off',
29
29
  backgroundDim: .45,
30
+ heroContrast: true,
30
31
  sidebarOpacity: .06,
31
32
  railOpacity: .3,
32
33
  bakerOpacity: .5,
@@ -99,7 +100,7 @@ export const PARTICLE_DETAIL_LEVELS = Object.freeze({
99
100
  precise: Object.freeze({ conversationParticleDensity: 'dense', heroParticleDensity: 'ultra' }),
100
101
  })
101
102
  export const PREFERENCE_GROUPS = Object.freeze({
102
- background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'sidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
103
+ background: Object.freeze(['texture', 'backgroundMode', 'backgroundDim', 'heroContrast', 'sidebarOpacity', 'railOpacity', 'bakerOpacity', 'classicUserOpacity', 'sidebarBlur', 'railBlur', 'bakerBlur', 'classicUserBlur']),
103
104
  particles: Object.freeze(['conversationParticleDensity', 'heroParticleDensity', 'particleTraversalSpeed']),
104
105
  material: Object.freeze(['glass', 'composerGlass']),
105
106
  accessibility: Object.freeze(['motion', 'bootAnimation', 'cursor']),
@@ -220,6 +221,7 @@ export function normalizePreferences(value) {
220
221
  railBlur: materialBlurOr(input.railBlur, DEFAULT_PREFERENCES.railBlur),
221
222
  bakerBlur: materialBlurOr(input.bakerBlur, DEFAULT_PREFERENCES.bakerBlur),
222
223
  classicUserBlur: materialBlurOr(input.classicUserBlur, DEFAULT_PREFERENCES.classicUserBlur),
224
+ heroContrast: booleanOr(input.heroContrast, DEFAULT_PREFERENCES.heroContrast),
223
225
  backgroundDim: typeof input.backgroundDim === 'number' && Number.isFinite(input.backgroundDim) ? Math.min(.9, Math.max(0, input.backgroundDim)) : .45,
224
226
  composerGlass: booleanOr(input.composerGlass, DEFAULT_PREFERENCES.composerGlass),
225
227
  bootAnimation: booleanOr(input.bootAnimation, DEFAULT_PREFERENCES.bootAnimation),
@@ -72,7 +72,7 @@ function resolveScheme(value) {
72
72
  return identity.includes('dark') || identity.includes('night') ? 'dark' : 'light'
73
73
  }
74
74
 
75
- export function createThemeController({ document, window, cssText, service, cursorAssets, onTransitionStateChange = () => {} }) {
75
+ export function createThemeController({ document, window, cssText, service, cursorAssets, onTransitionStateChange = () => {}, onSchemeChange = () => {} }) {
76
76
  if (!document || !window) {
77
77
  const gesture = { update() {}, finish() {}, cancel() {} }
78
78
  return { apply() {}, sync() {}, refresh() {}, setTheme() {}, beginThemeTransition() { return gesture }, toggle() {}, dispose() {} }
@@ -153,6 +153,7 @@ export function createThemeController({ document, window, cssText, service, curs
153
153
  if (disposed) return
154
154
  const next = resolveScheme(value)
155
155
  root.dataset.prtsScheme = next
156
+ onSchemeChange(next)
156
157
  return next
157
158
  }
158
159
 
@@ -1,3 +1,4 @@
1
+ import { themeTransferMarkup, mountThemeTransfer } from './theme-transfer-panel.js'
1
2
  import { backgroundMarkup, mountBackgroundSettings } from './custom-background.js'
2
3
  import { avatarMarkup, mountAvatarSettings } from './custom-avatar.js'
3
4
  import { customEmblemMarkup, mountCustomEmblemEditor } from './custom-emblem.js'
@@ -38,7 +39,9 @@ function previewSetting(key, label, description) {
38
39
  }
39
40
 
40
41
  function settingButtons(key, label, options) {
41
- const note = key === 'composerGlass'
42
+ const note = key === 'heroContrast'
43
+ ? '<small data-prts-setting-note>为新会话徽记添加柔光衬底,使它在背景图片上更清晰。</small>'
44
+ : key === 'composerGlass'
42
45
  ? '<small data-prts-setting-note>增强输入框的背景模糊与轻微折射,关闭时使用原有材质</small>'
43
46
  : key === 'bakerLoading'
44
47
  ? '<small data-prts-setting-note>每轮正文生成时显示三点动画,完成后显示全文;中断时保留已收到的内容</small>'
@@ -59,7 +62,7 @@ function settingButtons(key, label, options) {
59
62
  : key === 'sessionFlowPalette'
60
63
  ? '<small data-prts-setting-note>同时适配日间与夜间模式</small>'
61
64
  : ''
62
- if (['composerGlass', 'bakerLoading', 'motion', 'cursor', 'railDefaultHidden', 'sessionFlow', 'sessionGlow', 'bakerGroupAvatars', 'conversationEmblem', 'sessionPreview', 'aiToolsEnabled', 'previewOngoing', 'previewTools', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning'].includes(key)) return `<div data-prts-setting-row="${key}">
65
+ if (['heroContrast', 'composerGlass', 'bakerLoading', 'motion', 'cursor', 'railDefaultHidden', 'sessionFlow', 'sessionGlow', 'bakerGroupAvatars', 'conversationEmblem', 'sessionPreview', 'aiToolsEnabled', 'previewOngoing', 'previewTools', 'previewWarning', 'previewDone', 'previewDoubleClick', 'previewFollowOngoing', 'previewFollowWarning'].includes(key)) return `<div data-prts-setting-row="${key}">
63
66
  <span><strong>${label}</strong>${note}</span>
64
67
  <button type="button" role="switch" aria-label="${label}" aria-checked="false" data-prts-setting-switch="${key}" ${key === 'cursor' ? 'data-prts-cursor-switch' : ''}><span aria-hidden="true"></span></button>
65
68
  </div>`
@@ -134,9 +137,10 @@ export function themeSettingsMarkup() {
134
137
  <span><strong>设置未保存</strong><small>未能保存到 DSH,可能存在连接中断、配置冲突或当前访问不支持持久保存。重试将重新提交本次修改。</small></span>
135
138
  <button type="button" data-prts-retry-save>重试保存</button>
136
139
  </div>
140
+ <button type="button" data-prts-transfer-open data-prts-footer-action aria-haspopup="dialog" aria-expanded="false"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M6 14V3m-3 3 3-3 3 3M14 6v11m-3-3 3 3 3-3"/></svg><span>导入与导出</span></button>
137
141
  <div data-prts-reset-zone>
138
142
 
139
- <button type="button" data-prts-reset-visual title="恢复全部主题设置" aria-label="恢复全部主题设置">恢复默认</button>
143
+ <button type="button" data-prts-reset-visual data-prts-footer-action title="恢复全部主题设置" aria-label="恢复全部主题设置"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 7a6.5 6.5 0 1 1-.4 5M4 3v4h4"/></svg><span>恢复默认</span></button>
140
144
  <div data-prts-reset-confirm hidden role="group" aria-label="确认恢复全部主题设置">
141
145
  <span>重置全部分类的主题设置,保留主题开关、启动动画和徽记配置。</span>
142
146
  <button type="button" data-prts-reset-confirm-action>确认恢复</button>
@@ -144,6 +148,7 @@ export function themeSettingsMarkup() {
144
148
  </div>
145
149
  </div>
146
150
  </footer>
151
+ ${themeTransferMarkup()}
147
152
  </div>
148
153
  <div data-prts-theme-settings-scroll>
149
154
  <section data-prts-settings-section="appearance" role="tabpanel" id="prts-page-appearance" aria-labelledby="prts-tab-appearance" tabindex="0">
@@ -159,7 +164,7 @@ export function themeSettingsMarkup() {
159
164
  </section>
160
165
  </section>
161
166
  <section data-prts-settings-section="background" role="tabpanel" id="prts-page-background" aria-labelledby="prts-tab-background" tabindex="0" hidden>
162
- ${backgroundMarkup()}
167
+ ${backgroundMarkup(settingButtons('heroContrast', '新会话徽记增强', []))}
163
168
  <div data-prts-composer-glass-setting>${settingButtons('composerGlass', '输入框增强毛玻璃', [])}</div>
164
169
  <small data-prts-composer-glass-required hidden>自定义背景下,输入框增强毛玻璃始终开启。</small>
165
170
  </section>
@@ -262,7 +267,7 @@ export function createThemeSettingsOverlay({
262
267
  trigger,
263
268
  panel,
264
269
  backdrop,
265
- customEmblemStore, avatarStore, backgroundStore,
270
+ customEmblemStore, avatarStore, backgroundStore, themeTransfer,
266
271
  onOpen = () => {},
267
272
  onPreferenceChange = () => {},
268
273
  onPreferencePreview = () => {},
@@ -270,6 +275,7 @@ export function createThemeSettingsOverlay({
270
275
  onRetrySave = () => {},
271
276
  }) {
272
277
  const window = document?.defaultView
278
+ const transferEditor = mountThemeTransfer({ panel, window, transfer: themeTransfer })
273
279
  const backgroundEditor = mountBackgroundSettings({panel,window,store:backgroundStore,onChange:onPreferenceChange})
274
280
  const avatarEditor = mountAvatarSettings({panel,window,store:avatarStore})
275
281
  const customEditor = mountCustomEmblemEditor({ panel, window, store: customEmblemStore })
@@ -749,6 +755,7 @@ export function createThemeSettingsOverlay({
749
755
  panel.querySelector('[data-prts-baker-options]').hidden = preferences?.conversationStyle !== 'deck-chat'
750
756
  panel.querySelector('[data-prts-avatars]').hidden = preferences?.conversationStyle !== 'deck-chat'
751
757
  const customBackground = preferences.backgroundMode !== 'off'
758
+ panel.querySelector('[data-prts-background-hero-setting]').hidden = !customBackground
752
759
  panel.querySelector('[data-prts-composer-glass-setting]').hidden = customBackground
753
760
  panel.querySelector('[data-prts-composer-glass-required]').hidden = !customBackground
754
761
  backgroundEditor.update(preferences)
@@ -766,6 +773,7 @@ export function createThemeSettingsOverlay({
766
773
  close: () => setOpen(false),
767
774
  toggle,
768
775
  dispose() {
776
+ transferEditor.dispose()
769
777
  customEditor.dispose()
770
778
  avatarEditor.dispose()
771
779
  backgroundEditor.dispose()
@@ -0,0 +1,98 @@
1
+ import { THEME_PACKAGE_LIMIT, validateThemePackage, describeThemePackage } from './theme-transfer.js'
2
+
3
+ export function themeTransferMarkup() {
4
+ return `<dialog data-prts-transfer-dialog aria-labelledby="prts-transfer-title">
5
+ <section data-prts-transfer>
6
+ <header data-prts-transfer-heading><div><small>P.R.T.S. / 主题文件</small><h2 id="prts-transfer-title">导入与导出</h2></div><button type="button" data-prts-transfer-close aria-label="关闭导入与导出">×</button></header>
7
+ <div data-prts-transfer-columns>
8
+ <section data-prts-transfer-card>
9
+ <h3>导出主题</h3><p>保存一份主题,备份或带到另一台设备。</p>
10
+ <label data-prts-transfer-option><input type="checkbox" data-prts-transfer-assets checked><span>包含自定义素材<small>背景、头像和徽记,保留编辑参数与排列。</small></span></label>
11
+ <small>取消勾选,仅导出主题设置。</small>
12
+ <button type="button" data-prts-transfer-export>导出主题文件 <span aria-hidden="true">↓</span></button>
13
+ </section>
14
+ <section data-prts-transfer-card>
15
+ <h3>导入主题</h3><p>选择已导出的文件,确认内容后应用。</p>
16
+ <div data-prts-transfer-file-hint><span aria-hidden="true">↑</span><small>.prts-theme.json · 最大 160 MB</small></div>
17
+ <button type="button" data-prts-transfer-choose>选择主题文件</button>
18
+ <input type="file" accept=".prts-theme.json,application/json" aria-label="导入主题文件" data-prts-transfer-file hidden>
19
+ </section>
20
+ </div>
21
+ <div data-prts-transfer-preview hidden>
22
+ <strong>确认导入</strong><p data-prts-transfer-summary></p>
23
+ <small>应用前会下载当前配置备份,请保留备份文件。</small>
24
+ <div data-prts-transfer-buttons><button type="button" data-prts-transfer-apply>确认导入</button><button type="button" data-prts-transfer-cancel>取消</button></div>
25
+ </div>
26
+ <div data-prts-transfer-recovery hidden><small>恢复将替换最近一次导入后所做的修改。</small><button type="button" data-prts-transfer-restore>恢复导入前配置</button></div>
27
+ <p data-prts-transfer-status role="status" aria-live="polite"></p>
28
+ </section>
29
+ </dialog>`
30
+ }
31
+
32
+ export function mountThemeTransfer({ panel, window, transfer }) {
33
+ const section = panel.querySelector('[data-prts-transfer]')
34
+ if (!section || !transfer) return { dispose() {} }
35
+ const dialog = panel.querySelector('[data-prts-transfer-dialog]')
36
+ const trigger = panel.querySelector('[data-prts-transfer-open]')
37
+ const open = () => { dialog.showModal(); trigger?.setAttribute('aria-expanded', 'true'); get('close').focus() }
38
+ const close = () => { if (busy) return; dialog.close(); trigger?.setAttribute('aria-expanded', 'false'); trigger?.focus() }
39
+ const keydown = event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close() } }
40
+ const cancel = event => { if (event.target === dialog) { event.preventDefault(); event.stopPropagation(); close() } }
41
+ const get = name => section.querySelector(`[data-prts-transfer-${name}]`)
42
+ let pending, busy = false, disposed = false, generation = 0
43
+ const render = () => {
44
+ if (disposed) return
45
+ get('preview').hidden = !pending
46
+ get('recovery').hidden = !transfer.hasBackup
47
+ for (const control of section.querySelectorAll('button,input')) control.disabled = busy
48
+ // Prevent local editors from racing a multi-service import; other clients use revision checks.
49
+ for (const page of panel.querySelectorAll('[data-prts-settings-section]')) if (!page.contains(section)) page.inert = busy
50
+ }
51
+ async function run(task) {
52
+ if (busy) return
53
+ busy = true; get('status').textContent = '正在处理,请稍候…'; render()
54
+ try { get('status').textContent = await task() }
55
+ catch (error) { if (!disposed) get('status').textContent = error.message }
56
+ finally { busy = false; render() }
57
+ }
58
+ const change = event => {
59
+ if (event.target !== get('file')) return
60
+ const file = event.target.files?.[0], selected = ++generation
61
+ pending = undefined; render()
62
+ if (!file) return
63
+ void run(async () => {
64
+ if (file.size > THEME_PACKAGE_LIMIT) throw Error('主题文件不能超过 160 MB')
65
+ const text = await new Promise((resolve, reject) => {
66
+ const reader = new window.FileReader()
67
+ reader.onload = () => resolve(reader.result); reader.onerror = () => reject(Error('无法读取主题文件')); reader.readAsText(file)
68
+ })
69
+ let value
70
+ try { value = JSON.parse(text) } catch { throw Error('主题文件不是有效的 JSON') }
71
+ validateThemePackage(value)
72
+ if (disposed || selected !== generation) return ''
73
+ pending = value
74
+ get('summary').textContent = describeThemePackage(value)
75
+ return '已读取文件,请确认导入内容。'
76
+ })
77
+ }
78
+ const click = event => {
79
+ const button = event.target.closest?.('button')
80
+ if (button === get('close')) close()
81
+ if (button === get('choose')) get('file').click()
82
+ if (button === get('export')) void run(async () => {
83
+ const value = await transfer.export(get('assets').checked)
84
+ transfer.download(value)
85
+ return '已发起下载,请确认主题文件已保存。'
86
+ })
87
+ if (button === get('apply') && pending) void run(async () => {
88
+ await transfer.import(pending); pending = undefined; get('file').value = ''
89
+ return '主题已导入并保存。'
90
+ })
91
+ if (button === get('cancel')) { pending = undefined; get('file').value = ''; get('status').textContent = ''; render() }
92
+ if (button === get('restore')) void run(async () => { await transfer.restore(); return '已恢复导入前配置。' })
93
+ }
94
+ trigger?.addEventListener('click', open)
95
+ dialog.addEventListener('keydown', keydown); dialog.addEventListener('cancel', cancel)
96
+ section.addEventListener('change', change); section.addEventListener('click', click); render()
97
+ return { dispose() { disposed = true; generation++; if (dialog.open) dialog.close(); trigger?.removeEventListener('click', open); dialog.removeEventListener('keydown', keydown); dialog.removeEventListener('cancel', cancel); section.removeEventListener('change', change); section.removeEventListener('click', click) } }
98
+ }