@ash-qw/dsh-theme-prts 2.2.0 → 2.3.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.
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.1",
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",
@@ -0,0 +1,59 @@
1
+ import { backgroundPlacement } from '../shared/background-region.js'
2
+
3
+ export function createBackgroundLayout({window,document}) {
4
+ const root=document.documentElement
5
+ const raf=window.requestAnimationFrame?.bind(window) ?? (fn=>window.setTimeout(fn,16))
6
+ const caf=window.cancelAnimationFrame?.bind(window) ?? window.clearTimeout.bind(window)
7
+ let state={}, frame, disposed=false, enabled=false, observing=false, operation, sessions
8
+ const set=(name,value)=>{if(root.style.getPropertyValue(name)!==value) root.style.setProperty(name,value)}
9
+ const update=()=>{
10
+ frame=undefined
11
+ if(disposed||!observing)return
12
+ const nextOperation=document.querySelector('[data-prts-region="operation"]')
13
+ const nextSessions=document.querySelector('[data-prts-region="sessions"]')
14
+ if(operation!==nextOperation || sessions!==nextSessions){
15
+ resize?.disconnect(); operation=nextOperation;sessions=nextSessions
16
+ for(const node of [operation,sessions])if(node)resize?.observe(node)
17
+ }
18
+ const railWidth=parseFloat(window.getComputedStyle(root).getPropertyValue('--prts-nav-width'))||0
19
+ for(const slot of ['full','conversation','sidebar']){
20
+ const entry=state[slot]
21
+ const node=slot==='sidebar'?sessions:operation
22
+ const width=slot==='full'?window.innerWidth:(node?.clientWidth||0)+(slot==='sidebar'?railWidth:0)
23
+ const height=slot==='full'?window.innerHeight:node?.clientHeight||0
24
+ const active=entry?.region && width>0 && height>0
25
+ if(active){
26
+ const p=backgroundPlacement(entry.width,entry.height,width,height,entry.region)
27
+ set(`--prts-protected-${slot}-x`,`${p.x}px`)
28
+ set(`--prts-protected-${slot}-y`,`${p.y}px`)
29
+ if(slot==='sidebar')set('--prts-protected-sidebar-width',`${p.width}px`)
30
+ }else{
31
+ root.style.removeProperty(`--prts-protected-${slot}-x`)
32
+ root.style.removeProperty(`--prts-protected-${slot}-y`)
33
+ if(slot==='sidebar')root.style.removeProperty('--prts-protected-sidebar-width')
34
+ }
35
+ }
36
+ }
37
+ function schedule(){if(!disposed && observing && frame===undefined)frame=raf(update)}
38
+ const resize=window.ResizeObserver?new window.ResizeObserver(schedule):null
39
+ const mutations=new window.MutationObserver(()=>{
40
+ if(!operation?.isConnected || !sessions?.isConnected)schedule()
41
+ })
42
+ const attributes=new window.MutationObserver(schedule)
43
+ function clear(){
44
+ caf(frame);frame=undefined;resize?.disconnect();mutations.disconnect();attributes.disconnect();window.removeEventListener('resize',schedule)
45
+ operation=undefined;sessions=undefined;observing=false
46
+ for(const slot of ['full','conversation','sidebar'])for(const axis of ['x','y','width'])root.style.removeProperty(`--prts-protected-${slot}-${axis}`)
47
+ }
48
+ function reconcile(){
49
+ if(disposed || !enabled || !['full','conversation','sidebar'].some(slot=>state[slot]?.region)){clear();return}
50
+ if(!observing){
51
+ observing=true
52
+ mutations.observe(document.body||root,{childList:true,subtree:true})
53
+ attributes.observe(root,{attributes:true,attributeFilter:['data-prts-background-mode','data-prts-rail-mode']})
54
+ window.addEventListener('resize',schedule)
55
+ }
56
+ schedule()
57
+ }
58
+ return {update(value){state=value;reconcile()},setEnabled(value){enabled=value;reconcile()},dispose(){disposed=true;clear()}}
59
+ }
@@ -0,0 +1,103 @@
1
+ import { backgroundPlacement } from '../shared/background-region.js'
2
+
3
+ export function backgroundRegionMarkup(slot) {
4
+ return `<dialog data-prts-region-editor="${slot}" aria-labelledby="prts-region-title-${slot}">
5
+ <header><div><strong id="prts-region-title-${slot}">保护重要区域</strong><p>拖动框移动,拖动四边或四角缩放。横向优先保留中心,纵向优先保留顶端。</p></div><button type="button" data-prts-region-cancel aria-label="关闭保护区域设置">×</button></header>
6
+ <div data-prts-region-workspace><section data-prts-region-original><strong>原图与保护框</strong><div data-prts-region-stage><img alt="背景原图" draggable="false"><div data-prts-region-box tabindex="0" role="group" aria-label="保护区域,方向键移动"><span>保护区域</span>${['nw','ne','sw','se','n','e','s','w'].map((corner,i)=>`<button type="button" data-prts-region-handle="${corner}" aria-label="调整${['左上角','右上角','左下角','右下角','上边','右边','下边','左边'][i]},使用方向键"></button>`).join('')}</div><div data-prts-region-ruler="x"></div><div data-prts-region-ruler="y"></div></div><small>边缘刻度为图片坐标。可用方向键移动,Shift 加速。</small></section>
7
+ <section data-prts-region-samples><strong>窗口裁剪预览</strong><p>${slot==='sidebar' ? '按当前侧栏高度等比例预览,包含导航栏背景。' : '白框标出保护区域,调整时同步更新。'}</p><div data-prts-region-preview-grid>${(slot==='sidebar' ? [['narrow','较窄侧栏 · 264px'],['wide','较宽侧栏 · 420px']] : [['wide','横向窗口 · 16:9'],['narrow','纵向窗口 · 9:16']]).map(([kind,label])=>`<figure><figcaption>${label}</figcaption><div data-prts-region-preview="${kind}"><i></i></div><small data-prts-region-warning hidden>此比例无法完整显示保护区域:横向保中心,纵向保顶端。</small></figure>`).join('')}</div></section></div>
8
+ <footer><button type="button" data-prts-region-create>恢复默认框</button><button type="button" data-prts-region-clear>清除保护区域</button><span></span><button type="button" data-prts-region-cancel>取消</button><button type="button" data-prts-region-save>保存</button></footer><p data-prts-region-status role="status"></p>
9
+ </dialog>`
10
+ }
11
+ export function mountBackgroundRegionEditor({section,window,store}) {
12
+ let state=store.snapshot(), active, draft=null, revision, imageId, busy=false, disposed=false, drag
13
+ const clamp=(v,min,max)=>Math.max(min,Math.min(max,v))
14
+ const defaultRegion=()=>({x:.25,y:.2,width:.5,height:.6})
15
+ const editor=()=>active && section.querySelector(`[data-prts-region-editor="${active}"]`)
16
+ function draw(){
17
+ const el=editor(),entry=state[active];if(!el||!entry)return
18
+ const box=el.querySelector('[data-prts-region-box]')
19
+ box.hidden=!draft
20
+ if(draft)Object.assign(box.style,{left:`${draft.x*100}%`,top:`${draft.y*100}%`,width:`${draft.width*100}%`,height:`${draft.height*100}%`})
21
+ for(const axis of ['x','y']){
22
+ const ruler=el.querySelector(`[data-prts-region-ruler="${axis}"]`)
23
+ ruler.innerHTML=[0,25,50,75,100].filter(v=>!draft||Math.abs(v-draft[axis]*100)>8).map(v=>`<span style="${axis==='x'?'left':'top'}:${v}%">${v}</span>`).join('')
24
+ if(draft)ruler.innerHTML+=`<b style="${axis==='x'?'left':'top'}:${draft[axis]*100}%">${Math.round(draft[axis]*100)}%</b>`
25
+ }
26
+ const previews=[...el.querySelectorAll('[data-prts-region-preview]')]
27
+ const sidebarHeight=section.ownerDocument.querySelector('[data-prts-region="sessions"]')?.clientHeight||window.innerHeight
28
+ const railWidth=parseFloat(window.getComputedStyle(section.ownerDocument.documentElement).getPropertyValue('--prts-nav-width'))||0
29
+ // DSH 0.2.1-rc.2 clamps the sidebar track to 264–420px; use the same scale for both samples.
30
+ const sidebarScale=Math.min(240/Math.max(1,sidebarHeight),...previews.map(preview=>Math.max(1,preview.parentElement.clientWidth)/((preview.dataset.prtsRegionPreview==='narrow'?264:420)+railWidth)))
31
+ for(const preview of previews){
32
+ const narrow=preview.dataset.prtsRegionPreview==='narrow'
33
+ const w=active==='sidebar'?((narrow?264:420)+railWidth)*sidebarScale:Math.max(1,Math.min(narrow?112:200,preview.parentElement.clientWidth))
34
+ const h=active==='sidebar'?sidebarHeight*sidebarScale:w*(narrow?16/9:9/16)
35
+ const p=backgroundPlacement(entry.width,entry.height,w,h,draft)
36
+ Object.assign(preview.style,{width:`${w}px`,height:`${h}px`,backgroundImage:`url("/prts-theme-api/backgrounds/image/${entry.id}")`,backgroundSize:`${p.width}px ${p.height}px`,backgroundPosition:`${p.x}px ${p.y}px`})
37
+ const mark=preview.firstElementChild;mark.hidden=!draft
38
+ if(draft)Object.assign(mark.style,{left:`${p.x+draft.x*p.width}px`,top:`${p.y+draft.y*p.height}px`,width:`${draft.width*p.width}px`,height:`${draft.height*p.height}px`})
39
+ preview.parentElement.querySelector('[data-prts-region-warning]').hidden=!p.clipped
40
+ }
41
+ for(const button of el.querySelectorAll('button'))button.disabled=busy
42
+ }
43
+ const resize=window.ResizeObserver ? new window.ResizeObserver(()=>{if(!disposed)draw()}) : null
44
+ function close(){window.removeEventListener('resize',draw);resize?.disconnect();const el=editor();el?.querySelector('[data-prts-region-stage]').style.removeProperty('cursor');if(el?.open)el.close();if(active)section.querySelector(`[data-prts-background-region-open="${active}"]`)?.focus();active=undefined;drag=undefined}
45
+ function open(slot){
46
+ if(busy)return
47
+ close();const entry=state[slot];if(!entry?.width||!entry?.height)return
48
+ active=slot;revision=state.revision;imageId=entry.id;draft=entry.region?{...entry.region}:defaultRegion()
49
+ const el=editor();el.showModal();resize?.observe(el)
50
+ if(slot==='sidebar'){const sidebar=section.ownerDocument.querySelector('[data-prts-region="sessions"]');if(sidebar)resize?.observe(sidebar)}
51
+ window.addEventListener('resize',draw)
52
+ el.querySelector('[data-prts-region-stage]').style.width=`min(100%, ${Math.min(640,360*entry.width/entry.height)}px)`
53
+ el.querySelector('img').src=`/prts-theme-api/backgrounds/image/${entry.id}`
54
+ el.querySelector('[data-prts-region-status]').textContent=''
55
+ draw()
56
+ }
57
+ async function save(){
58
+ if(busy||!active)return
59
+ const el=editor(),slot=active,region=draft?{...draft}:null
60
+ busy=true;draw();el.querySelector('[data-prts-region-status]').textContent='正在保存…'
61
+ try{await store.saveRegion(slot,region,revision);if(!disposed){close();section.querySelector('[data-prts-background-status]').textContent='保护区域已保存,并同步到其他页面。';section.querySelector(`[data-prts-background-region-open="${slot}"]`).focus()}}
62
+ catch(error){if(!disposed)el.querySelector('[data-prts-region-status]').textContent=error.message+' 请取消后重新打开编辑。'}
63
+ finally{busy=false;if(!disposed)draw()}
64
+ }
65
+ const click=event=>{
66
+ const openButton=event.target.closest('[data-prts-background-region-open]');if(openButton){open(openButton.dataset.prtsBackgroundRegionOpen);return}
67
+ if(!active||busy)return
68
+ if(event.target.closest('[data-prts-region-cancel]'))close()
69
+ else if(event.target.closest('[data-prts-region-save]'))void save()
70
+ else if(event.target.closest('[data-prts-region-create]')){draft=defaultRegion();draw();editor().querySelector('[data-prts-region-box]').focus()}
71
+ else if(event.target.closest('[data-prts-region-clear]')){draft=null;draw()}
72
+ }
73
+ const down=event=>{
74
+ const el=editor(),stage=event.target.closest('[data-prts-region-stage]')
75
+ if(busy||!draft||!stage||!el?.contains(stage)||!event.target.closest('[data-prts-region-box]')||event.button!==0)return
76
+ event.preventDefault();const bounds=stage.getBoundingClientRect();if(!bounds.width||!bounds.height)return
77
+ const x=clamp((event.clientX-bounds.left)/bounds.width,0,1),y=clamp((event.clientY-bounds.top)/bounds.height,0,1)
78
+ drag={x,y,bounds,initial:{...draft},corner:event.target.dataset.prtsRegionHandle,id:event.pointerId}
79
+ stage.style.cursor=window.getComputedStyle(event.target).cursor
80
+ stage.setPointerCapture?.(event.pointerId);draw()
81
+ }
82
+ function adjust(initial,dx,dy,corner){
83
+ if(!corner)return {...initial,x:clamp(initial.x+dx,0,1-initial.width),y:clamp(initial.y+dy,0,1-initial.height)}
84
+ let left=initial.x,top=initial.y,right=left+initial.width,bottom=top+initial.height
85
+ if(corner.includes('w'))left=clamp(left+dx,0,right-.01);else if(corner.includes('e'))right=clamp(right+dx,left+.01,1)
86
+ if(corner.includes('n'))top=clamp(top+dy,0,bottom-.01);else if(corner.includes('s'))bottom=clamp(bottom+dy,top+.01,1)
87
+ return {x:left,y:top,width:/[ew]/.test(corner)?right-left:initial.width,height:/[ns]/.test(corner)?bottom-top:initial.height}
88
+ }
89
+ const move=event=>{if(!drag||event.pointerId!==drag.id)return;draft=adjust(drag.initial,(event.clientX-drag.bounds.left)/drag.bounds.width-drag.x,(event.clientY-drag.bounds.top)/drag.bounds.height-drag.y,drag.corner);draw()}
90
+ const up=event=>{if(drag&&event.pointerId===drag.id){if(event.type==='pointercancel')draft=drag.initial;drag=undefined;editor()?.querySelector('[data-prts-region-stage]').style.removeProperty('cursor');draw()}}
91
+ const key=event=>{
92
+ if(!active||busy)return
93
+ if(event.key==='Escape'){event.preventDefault();event.stopPropagation();close();return}
94
+ if(!draft||!event.target.closest('[data-prts-region-box]')||!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(event.key))return
95
+ event.preventDefault();const step=event.shiftKey ? .05 : .01
96
+ draft=adjust(draft,event.key==='ArrowLeft'?-step:event.key==='ArrowRight'?step:0,event.key==='ArrowUp'?-step:event.key==='ArrowDown'?step:0,event.target.dataset.prtsRegionHandle);draw()
97
+ }
98
+ const cancel=event=>{if(event.target.matches('[data-prts-region-editor]')){event.preventDefault();event.stopPropagation();if(!busy)close()}}
99
+ section.addEventListener('cancel',cancel,true)
100
+ const handlers={click,pointerdown:down,pointermove:move,pointerup:up,pointercancel:up,keydown:key}
101
+ for(const [name,fn] of Object.entries(handlers))section.addEventListener(name,fn)
102
+ return {update(value,mode){state=value;if(active&&(state[active]?.id!==imageId||mode==='off'||(mode==='full')!==(active==='full')))close()},dispose(){disposed=true;close();section.removeEventListener('cancel',cancel,true);for(const [name,fn] of Object.entries(handlers))section.removeEventListener(name,fn)}}
103
+ }
@@ -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.
@@ -69,7 +69,7 @@ export function createCursorAdapter({ document, window, assets }) {
69
69
  const target = event.target?.nodeType === 1 ? event.target : event.target?.parentElement
70
70
  if (target === resizeTarget && target.style.cursor === resizeApplied) return
71
71
  clearResize()
72
- if (!target) return
72
+ if (!target || target.closest('[data-prts-region-stage]')) return
73
73
  const cursor = window.getComputedStyle(target).cursor
74
74
  const direction = { 'n-resize': 'ns', 's-resize': 'ns', 'ns-resize': 'ns', 'row-resize': 'ns',
75
75
  'e-resize': 'ew', 'w-resize': 'ew', 'ew-resize': 'ew', 'col-resize': 'ew',
@@ -1,3 +1,5 @@
1
+ import { createBackgroundLayout } from './background-layout.js'
2
+ import { mountBackgroundRegionEditor, backgroundRegionMarkup } from './background-region-editor.js'
1
3
  import { opacityDialMarkup, mountOpacityDials } from './opacity-dial.js'
2
4
  const BACKGROUND_ENDPOINT = '/prts-theme-api/backgrounds'
3
5
  const backgroundLabels = { sidebar: '侧边栏', conversation: '会话正文', full: '完整背景' }
@@ -7,10 +9,12 @@ function backgroundImageUrl(entry) {
7
9
  export function createBackgroundStore({ window, document }) {
8
10
  let state = { revision: -1, sidebar: null, conversation: null, full: null }, disposed = false, events, refreshing
9
11
  const listeners = new Set()
12
+ const layout = createBackgroundLayout({window,document})
10
13
  const abort = window.AbortController ? new window.AbortController() : null
11
14
  const apply = value => {
12
15
  if (disposed || value.revision < state.revision) return
13
16
  state = value
17
+ layout.update(state)
14
18
  for (const slot of Object.keys(backgroundLabels)) {
15
19
  const url = backgroundImageUrl(state[slot])
16
20
  const root = document.documentElement
@@ -48,12 +52,18 @@ export function createBackgroundStore({ window, document }) {
48
52
  window.addEventListener('focus', focus)
49
53
  return {
50
54
  snapshot: () => state, refresh,
55
+ setEnabled: value => layout.setEnabled(value),
51
56
  subscribe(fn) { listeners.add(fn); fn(state); return () => listeners.delete(fn) },
52
57
  async save(slot, image, revision = state.revision) {
53
58
  try { return await request({ slot, image, revision }) }
54
59
  catch (error) { await refresh().catch(() => {}); throw error }
55
60
  },
61
+ async saveRegion(slot, region, revision = state.revision) {
62
+ try { return await request({ slot, region, revision }) }
63
+ catch (error) { await refresh().catch(() => {}); throw error }
64
+ },
56
65
  dispose() {
66
+ layout.dispose()
57
67
  document.documentElement.style.removeProperty('--prts-sidebar-image-ratio')
58
68
  disposed = true; abort?.abort(); events?.close(); listeners.clear(); window.removeEventListener('focus', focus)
59
69
  for (const slot of Object.keys(backgroundLabels)) {
@@ -63,7 +73,7 @@ export function createBackgroundStore({ window, document }) {
63
73
  },
64
74
  }
65
75
  }
66
- export function backgroundMarkup() {
76
+ export function backgroundMarkup(heroContrastControl = '') {
67
77
  return `<section data-prts-background-settings>
68
78
  <header><strong>会话背景</strong><small>分区使用不同图片,或让一张完整背景贯穿侧边栏与正文。</small></header>
69
79
  <div data-prts-background-modes role="group" aria-label="背景模式">
@@ -71,12 +81,16 @@ export function backgroundMarkup() {
71
81
  </div>
72
82
  <div data-prts-background-images hidden>${Object.entries(backgroundLabels).map(([slot,label]) => `<article data-prts-background-card="${slot}" hidden>
73
83
  <div data-prts-background-card-heading><strong>${label}</strong><button type="button" data-prts-background-remove="${slot}" aria-label="移除${label}图片" hidden>移除</button></div>
74
- <button type="button" data-prts-background-upload="${slot}" aria-label="上传或替换${label}"><span data-prts-background-thumbnail="${slot}" aria-hidden="true"></span><span data-prts-background-caption="${slot}">+ 选择图片</span></button>
84
+ <div data-prts-background-image-controls="${slot}"><span data-prts-background-thumbnail="${slot}" aria-hidden="true"></span><span data-prts-background-empty>尚未选择图片</span>
85
+ <div data-prts-background-image-actions><button type="button" data-prts-background-region-open="${slot}" hidden><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7 3H3v4m10-4h4v4M3 13v4h4m10-4v4h-4"/><rect x="6.5" y="6.5" width="7" height="7" rx="1.5"/></svg><span>保护区域</span></button><button type="button" data-prts-background-upload="${slot}" aria-label="上传或替换${label}"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="3" width="14" height="14" rx="3"/><path d="m4 14 4-4 3 3 2-2 4 4"/><circle cx="12.5" cy="7" r="1"/></svg><span data-prts-background-caption="${slot}">选择图片</span></button></div>
86
+ </div>
87
+ ${backgroundRegionMarkup(slot)}
75
88
  <input type="file" data-prts-background-file="${slot}" accept="image/png,image/jpeg,image/webp,image/avif" hidden>
76
89
  </article>`).join('')}</div>
77
90
  <label data-prts-background-shade hidden><span>背景淡化 <output></output></span><input type="range" min="0" max="90" step="5" aria-label="背景淡化程度"><small>调高后背景更淡,接近当前主题底色。</small></label>
91
+ <div data-prts-background-hero-setting hidden>${heroContrastControl}</div>
78
92
  ${opacityDialMarkup()}
79
- <small data-prts-background-help>支持 PNG / JPEG / WebP / AVIF · 最大 10 MB<br>图片居中铺满,切换模式保留素材,并在当前 DSH 实例同步。</small><p data-prts-background-status role="status"></p>
93
+ <small data-prts-background-help>支持 PNG / JPEG / WebP / AVIF · 最大 10 MB<br>图片铺满,可框选重要区域优先保留。切换模式保留素材,并在当前 DSH 实例同步。</small><p data-prts-background-status role="status"></p>
80
94
  </section>`
81
95
  }
82
96
  export function mountBackgroundSettings({ panel, window, store, onChange }) {
@@ -85,9 +99,11 @@ export function mountBackgroundSettings({ panel, window, store, onChange }) {
85
99
  let disposed = false, busy = false, preferences = {}, state = store.snapshot()
86
100
  const status = section.querySelector('[data-prts-background-status]')
87
101
  const range = section.querySelector('input[type="range"]')
102
+ const regions = mountBackgroundRegionEditor({section,window,store})
88
103
  const dials = mountOpacityDials({ section, window, onChange })
89
104
  function render() {
90
105
  dials.update(preferences, state)
106
+ regions.update(state, preferences.backgroundMode || 'off')
91
107
  const mode = preferences.backgroundMode || 'off'
92
108
  section.querySelector('[data-prts-background-images]').hidden = mode === 'off'
93
109
  section.querySelector('[data-prts-background-shade]').hidden = mode === 'off'
@@ -96,10 +112,11 @@ export function mountBackgroundSettings({ panel, window, store, onChange }) {
96
112
  for (const slot of Object.keys(backgroundLabels)) {
97
113
  const url = backgroundImageUrl(state[slot])
98
114
  section.querySelector(`[data-prts-background-card="${slot}"]`).hidden = mode === 'off' || (mode === 'full') !== (slot === 'full')
99
- section.querySelector(`[data-prts-background-upload="${slot}"]`).toggleAttribute('data-has-image', Boolean(url))
115
+ section.querySelector(`[data-prts-background-image-controls="${slot}"]`).toggleAttribute('data-has-image', Boolean(url))
100
116
  section.querySelector(`[data-prts-background-thumbnail="${slot}"]`).style.backgroundImage = url ? `url("${url}")` : ''
101
- section.querySelector(`[data-prts-background-caption="${slot}"]`).textContent = url ? '替换图片' : '+ 选择图片'
117
+ section.querySelector(`[data-prts-background-caption="${slot}"]`).textContent = url ? '替换图片' : '选择图片'
102
118
  section.querySelector(`[data-prts-background-remove="${slot}"]`).hidden = !url
119
+ section.querySelector(`[data-prts-background-region-open="${slot}"]`).hidden = !url
103
120
  }
104
121
  for (const button of section.querySelectorAll('[data-prts-background-upload], [data-prts-background-remove]')) button.disabled = busy
105
122
  }
@@ -138,6 +155,6 @@ export function mountBackgroundSettings({ panel, window, store, onChange }) {
138
155
  const unsubscribe = store.subscribe(value => { state = value; render() })
139
156
  return {
140
157
  update(value) { preferences = value; render() },
141
- dispose() { disposed = true; dials.dispose(); unsubscribe(); section.removeEventListener('click', click); section.removeEventListener('change', change); section.removeEventListener('input', input) },
158
+ dispose() { disposed = true; regions.dispose(); dials.dispose(); unsubscribe(); section.removeEventListener('click', click); section.removeEventListener('change', change); section.removeEventListener('input', input) },
142
159
  }
143
160
  }
@@ -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
@@ -176,6 +179,7 @@ export function applyPrtsPlugin(ctx, environment) {
176
179
  }
177
180
 
178
181
  const disposeVisualState = () => {
182
+ backgroundStore.setEnabled(false)
179
183
  particleField.update({ ...preferences, enabled: false })
180
184
  startup.stop({ restoreFocus: false })
181
185
  stopStartupReadyWatch()
@@ -198,9 +202,13 @@ export function applyPrtsPlugin(ctx, environment) {
198
202
  disposeVisualState()
199
203
  return
200
204
  }
205
+ backgroundStore.setEnabled(preferences.backgroundMode !== 'off')
201
206
  if (!themeAlreadyApplied) theme.apply(preferences)
202
207
  operations ||= createOperationsShell({
203
208
  document, window, assets, adapter, hostGeometry, customEmblemStore, avatarStore, backgroundStore,
209
+ themeTransfer: themeTransfer ||= createThemeTransfer({ window, getPreferences: () => nativePreferences.value,
210
+ savePreferences: (value, revision) => nativePreferences.replace(value, revision),
211
+ checkWritable: () => nativePreferences.checkWritable(), checkExport: () => nativePreferences.checkReadable(), themeVersion: version }),
204
212
  onSchemeToggle(next, interaction) {
205
213
  return theme.setTheme(next, { ...interaction, animate: true })
206
214
  },
@@ -347,6 +355,7 @@ export function applyPrtsPlugin(ctx, environment) {
347
355
  } else {
348
356
  applyVisualState()
349
357
  }
358
+ const stopBackgroundRedraw = backgroundStore.subscribe(() => { if (!safeMode) particleField.update(preferences) })
350
359
  const bindNativePreferences = settingsCtx => {
351
360
  const forms = contextService(settingsCtx, 'configForms')
352
361
  if (forms) settingsCtx.effect(() => nativePreferences.bind(forms.get('theme-prts')))
@@ -359,6 +368,7 @@ export function applyPrtsPlugin(ctx, environment) {
359
368
  statusSource.dispose()
360
369
  customEmblemStore.dispose()
361
370
  avatarStore.dispose()
371
+ stopBackgroundRedraw()
362
372
  backgroundStore.dispose()
363
373
  particleField.dispose()
364
374
  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),