@ash-qw/dsh-theme-prts 2.4.0 → 2.5.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.
@@ -1,20 +1,21 @@
1
- import { THEME_PACKAGE_LIMIT, validateThemePackage, describeThemePackage, ignoredThemePackageFields } from './theme-transfer.js'
1
+ import { mountFileDrop } from './file-drop.js'
2
+ import { checkTransferCancelled, readTransferFile, THEME_PACKAGE_LIMIT, validateThemePackage, describeThemePackage, ignoredThemePackageFields } from './theme-transfer.js'
2
3
 
3
4
  export function themeTransferMarkup() {
4
5
  return `<dialog data-prts-transfer-dialog aria-labelledby="prts-transfer-title">
5
6
  <section data-prts-transfer>
6
7
  <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
8
  <div data-prts-transfer-columns>
8
- <section data-prts-transfer-card>
9
+ <section data-prts-transfer-card="export">
9
10
  <h3>导出主题</h3><p>保存一份主题,备份或带到另一台设备。</p>
10
11
  <label data-prts-transfer-option><input type="checkbox" data-prts-transfer-assets checked><span>包含自定义素材<small>背景、头像和徽记,保留编辑参数与排列。</small></span></label>
11
12
  <small>取消勾选,仅导出主题设置。</small>
12
13
  <button type="button" data-prts-transfer-export>导出主题文件 <span aria-hidden="true">↓</span></button>
13
14
  </section>
14
- <section data-prts-transfer-card>
15
- <h3>导入主题</h3><p>选择已导出的文件,确认内容后应用。</p>
15
+ <section data-prts-transfer-card="import" data-prts-transfer-drop>
16
+ <h3>导入主题</h3><p>选择或拖入已导出的文件,确认内容后应用。</p>
16
17
  <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
+ <button type="button" data-prts-transfer-choose>选择主题文件 <span aria-hidden="true">↑</span></button>
18
19
  <input type="file" accept=".prts-theme.json,application/json" aria-label="导入主题文件" data-prts-transfer-file hidden>
19
20
  </section>
20
21
  </div>
@@ -25,6 +26,8 @@ export function themeTransferMarkup() {
25
26
  <div data-prts-transfer-buttons><button type="button" data-prts-transfer-apply>确认导入</button><button type="button" data-prts-transfer-cancel>取消</button></div>
26
27
  </div>
27
28
  <div data-prts-transfer-recovery hidden><small>恢复将替换最近一次导入后所做的修改。</small><button type="button" data-prts-transfer-restore>恢复导入前配置</button></div>
29
+ <div data-prts-transfer-progress hidden><div><span data-prts-transfer-phase></span><span data-prts-transfer-percent></span></div><progress data-prts-transfer-bar max="1" aria-label="主题文件处理进度"></progress></div>
30
+ <button type="button" data-prts-transfer-stop hidden>取消操作</button>
28
31
  <p data-prts-transfer-status role="status" aria-live="polite"></p>
29
32
  </section>
30
33
  </dialog>`
@@ -40,34 +43,56 @@ export function mountThemeTransfer({ panel, window, transfer }) {
40
43
  const keydown = event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close() } }
41
44
  const cancel = event => { if (event.target === dialog) { event.preventDefault(); event.stopPropagation(); close() } }
42
45
  const get = name => section.querySelector(`[data-prts-transfer-${name}]`)
43
- let pending, busy = false, disposed = false, generation = 0
46
+ let choosing = false, activeOperation, pending, busy = false, disposed = false, generation = 0, controller, cancellable = true
44
47
  const render = () => {
45
48
  if (disposed) return
49
+ for (const card of section.querySelectorAll('[data-prts-transfer-card]')) card.toggleAttribute('data-prts-transfer-active', card.dataset.prtsTransferCard === (busy ? activeOperation : pending || choosing ? 'import' : undefined))
46
50
  get('preview').hidden = !pending
47
51
  if (!pending) get('compatibility').hidden = true
48
52
  get('recovery').hidden = !transfer.hasBackup
49
53
  for (const control of section.querySelectorAll('button,input')) control.disabled = busy
54
+ get('stop').hidden = !busy
55
+ get('stop').disabled = !busy || !cancellable || controller?.signal.aborted
50
56
  // Prevent local editors from racing a multi-service import; other clients use revision checks.
51
57
  for (const page of panel.querySelectorAll('[data-prts-settings-section]')) if (!page.contains(section)) page.inert = busy
52
58
  }
53
- async function run(task) {
59
+ const progress = ({ value, label, cancellable: allowed }) => {
60
+ if (disposed) return
61
+ if (allowed === false) { cancellable = false; render() }
62
+ get('progress').hidden = false
63
+ get('phase').textContent = label
64
+ get('percent').textContent = value === null ? '' : `${Math.round(value * 100)}%`
65
+ if (value === null) get('bar').removeAttribute('value')
66
+ else get('bar').value = value
67
+ }
68
+ const paint = () => new Promise(resolve => window.setTimeout(resolve, 0))
69
+ async function run(kind, task) {
54
70
  if (busy) return
55
- busy = true; get('status').textContent = '正在处理,请稍候…'; render()
56
- try { get('status').textContent = await task() }
57
- catch (error) { if (!disposed) get('status').textContent = error.message }
58
- finally { busy = false; render() }
71
+ controller = new window.AbortController(); cancellable = true
72
+ activeOperation = kind
73
+ busy = true; section.setAttribute('aria-busy', 'true'); get('status').textContent = ''; get('progress').hidden = true; render()
74
+ progress({ value: null, label: '正在准备…' })
75
+ try {
76
+ await paint()
77
+ checkTransferCancelled(controller.signal)
78
+ const message = await task(controller.signal)
79
+ if (!disposed) { progress({ value: 1, label: '处理完成' }); get('status').textContent = message }
80
+ }
81
+ catch (error) { if (!disposed) { get('progress').hidden = true; get('status').textContent = error.name === 'AbortError' ? '操作已取消。' : error.message } }
82
+ finally { busy = false; if (!pending && !disposed) get('file').value = ''; if (!disposed) section.setAttribute('aria-busy', 'false'); render() }
59
83
  }
60
84
  const change = event => {
61
85
  if (event.target !== get('file')) return
86
+ choosing = false
62
87
  const file = event.target.files?.[0], selected = ++generation
63
88
  pending = undefined; render()
64
89
  if (!file) return
65
- void run(async () => {
90
+ void run('import', async signal => {
66
91
  if (file.size > THEME_PACKAGE_LIMIT) throw Error('主题文件不能超过 160 MB')
67
- const text = await new Promise((resolve, reject) => {
68
- const reader = new window.FileReader()
69
- reader.onload = () => resolve(reader.result); reader.onerror = () => reject(Error('无法读取主题文件')); reader.readAsText(file)
70
- })
92
+ const text = await readTransferFile(window, file, signal, event => progress({ value: event.lengthComputable && event.total ? event.loaded / event.total * 0.8 : null, label: '正在读取主题文件…' }))
93
+ progress({ value: 0.8, label: '正在解析并检查主题文件…' })
94
+ await paint()
95
+ checkTransferCancelled(signal)
71
96
  let value
72
97
  try { value = JSON.parse(text) } catch { throw Error('主题文件不是有效的 JSON') }
73
98
  validateThemePackage(value)
@@ -85,22 +110,31 @@ export function mountThemeTransfer({ panel, window, transfer }) {
85
110
  }
86
111
  const click = event => {
87
112
  const button = event.target.closest?.('button')
113
+ if (button === get('stop') && busy && cancellable) {
114
+ controller.abort(); progress({ value: null, label: '正在取消,请稍候…', cancellable: false }); return
115
+ }
88
116
  if (button === get('close')) close()
89
- if (button === get('choose')) get('file').click()
90
- if (button === get('export')) void run(async () => {
91
- const value = await transfer.export(get('assets').checked)
117
+ if (button === get('choose')) { choosing = true; render(); get('file').click() }
118
+ if (button === get('export')) void run('export', async signal => {
119
+ const value = await transfer.export(get('assets').checked, state => progress({ ...state, value: state.value * 0.9 }), signal)
120
+ progress({ value: 0.9, label: '正在生成下载文件…' })
121
+ await paint()
122
+ checkTransferCancelled(signal)
92
123
  transfer.download(value)
93
124
  return '已发起下载,请确认主题文件已保存。'
94
125
  })
95
- if (button === get('apply') && pending) void run(async () => {
96
- await transfer.import(pending); pending = undefined; get('file').value = ''
126
+ if (button === get('apply') && pending) void run('import', async signal => {
127
+ await transfer.import(pending, progress, signal); pending = undefined; get('file').value = ''
97
128
  return '主题已导入并保存。'
98
129
  })
99
- if (button === get('cancel')) { pending = undefined; get('file').value = ''; get('status').textContent = ''; render() }
100
- if (button === get('restore')) void run(async () => { await transfer.restore(); return '已恢复导入前配置。' })
130
+ if (button === get('cancel')) { pending = undefined; get('file').value = ''; get('status').textContent = ''; get('progress').hidden = true; render() }
131
+ if (button === get('restore')) void run('import', async signal => { await transfer.restore(progress, signal); return '已恢复导入前配置。' })
101
132
  }
133
+ const drop = mountFileDrop({ area: get('drop'), input: get('file'), enabled: () => !busy && !disposed, onError: message => { get('status').textContent = message } })
134
+ const cancelChoice = () => { choosing = false; render() }
135
+ get('file').addEventListener('cancel', cancelChoice)
102
136
  trigger?.addEventListener('click', open)
103
137
  dialog.addEventListener('keydown', keydown); dialog.addEventListener('cancel', cancel)
104
138
  section.addEventListener('change', change); section.addEventListener('click', click); render()
105
- 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) } }
139
+ return { dispose() { get('file').removeEventListener('cancel', cancelChoice); drop.dispose(); disposed = true; controller?.abort(); 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) } }
106
140
  }
@@ -1,6 +1,25 @@
1
1
  import { validBackgroundRegion } from '../shared/background-region.js'
2
2
  import { DEFAULT_PREFERENCES, normalizePreferences } from './preferences.js'
3
3
 
4
+ export function checkTransferCancelled(signal) {
5
+ if (signal?.aborted) throw Object.assign(new Error('操作已取消'), { name: 'AbortError' })
6
+ }
7
+
8
+ export function readTransferFile(window, blob, signal, onProgress, asImage = false) {
9
+ return new Promise((resolve, reject) => {
10
+ checkTransferCancelled(signal)
11
+ const reader = new window.FileReader()
12
+ const cleanup = () => signal?.removeEventListener('abort', abort)
13
+ const abort = () => { reader.abort(); cleanup(); reject(Object.assign(new Error('操作已取消'), { name: 'AbortError' })) }
14
+ reader.onload = () => { cleanup(); resolve(asImage ? String(reader.result).split(',')[1] : reader.result) }
15
+ reader.onerror = () => { cleanup(); reject(Error('无法读取主题文件或图片')) }
16
+ reader.onprogress = onProgress
17
+ signal?.addEventListener('abort', abort, { once: true })
18
+ if (asImage) reader.readAsDataURL(blob)
19
+ else reader.readAsText(blob)
20
+ })
21
+ }
22
+
4
23
  export const THEME_PACKAGE_LIMIT = 160 * 1024 * 1024
5
24
  const transferSlots = ['full', 'sidebar', 'conversation']
6
25
  const transferRoles = ['user', 'assistant']
@@ -84,61 +103,64 @@ export function describeThemePackage(value) {
84
103
 
85
104
  export function createThemeTransfer({ window, getPreferences, savePreferences, checkWritable, checkExport = checkWritable, themeVersion }) {
86
105
  let busy = false, backup
87
- const request = async (path, data) => {
88
- const response = await window.fetch('/prts-theme-api/' + path, { credentials: 'same-origin',
106
+ const request = async (path, data, signal) => {
107
+ checkTransferCancelled(signal)
108
+ const response = await window.fetch('/prts-theme-api/' + path, { credentials: 'same-origin', signal,
89
109
  ...(data === undefined ? {} : { method: 'POST', headers: { 'content-type': 'application/json', 'x-prts-request': '1' }, body: JSON.stringify(data) }) })
90
110
  const result = await response.json()
91
111
  if (!response.ok) throw Error(result.error || '无法连接主题素材服务')
92
112
  return result
93
113
  }
94
- const readImage = blob => new Promise((resolve, reject) => {
95
- const reader = new window.FileReader()
96
- reader.onload = () => resolve(String(reader.result).split(',')[1])
97
- reader.onerror = () => reject(Error('无法读取背景图片'))
98
- reader.readAsDataURL(blob)
99
- })
100
- async function capture(includeAssets) {
114
+ async function capture(includeAssets, progress = () => {}, signal) {
115
+ checkTransferCancelled(signal)
116
+ progress({ value: 0, label: '正在读取主题设置…' })
101
117
  const value = { format: 'prts-theme', version: 1, themeVersion, exportedAt: new Date().toISOString(), preferences: { ...getPreferences() } }
102
118
  const states = {}
103
119
  if (includeAssets) {
104
120
  const backgrounds = {}, backgroundRegions = {}, avatars = {}, items = [], ids = new Map()
105
- states.backgrounds = await request('backgrounds/configuration')
121
+ progress({ value: 0.1, label: '正在读取背景…' })
122
+ states.backgrounds = await request('backgrounds/configuration', undefined, signal)
106
123
  for (const slot of transferSlots) {
107
124
  const entry = states.backgrounds[slot]
108
125
  backgroundRegions[slot] = entry?.region ?? null
109
126
  backgrounds[slot] = null
110
127
  if (entry) {
111
128
  if (!/^[a-f0-9-]{36}$/.test(entry.id)) throw Error('背景引用无效')
112
- const response = await window.fetch('/prts-theme-api/backgrounds/image/' + entry.id, { credentials: 'same-origin' })
129
+ const response = await window.fetch('/prts-theme-api/backgrounds/image/' + entry.id, { credentials: 'same-origin', signal })
113
130
  if (!response.ok) throw Error('无法导出背景图片')
114
- backgrounds[slot] = await readImage(await response.blob())
131
+ backgrounds[slot] = await readTransferFile(window, await response.blob(), signal, undefined, true)
115
132
  }
116
133
  }
117
- states.avatars = await request('avatars/configuration')
134
+ progress({ value: 0.35, label: '正在读取头像…' })
135
+ states.avatars = await request('avatars/configuration', undefined, signal)
118
136
  for (const role of transferRoles) {
119
- const source = await request('avatars/edit/' + role)
137
+ const source = await request('avatars/edit/' + role, undefined, signal)
120
138
  if (source.revision !== states.avatars.revision) throw Error('头像已变化,请重新导出')
121
139
  avatars[role] = source.image ? { image: source.image, crop: source.crop } : null
122
140
  }
123
- states.emblems = await request('emblems/configuration')
141
+ progress({ value: 0.5, label: '正在读取徽记…' })
142
+ states.emblems = await request('emblems/configuration', undefined, signal)
124
143
  async function reference(entry) {
125
144
  if (!entry) return null
126
145
  if (entry.kind === 'builtin') return { kind: 'builtin', key: entry.key, label: entry.label }
127
146
  if (!ids.has(entry.draftId)) {
128
147
  ids.set(entry.draftId, items.length)
129
- items.push(await request('emblems/draft/' + encodeURIComponent(entry.draftId) + '/export'))
148
+ items.push(await request('emblems/draft/' + encodeURIComponent(entry.draftId) + '/export', undefined, signal))
130
149
  }
131
150
  return { kind: 'custom', asset: ids.get(entry.draftId), label: entry.label }
132
151
  }
133
152
  const hero = await reference(states.emblems.configuration.hero), conversation = []
134
153
  for (const entry of states.emblems.configuration.conversation) conversation.push(await reference(entry))
135
154
  value.assets = { backgrounds, backgroundRegions, avatars, emblems: { items, hero, conversation } }
155
+ progress({ value: 0.85, label: '正在核对素材…' })
136
156
  for (const kind of ['backgrounds', 'avatars', 'emblems']) {
137
- if ((await request(kind + '/configuration')).revision !== states[kind].revision) throw Error('素材在导出期间发生变化,请重试')
157
+ if ((await request(kind + '/configuration', undefined, signal)).revision !== states[kind].revision) throw Error('素材在导出期间发生变化,请重试')
138
158
  }
139
159
  }
140
160
  if (JSON.stringify(value.preferences) !== JSON.stringify(getPreferences())) throw Error('设置在导出期间发生变化,请重试')
161
+ checkTransferCancelled(signal)
141
162
  validateThemePackage(value)
163
+ progress({ value: 1, label: '主题数据已就绪' })
142
164
  return { value, states }
143
165
  }
144
166
  async function exclusive(fn) {
@@ -154,14 +176,23 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
154
176
  window.document.body.append(link); link.click(); link.remove()
155
177
  window.setTimeout(() => window.URL.revokeObjectURL(url), 1000)
156
178
  }
157
- async function apply(value) {
179
+ async function apply(value, progress = () => {}, signal) {
180
+ checkTransferCancelled(signal)
158
181
  validateThemePackage(value)
159
182
  const preferenceRevision = checkWritable()
160
- const before = await capture(Boolean(value.assets))
183
+ const before = await capture(Boolean(value.assets), state => progress({ value: state.value * 0.3, label: '备份:' + state.label }), signal)
161
184
  backup = before.value
185
+ progress({ value: 0.3, label: '正在生成导入前备份…' })
186
+ await new Promise(resolve => window.setTimeout(resolve, 0))
187
+ checkTransferCancelled(signal)
162
188
  download(backup, 'before-import')
189
+ let completed = 0
190
+ const total = value.assets ? value.assets.emblems.items.length + 7 : 1
191
+ const step = label => progress({ value: 0.35 + 0.65 * completed / total, label })
163
192
  const undo = [], revisions = Object.fromEntries(Object.entries(before.states).map(([key, state]) => [key, state.revision]))
164
193
  const write = async (kind, data, previous) => {
194
+ checkTransferCancelled(signal)
195
+ // Await writes without aborting: their confirmed revision is needed for rollback.
165
196
  const result = await request(kind + '/configuration', { ...data, revision: revisions[kind] })
166
197
  revisions[kind] = result.revision
167
198
  undo.push(async () => {
@@ -173,24 +204,35 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
173
204
  if (value.assets) {
174
205
  const a = value.assets, old = before.value.assets, drafts = []
175
206
  // Generate every custom draft before changing the active configuration.
176
- for (const item of a.emblems.items) drafts.push(await request('emblems/import', item))
207
+ for (const item of a.emblems.items) {
208
+ step('正在导入徽记素材…')
209
+ checkTransferCancelled(signal)
210
+ drafts.push(await request('emblems/import', item)); completed++
211
+ }
177
212
  const entry = (ref, index) => ref === null ? null : ref.kind === 'builtin' ? ref : { kind: 'custom', key: 'import-' + index + '-' + drafts[ref.asset].draftId, label: ref.label, draftId: drafts[ref.asset].draftId }
178
213
  const configuration = { version: 2, hero: entry(a.emblems.hero, 'hero'), conversation: a.emblems.conversation.map(entry) }
179
- for (const slot of transferSlots) await write('backgrounds', { slot, image: a.backgrounds[slot], region: a.backgroundRegions?.[slot] ?? null }, { slot, image: old.backgrounds[slot], region: old.backgroundRegions?.[slot] ?? null })
180
- for (const role of transferRoles) await write('avatars', { role, image: a.avatars[role]?.image ?? null, crop: a.avatars[role]?.crop }, { role, image: old.avatars[role]?.image ?? null, crop: old.avatars[role]?.crop })
181
- await write('emblems', { configuration }, { configuration: before.states.emblems.configuration })
214
+ for (const slot of transferSlots) { step('正在应用背景…'); await write('backgrounds', { slot, image: a.backgrounds[slot], region: a.backgroundRegions?.[slot] ?? null }, { slot, image: old.backgrounds[slot], region: old.backgroundRegions?.[slot] ?? null }); completed++ }
215
+ for (const role of transferRoles) { step('正在应用头像…'); await write('avatars', { role, image: a.avatars[role]?.image ?? null, crop: a.avatars[role]?.crop }, { role, image: old.avatars[role]?.image ?? null, crop: old.avatars[role]?.crop }); completed++ }
216
+ step('正在应用徽记排列…')
217
+ await write('emblems', { configuration }, { configuration: before.states.emblems.configuration }); completed++
182
218
  }
219
+ step('正在保存主题设置…')
220
+ checkTransferCancelled(signal)
221
+ progress({ value: 0.99, label: '正在保存主题设置…', cancellable: false })
183
222
  await savePreferences(mergeThemePreferences(before.value.preferences, value.preferences), preferenceRevision)
223
+ progress({ value: 1, label: '主题已保存' })
184
224
  } catch (error) {
225
+ progress({ value: null, label: signal?.aborted ? '正在取消,回退素材…' : '导入失败,正在回退素材…', cancellable: false })
185
226
  let failures = 0
186
227
  for (const restore of undo.reverse()) { try { await restore() } catch { failures++ } }
228
+ if (signal?.aborted && !failures && error.name === 'AbortError') throw Error('操作已取消,已回退已写入的素材。')
187
229
  throw Error(`${error.message}。${failures ? '部分素材未能自动恢复。' : '已回退已确认写入的素材。'}若连接中断,请检查实际状态;导入前备份已发起下载,可重新导入恢复。`)
188
230
  }
189
231
  }
190
232
  return {
191
- export: includeAssets => exclusive(async () => { checkExport(); return (await capture(includeAssets)).value }),
192
- import: value => exclusive(() => apply(value)),
193
- restore: () => exclusive(() => { if (!backup) throw Error('没有可恢复的备份'); return apply(backup) }),
233
+ export: (includeAssets, progress, signal) => exclusive(async () => { checkExport(); return (await capture(includeAssets, progress, signal)).value }),
234
+ import: (value, progress, signal) => exclusive(() => apply(value, progress, signal)),
235
+ restore: (progress, signal) => exclusive(() => { if (!backup) throw Error('没有可恢复的备份'); return apply(backup, progress, signal) }),
194
236
  get hasBackup() { return Boolean(backup) },
195
237
  download,
196
238
  }
@@ -187,18 +187,45 @@ html[data-dsh-prts][data-prts-conversation-style="deck-chat"]:is(
187
187
  --prts-chat-user-surface: rgb(255 255 255 / var(--prts-baker-opacity, 50%));
188
188
  --prts-chat-assistant-surface: rgb(223 227 223 / var(--prts-baker-opacity, 50%));
189
189
  }
190
+ /* One backdrop plane covers the body and tail; the parent must not create
191
+ a nested backdrop root. The mask rounds the body without clipping content. */
190
192
  & :is([data-chat-flow-kind="user"], [data-submission-echo]:not([data-pending-steering])) [class$="_bubble"],
191
- & :is([data-prts-ai-surface], [data-message-role="user"], [data-message-role="assistant"]),
192
- & [data-chat-flow-kind="assistant-step"]:not([data-chat-group-part="reasoning"]) [class$="_body"]:has(:is([data-markdown], [data-slot*="markdown" i], .markdown-body)) {
193
- -webkit-backdrop-filter: blur(var(--prts-baker-blur, 8px)) saturate(1.08);
194
- backdrop-filter: blur(var(--prts-baker-blur, 8px)) saturate(1.08);
193
+ & :is([data-prts-ai-surface], [data-message-role="user"], [data-message-role="assistant"]) {
194
+ isolation: isolate;
195
+ background: transparent !important;
196
+ -webkit-backdrop-filter: none;
197
+ backdrop-filter: none;
195
198
  }
196
199
  & [data-prts-ai-surface]::before,
197
200
  & [data-message-role="assistant"]::after,
198
201
  & :is([data-chat-flow-kind="user"], [data-submission-echo]:not([data-pending-steering])) [class$="_bubble"]::before,
199
202
  & [data-message-role="user"]::before {
200
- -webkit-backdrop-filter: blur(var(--prts-baker-blur, 8px)) saturate(1.08);
201
- backdrop-filter: blur(var(--prts-baker-blur, 8px)) saturate(1.08);
203
+ inset: 0 0 0 -10px; width: auto; height: auto; z-index: -1;
204
+ transform: none;
205
+ -webkit-backdrop-filter: blur(var(--prts-baker-blur, 8px)) saturate(1.08);
206
+ backdrop-filter: blur(var(--prts-baker-blur, 8px)) saturate(1.08);
207
+ mask:
208
+ linear-gradient(#000 0 0) 10px 0 / calc(100% - 28px) calc(100% - 18px),
209
+ linear-gradient(#000 0 0) 10px 18px / calc(100% - 10px) calc(100% - 36px),
210
+ linear-gradient(#000 0 0) 28px bottom / calc(100% - 46px) 18px,
211
+ radial-gradient(circle at left bottom, #000 17.5px, transparent 18px) right top / 18px 18px,
212
+ radial-gradient(circle at right top, #000 17.5px, transparent 18px) 10px bottom / 18px 18px,
213
+ radial-gradient(circle at left top, #000 17.5px, transparent 18px) right bottom / 18px 18px,
214
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 18'%3E%3Cpath d='M0 0H10V18Q10 6 0 0Z'/%3E%3C/svg%3E") left top / 10px 18px;
215
+ mask-repeat: no-repeat;
216
+ }
217
+ & :is([data-chat-flow-kind="user"], [data-submission-echo]:not([data-pending-steering])) [class$="_bubble"]::before,
218
+ & [data-message-role="user"]::before {
219
+ inset: 0 -10px 0 0;
220
+ mask:
221
+ linear-gradient(#000 0 0) 18px 0 / calc(100% - 28px) calc(100% - 18px),
222
+ linear-gradient(#000 0 0) 0 18px / calc(100% - 10px) calc(100% - 36px),
223
+ linear-gradient(#000 0 0) 18px bottom / calc(100% - 46px) 18px,
224
+ radial-gradient(circle at right bottom, #000 17.5px, transparent 18px) left top / 18px 18px,
225
+ radial-gradient(circle at right top, #000 17.5px, transparent 18px) left bottom / 18px 18px,
226
+ radial-gradient(circle at left top, #000 17.5px, transparent 18px) right 10px bottom 0 / 18px 18px,
227
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 18'%3E%3Cpath d='M10 0H0V18Q0 6 10 0Z'/%3E%3C/svg%3E") right top / 10px 18px;
228
+ mask-repeat: no-repeat;
202
229
  }
203
230
  }
204
231
 
@@ -419,3 +446,36 @@ html[data-dsh-prts][data-windows-titlebar] [data-prts-region="operation"] { bord
419
446
  [data-prts-region-handle="s"] {left:50%;bottom:-8px;transform:translateX(-50%);cursor:ns-resize}
420
447
  [data-prts-region-handle="w"] {left:-8px;top:50%;transform:translateY(-50%);cursor:ew-resize}
421
448
  [data-prts-region-handle="e"] {right:-8px;top:50%;transform:translateY(-50%);cursor:ew-resize}
449
+
450
+ html[data-dsh-prts] [data-prts-background-confirm] {
451
+ box-sizing: border-box; width: min(560px, calc(100vw - 32px)); max-height: calc(100dvh - 32px);
452
+ padding: 24px; overflow: auto; border: 1px solid var(--prts-line-strong); border-top: 2px solid var(--prts-yellow);
453
+ border-radius: 8px; background: var(--prts-canvas); color: var(--prts-ink); box-shadow: 0 24px 80px #0006;
454
+ font: 13px/1.6 var(--prts-body);
455
+ &::backdrop { background: #0008; backdrop-filter: blur(4px); }
456
+ & header { display: block; margin: 0 0 20px; padding: 0; border: 0; }
457
+ & header small { color: var(--prts-muted); font-size: 12px; }
458
+ & h2 { margin: 4px 0 0; color: var(--prts-ink); font-size: 21px; line-height: 1.4; }
459
+ & [data-prts-background-compare] { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
460
+ & figure { min-width: 0; margin: 0; }
461
+ & img { display: block; box-sizing: border-box; width: 100%; aspect-ratio: 4/3; object-fit: contain; border: 1px solid var(--prts-line); border-radius: 4px; background: var(--prts-panel-raised); }
462
+ & figure:last-child img { border-color: var(--prts-yellow); }
463
+ & figcaption { margin-top: 8px; font-size: 12px; }
464
+ & [data-prts-background-filename] { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--prts-muted); }
465
+ & p { margin: 20px 0; color: var(--prts-muted); font-size: 12px; }
466
+ & footer { display: flex; justify-content: flex-end; gap: 10px; padding-top: 16px; border-top: 1px solid var(--prts-line); }
467
+ & button { min-width: 88px; padding: 9px 16px; border: 1px solid var(--prts-line-strong); border-radius: 4px; background: var(--prts-panel-raised); color: var(--prts-ink); font: inherit; cursor: pointer; }
468
+ & [data-prts-background-confirm-apply] { border-color: var(--prts-yellow); background: var(--prts-yellow); color: #171b1d; font-weight: 600; }
469
+ & button:hover { filter: brightness(.94); }
470
+ & button:focus-visible { outline: 2px solid var(--prts-yellow); outline-offset: 3px; }
471
+ }
472
+ @media (max-width: 480px) {
473
+ html[data-dsh-prts] [data-prts-background-confirm] { padding: 18px; }
474
+ }
475
+
476
+ /* Expanded process headers remain sticky without painting the native opaque base. */
477
+ html[data-dsh-prts]:is([data-prts-background-mode="full"][data-prts-background-full],[data-prts-background-mode="split"][data-prts-background-conversation]) [data-prts-region="operation"] :is([data-variant="think"][data-expanded] [data-open] > [data-disclosure-header],button[aria-expanded="true"]:has([data-compaction-disclosure])) {
478
+ background:color-mix(in srgb,var(--prts-panel) 28%,transparent);
479
+ -webkit-backdrop-filter:blur(12px);
480
+ backdrop-filter:blur(12px);
481
+ }
@@ -1542,7 +1542,7 @@ html[data-dsh-prts] [data-prts-conversation-control] {
1542
1542
 
1543
1543
  html[data-dsh-prts] [data-composer-card] button:not([data-slot="conversation.input.attachments"] *):not([data-prts-glass-menu] *):not([data-prts-floating-glass] *):not([role="menu"] *):not([role="listbox"] *):is(:hover, :focus-visible),
1544
1544
  html[data-dsh-prts] [data-prts-conversation-control]:is(:hover, :focus-visible) {
1545
- border-color: var(--prts-yellow) !important;
1545
+ border-color: var(--prts-line) !important;
1546
1546
  }
1547
1547
 
1548
1548
  html[data-dsh-prts] [data-composer-card] button[aria-label*="send" i],
@@ -1606,7 +1606,9 @@ html[data-dsh-prts] [data-prts-floating-item] {
1606
1606
  color: var(--prts-ink) !important;
1607
1607
  }
1608
1608
 
1609
- html[data-dsh-prts] [data-prts-floating-item]:is(:hover, [data-highlighted]) {
1609
+ /* Native material menus own their item states, including body portals without
1610
+ aria-controls or composer markers. Keep generic fallback styling separate. */
1611
+ html[data-dsh-prts] [data-prts-floating-item]:not([data-menu-material] *, [data-prts-glass-menu] *, [data-slot="conversation.input.overlay"] *):is(:hover, [data-highlighted]) {
1610
1612
  background: var(--prts-panel-muted) !important;
1611
1613
  }
1612
1614
 
@@ -364,7 +364,7 @@ html[data-dsh-prts] :is(
364
364
  [data-prts-glass-control],
365
365
  [data-prts-conversation-control]:not([data-prts-conversation-control="micro"], [data-prts-conversation-control="file"])
366
366
  ):is(:hover, :focus-visible) {
367
- --prts-control-outline: var(--prts-yellow);
367
+ --prts-control-outline: var(--prts-line);
368
368
  }
369
369
 
370
370
  html[data-dsh-prts] :is(
@@ -464,7 +464,7 @@ html[data-dsh-prts] :is([data-presented-file],[data-changed-files],[data-prts-co
464
464
  background:var(--prts-active-glass)!important;color:var(--prts-ink);
465
465
  backdrop-filter:var(--prts-active-glass-filter);
466
466
  &::before{content:none}
467
- &:is(:hover,:focus-within){border-color:var(--prts-yellow)!important}
467
+ &:is(:hover,:focus-within){border-color:var(--prts-glass-border)!important}
468
468
  }
469
469
  /* One surface for both single-file summaries and grouped changes. */
470
470
  html[data-dsh-prts] [data-changed-files]{
@@ -510,3 +510,42 @@ html[data-dsh-prts] [data-turn-trigger] {
510
510
  & > div > p { color:var(--prts-muted); }
511
511
  & [data-context-text] { color:var(--prts-ink); }
512
512
  }
513
+
514
+ /* Body interactions lock onto four corners; shell controls keep their own treatment. */
515
+ html[data-dsh-prts] :is([data-chat-flow-kind],[data-message-role]) :is([data-prts-conversation-control],[data-presented-file],[data-changed-files],[data-produced-files-row] > button) {
516
+ position:relative;
517
+ --prts-control-outline:var(--prts-line);
518
+ &:is(:hover,:focus-visible,:focus-within) {
519
+ --prts-control-outline:var(--prts-line);
520
+ border-color:var(--prts-line)!important;
521
+ }
522
+ &:is([data-presented-file],[data-changed-files],[data-prts-conversation-control="file"]) {
523
+ &:is(:hover,:focus-visible,:focus-within){border-color:var(--prts-glass-border)!important}
524
+ }
525
+ &:active{transform:none}
526
+ }
527
+ html[data-dsh-prts] [data-prts-body-target-frame] {
528
+ position:fixed;inset:auto;left:0;top:0;width:0;height:0;margin:0;padding:0;border:0;background:transparent;overflow:visible;z-index:100;pointer-events:none;opacity:0;--prts-corner-width:1px;
529
+ & > i {
530
+ position:absolute;left:0;top:0;width:7px;height:7px;will-change:transform;
531
+ &::before,&::after{content:"";position:absolute;left:0;top:0;background:currentColor}
532
+ &::before{width:7px;height:var(--prts-corner-width);transform:scaleX(var(--prts-arm-scale,1));transform-origin:left center}
533
+ &::after{width:var(--prts-corner-width);height:7px;transform:scaleY(var(--prts-arm-scale,1));transform-origin:center top}
534
+ &:nth-child(2),&:nth-child(4){
535
+ &::before{transform-origin:right center}
536
+ &::after{left:auto;right:0}
537
+ }
538
+ &:nth-child(3),&:nth-child(4){
539
+ &::before{top:auto;bottom:0}
540
+ &::after{transform-origin:center bottom}
541
+ }
542
+ }
543
+ &[data-pressed]{--prts-corner-width:2px;filter:brightness(1.3)}
544
+ }
545
+
546
+ html[data-dsh-prts] :is([data-chat-flow-kind],[data-message-role]) [data-changed-files] button:is(:hover,:focus-visible) {
547
+ background:color-mix(in srgb,var(--prts-ink) 5%,transparent);box-shadow:none;
548
+ }
549
+
550
+ html[data-dsh-prts] [data-prts-corner-target]:focus-visible{outline:none!important}
551
+ html[data-dsh-prts] [data-prts-corner-target]:active{transform:none!important}
@@ -940,10 +940,10 @@ html[data-dsh-prts] [data-prts-transfer] {
940
940
  & input[type="file"] { max-width: 100%; min-width: 0; }
941
941
  & input[type="checkbox"] { accent-color: var(--prts-yellow); }
942
942
  & button { justify-self: start; padding: 8px 14px; border: 1px solid var(--prts-line-strong); border-radius: 0; background: var(--prts-panel-raised); color: var(--prts-ink); font: inherit; cursor: pointer; }
943
- & button:hover:not(:disabled) { border-color: var(--prts-yellow); }
943
+ & button:hover:not(:disabled) { border-color: var(--prts-line-strong); }
944
944
  & button:focus-visible { outline: 2px solid var(--prts-yellow); outline-offset: 3px; }
945
945
  & button:disabled { opacity: .55; cursor: wait; }
946
- & [data-prts-transfer-export], & [data-prts-transfer-apply] { border-color: var(--prts-yellow); }
946
+ & [data-prts-transfer-apply] { border-color: var(--prts-yellow); }
947
947
  & hr { width: 100%; border: 0; border-top: 1px solid var(--prts-line); }
948
948
  & [data-prts-transfer-preview], & [data-prts-transfer-recovery] { display: grid; gap: 12px; padding: 16px; border: 1px solid var(--prts-line); }
949
949
  & [hidden] { display: none !important; }
@@ -951,7 +951,7 @@ html[data-dsh-prts] [data-prts-transfer] {
951
951
 
952
952
  html[data-dsh-prts] [data-prts-theme-settings] [data-prts-settings-actions] { display:flex; flex-direction:column; align-items:stretch; gap:8px; }
953
953
  html[data-dsh-prts] [data-prts-transfer-open] { display:flex; align-items:center; gap:8px; width:100%; padding:10px 8px; background:none; border:1px solid var(--prts-line); border-radius:4px; color:var(--prts-ink); font:500 12px/1.4 var(--prts-body); cursor:pointer; }
954
- html[data-dsh-prts] [data-prts-transfer-open]:hover { border-color:var(--prts-yellow); }
954
+ html[data-dsh-prts] [data-prts-transfer-open]:hover { border-color:var(--prts-line); }
955
955
  html[data-dsh-prts] [data-prts-transfer-dialog] { box-sizing:border-box; width:min(660px,calc(100vw - 32px)); max-height:calc(100dvh - 48px); padding:0; border:1px solid var(--prts-line-strong); border-radius:8px; background:var(--prts-canvas); color:var(--prts-ink); box-shadow:0 24px 80px #0006; font:13px/1.6 var(--prts-body); overflow:auto; }
956
956
  html[data-dsh-prts] [data-prts-transfer-dialog]::backdrop { background:#0007; }
957
957
  html[data-dsh-prts] [data-prts-transfer] {
@@ -960,10 +960,10 @@ html[data-dsh-prts] [data-prts-transfer] {
960
960
  & h2 { margin:4px 0 0; font-size:20px; line-height:1.4; }
961
961
  & h3 { margin:0; font-size:14px; }
962
962
  & [data-prts-transfer-close] { padding:2px 10px; font-size:24px; border:0; background:none; }
963
- & [data-prts-transfer-columns] { display:grid; grid-template-columns:1fr 1fr; gap:24px; }
964
- & [data-prts-transfer-card] { display:flex; flex-direction:column; gap:12px; min-width:0; }
965
- & [data-prts-transfer-card] + section { padding-left:24px; border-left:1px solid var(--prts-line); }
966
- & [data-prts-transfer-card] > button { margin-top:auto; display:flex; align-items:center; justify-content:space-between; }
963
+ & [data-prts-transfer-columns] { display:grid; grid-template-columns:1fr 1fr; gap:16px; }
964
+ & [data-prts-transfer-card] { display:flex; flex-direction:column; gap:12px; min-width:0; padding:18px; border:1px solid var(--prts-line); border-radius:6px; background:var(--prts-panel-raised); transition:border-color 180ms ease,background-color 180ms ease,box-shadow 180ms ease; }
965
+ & [data-prts-transfer-card] > button { margin-top:auto; display:flex; align-items:center; justify-content:space-between; gap:12px; align-self:stretch; border-radius:4px; }
966
+ & [data-prts-transfer-card] > button:hover:not(:disabled) { border-color:var(--prts-line-strong); }
967
967
  & [data-prts-transfer-option] { align-items:flex-start; flex-wrap:nowrap; padding:12px 0 0; }
968
968
  & [data-prts-transfer-option] input { margin:5px 0 0; }
969
969
  & [data-prts-transfer-option] small { display:block; margin-top:4px; }
@@ -986,7 +986,6 @@ html[data-dsh-prts] [data-prts-transfer] {
986
986
  @media(max-width:540px) {
987
987
  html[data-dsh-prts] [data-prts-transfer] { padding:20px; }
988
988
  html[data-dsh-prts] [data-prts-transfer] [data-prts-transfer-columns] { grid-template-columns:1fr; gap:20px; }
989
- html[data-dsh-prts] [data-prts-transfer] [data-prts-transfer-card] + section { padding:20px 0 0; border-left:0; border-top:1px solid var(--prts-line); }
990
989
  }
991
990
 
992
991
  /* Shared quiet actions below the settings categories. */
@@ -1015,3 +1014,21 @@ html[data-dsh-prts] [data-prts-theme-settings] [data-prts-footer-action][hidden]
1015
1014
  html[data-dsh-prts] [data-prts-theme-settings] [data-prts-settings-actions] { display:grid; grid-template-columns:1fr 1fr; gap:8px; padding:8px 12px; }
1016
1015
  html[data-dsh-prts] [data-prts-theme-settings] [data-prts-reset-zone] { width:100%; }
1017
1016
  }
1017
+
1018
+ [data-prts-transfer-progress] { display: grid; gap: 8px; }
1019
+ [data-prts-transfer-progress] > div { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }
1020
+ [data-prts-transfer-bar] { width: 100%; height: 8px; accent-color: var(--prts-accent, #d8b85e); }
1021
+
1022
+ [data-prts-drop-active] { outline: 2px dashed var(--prts-accent, #d8b85e); outline-offset: -3px; }
1023
+
1024
+ html[data-dsh-prts] [data-prts-transfer] [data-prts-transfer-card][data-prts-transfer-active],
1025
+ html[data-dsh-prts] [data-prts-transfer]:not([aria-busy="true"]) [data-prts-transfer-card]:has(:focus-visible) {
1026
+ border-color:var(--prts-line-strong); background:color-mix(in srgb,var(--prts-ink) 3%,var(--prts-panel-raised)); box-shadow:inset 0 1px color-mix(in srgb,var(--prts-ink) 7%,transparent),0 8px 24px #0002;
1027
+ }
1028
+ @media (hover:hover) {
1029
+ html[data-dsh-prts] [data-prts-transfer]:not([aria-busy="true"]) [data-prts-transfer-card]:hover {
1030
+ border-color:var(--prts-line-strong); background:color-mix(in srgb,var(--prts-ink) 3%,var(--prts-panel-raised)); box-shadow:inset 0 1px color-mix(in srgb,var(--prts-ink) 7%,transparent),0 8px 24px #0002;
1031
+ }
1032
+ }
1033
+ html[data-prts-motion="reduced"] [data-prts-transfer-card] { transition:none; }
1034
+ @media (prefers-reduced-motion:reduce) { [data-prts-transfer-card] { transition:none; } }