@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/CHANGELOG.md +12 -0
- package/README.en.md +8 -0
- package/README.md +8 -0
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/THIRD_PARTY_NOTICES.zh-CN.md +1 -1
- package/lib/client.js +378 -28
- package/package.json +2 -2
- package/src/client/conversation-control-adapter.js +2 -0
- package/src/client/custom-background.js +2 -1
- package/src/client/index.js +8 -0
- package/src/client/native-preferences.js +31 -0
- package/src/client/operations-shell-v2.js +2 -2
- package/src/client/particle-field-adapter.js +67 -14
- package/src/client/preferences.js +3 -1
- package/src/client/theme-controller-v2.js +2 -1
- package/src/client/theme-settings-workbench.js +13 -5
- package/src/client/theme-transfer-panel.js +98 -0
- package/src/client/theme-transfer.js +167 -0
- package/src/styles/custom-background.css +14 -0
- package/src/styles/refinement.css +22 -0
- package/src/styles/theme-settings-workbench.css +87 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ash-qw/dsh-theme-prts",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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>`
|
package/src/client/index.js
CHANGED
|
@@ -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
|
|
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
|
|
1341
|
-
|
|
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
|
-
|
|
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
|
|
1940
|
+
const previousRequestedDensity = heroActive
|
|
1892
1941
|
? previousPreferences.heroParticleDensity
|
|
1893
1942
|
: previousPreferences.conversationParticleDensity
|
|
1894
|
-
|
|
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 === '
|
|
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
|
+
}
|