@ash-qw/dsh-theme-prts 2.3.0 → 2.4.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,3 +1,4 @@
1
+ import { createSessionCardReveal } from './session-card-reveal.js'
1
2
  import { createSessionSummaryPreview, scrollSummaryTitle } from './session-summary-preview.js'
2
3
  import { ensureFacilityGraphic, ensureFacilityTextureDefs, renderFacilityGraphic } from './facility-vector.js'
3
4
  import { createSessionLifelineAnimator } from './session-lifeline.js'
@@ -353,7 +354,11 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
353
354
  queueScan()
354
355
  }
355
356
  function interruptSelectionTarget(event) {
356
- if (event.target.closest?.('[data-prts-session-row]')) clearSelectionTarget()
357
+ if (event.target.closest?.('[data-prts-session-row]')) {
358
+ cancelPendingNavigation()
359
+ cancelReveal()
360
+ clearSelectionTarget()
361
+ }
357
362
  }
358
363
  function setSelectionTarget(id) {
359
364
  clearSelectionTarget()
@@ -362,27 +367,41 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
362
367
  document.addEventListener('pointerdown', interruptSelectionTarget, true)
363
368
  }
364
369
 
365
- let pendingReveal, revealTimer, revealFrame, revealSettleTimer
370
+ let pendingReveal, revealTimer
366
371
  let navigationFrame, navigationGeneration = 0
367
372
  function cancelPendingNavigation() {
368
373
  navigationGeneration++
369
374
  window.cancelAnimationFrame?.(navigationFrame)
370
375
  }
371
376
  function cancelReveal() {
377
+ pendingReveal?.tracker?.cancel()
372
378
  pendingReveal = undefined
373
379
  window.clearTimeout(revealTimer)
374
- window.clearTimeout(revealSettleTimer)
375
- window.cancelAnimationFrame?.(revealFrame)
380
+ for (const type of ['wheel','touchmove','pointerdown','keydown']) document.removeEventListener(type, interruptReveal, true)
376
381
  }
377
- function revealSessionCard(sessionId) {
382
+ function interruptReveal(event) {
383
+ if (!event.target?.closest?.('[data-prts-region="sessions"]')) return
384
+ if (event.type === 'keydown' && !['ArrowUp','ArrowDown','PageUp','PageDown','Home','End',' '].includes(event.key)) return
385
+ cancelPendingNavigation()
378
386
  cancelReveal()
379
- pendingReveal = {sessionId, attempts: 0}
380
- revealTimer = window.setTimeout(cancelReveal, 4000)
387
+ clearSelectionTarget()
388
+ }
389
+ function revealSessionCard(sessionId, onDone) {
390
+ pendingReveal?.tracker?.interrupt()
391
+ cancelReveal()
392
+ pendingReveal = {sessionId, onDone}
393
+ revealTimer = window.setTimeout(finishReveal, 4000)
394
+ for (const type of ['wheel','touchmove','pointerdown','keydown']) document.addEventListener(type, interruptReveal, {capture:true, passive:true})
381
395
  const root = document?.querySelector?.('[data-prts-region="sessions"] [data-slot="sidebar.workspaces"]')
382
396
  syncSessionReveal(root)
383
397
  queueScan()
384
398
  return Boolean(pendingReveal?.expanding)
385
399
  }
400
+ function finishReveal() {
401
+ const onDone = pendingReveal?.onDone
402
+ cancelReveal()
403
+ onDone?.()
404
+ }
386
405
  function syncSessionReveal(root) {
387
406
  if (!pendingReveal || !root) return
388
407
  const {sessionId} = pendingReveal
@@ -390,30 +409,14 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
390
409
  const row = rows.find(el => el.getAttribute('data-row-key') === `session:${sessionId}`
391
410
  || el.getAttribute('data-session-id') === sessionId)
392
411
  if (row) {
393
- if (pendingReveal.scrolling) return
394
- const request = pendingReveal
395
- request.scrolling = true
396
- // Let React finish mounting and let the host's row glide settle before scrolling.
397
- revealFrame = window.requestAnimationFrame(async () => {
398
- const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
399
- const animations = []
400
- if (!reduced) for (let node = row; node && node !== root; node = node.parentElement) {
401
- for (const animation of node.getAnimations?.() ?? []) {
402
- const timing = animation.effect?.getComputedTiming?.()
403
- if (animation.playState === 'running' && Number.isFinite(timing?.endTime)) animations.push(animation.finished)
404
- }
405
- }
406
- if (animations.length) await Promise.race([
407
- Promise.allSettled(animations),
408
- new Promise(resolve => { revealSettleTimer = window.setTimeout(resolve, 300) }),
409
- ])
410
- if (pendingReveal !== request || !row.isConnected) return
411
- row.scrollIntoView?.({block:'nearest', inline:'nearest', behavior:reduced ? 'instant' : 'smooth'})
412
- cancelReveal()
413
- })
412
+ if (!pendingReveal.tracker) {
413
+ const request = pendingReveal
414
+ request.tracker = createSessionCardReveal({window, root, sessionId, onStart: () => window.clearTimeout(revealTimer), onDone: () => {
415
+ if (pendingReveal === request) finishReveal()
416
+ }})
417
+ }
414
418
  return
415
419
  }
416
- if (++pendingReveal.attempts > 40) { cancelReveal(); return }
417
420
  const items = snapshotOf(workspaces?.list)?.items ?? []
418
421
  const owner = items.find(item => item.sessionIds?.includes(sessionId))
419
422
  const key = owner?.workspaceId ?? ''
@@ -439,7 +442,6 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
439
442
  closeNavigationPreview()
440
443
  cancelPendingNavigation()
441
444
  setSelectionTarget(sessionId)
442
- revealSessionCard(sessionId)
443
445
  const generation = navigationGeneration
444
446
  const navigate = () => {
445
447
  if (generation !== navigationGeneration || !adapterActive) return
@@ -462,11 +464,14 @@ export function createSidebarControlAdapter({ document, window, sessions, worksp
462
464
  if (row) row.click?.()
463
465
  else clearSelectionTarget()
464
466
  }
465
- // Paint selection and expansion before the host synchronously mounts another conversation.
466
- // Already-expanded workspaces need this too: queueScan has not applied selection yet.
467
- navigationFrame = window.requestAnimationFrame(() => {
468
- if (generation !== navigationGeneration) return
469
- navigationFrame = window.requestAnimationFrame(navigate)
467
+ // Finish sidebar motion first, then paint its final position before the host can block.
468
+ revealSessionCard(sessionId, () => {
469
+ if (generation !== navigationGeneration || !adapterActive) return
470
+ setSelectionTarget(sessionId)
471
+ navigationFrame = window.requestAnimationFrame(() => {
472
+ if (generation !== navigationGeneration) return
473
+ navigationFrame = window.requestAnimationFrame(navigate)
474
+ })
470
475
  })
471
476
  }
472
477
 
@@ -771,8 +771,10 @@ export function createThemeController({ document, window, cssText, service, curs
771
771
  root.dataset.prtsBackgroundMode = preferences.backgroundMode
772
772
  root.style.setProperty('--prts-background-shade', `${preferences.backgroundDim * 100}%`)
773
773
  for (const name of ['sidebar', 'rail', 'baker']) root.style.setProperty(`--prts-${name}-opacity`, `${preferences[`${name}Opacity`] * 100}%`)
774
+ root.style.setProperty('--prts-right-sidebar-opacity', `${preferences.rightSidebarOpacity * 100}%`)
774
775
  root.style.setProperty('--prts-classic-user-opacity', `${preferences.classicUserOpacity * 100}%`)
775
776
  for (const name of ['sidebar', 'rail', 'baker']) root.style.setProperty(`--prts-${name}-blur`, `${preferences[`${name}Blur`]}px`)
777
+ root.style.setProperty('--prts-right-sidebar-blur', `${preferences.rightSidebarBlur}px`)
776
778
  root.style.setProperty('--prts-classic-user-blur', `${preferences.classicUserBlur}px`)
777
779
  refresh()
778
780
  },
@@ -1,4 +1,4 @@
1
- import { THEME_PACKAGE_LIMIT, validateThemePackage, describeThemePackage } from './theme-transfer.js'
1
+ import { THEME_PACKAGE_LIMIT, validateThemePackage, describeThemePackage, ignoredThemePackageFields } from './theme-transfer.js'
2
2
 
3
3
  export function themeTransferMarkup() {
4
4
  return `<dialog data-prts-transfer-dialog aria-labelledby="prts-transfer-title">
@@ -20,6 +20,7 @@ export function themeTransferMarkup() {
20
20
  </div>
21
21
  <div data-prts-transfer-preview hidden>
22
22
  <strong>确认导入</strong><p data-prts-transfer-summary></p>
23
+ <p data-prts-transfer-compatibility hidden role="alert"></p>
23
24
  <small>应用前会下载当前配置备份,请保留备份文件。</small>
24
25
  <div data-prts-transfer-buttons><button type="button" data-prts-transfer-apply>确认导入</button><button type="button" data-prts-transfer-cancel>取消</button></div>
25
26
  </div>
@@ -43,6 +44,7 @@ export function mountThemeTransfer({ panel, window, transfer }) {
43
44
  const render = () => {
44
45
  if (disposed) return
45
46
  get('preview').hidden = !pending
47
+ if (!pending) get('compatibility').hidden = true
46
48
  get('recovery').hidden = !transfer.hasBackup
47
49
  for (const control of section.querySelectorAll('button,input')) control.disabled = busy
48
50
  // Prevent local editors from racing a multi-service import; other clients use revision checks.
@@ -72,6 +74,12 @@ export function mountThemeTransfer({ panel, window, transfer }) {
72
74
  if (disposed || selected !== generation) return ''
73
75
  pending = value
74
76
  get('summary').textContent = describeThemePackage(value)
77
+ const ignored = ignoredThemePackageFields(value)
78
+ const compatibility = get('compatibility')
79
+ compatibility.hidden = ignored.length === 0
80
+ compatibility.textContent = ignored.length
81
+ ? `当前版本不支持以下配置,导入后将丢失:${ignored.join('、')}。请保留原文件。`
82
+ : ''
75
83
  return '已读取文件,请确认导入内容。'
76
84
  })
77
85
  }
@@ -1,3 +1,4 @@
1
+ import { validBackgroundRegion } from '../shared/background-region.js'
1
2
  import { DEFAULT_PREFERENCES, normalizePreferences } from './preferences.js'
2
3
 
3
4
  export const THEME_PACKAGE_LIMIT = 160 * 1024 * 1024
@@ -6,6 +7,33 @@ const transferRoles = ['user', 'assistant']
6
7
  const transferBuiltins = ['rhodes-island', 'lungmen', 'penguin-logistics', 'rhine-lab', 'reunion']
7
8
  const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
8
9
  const encodedImage = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= Math.ceil(limit / 3) * 4 && value.length % 4 === 0 && /^[A-Za-z0-9+/]+={0,2}$/.test(value)
10
+ const unknownFields = (value, known, prefix) => record(value)
11
+ ? Object.keys(value).filter(key => !known.includes(key)).map(key => `${prefix}.${key}`)
12
+ : []
13
+
14
+ export function ignoredThemePackageFields(value) {
15
+ const ignored = unknownFields(value.preferences, Object.keys(DEFAULT_PREFERENCES), '设置')
16
+ const assets = value.assets
17
+ if (!assets) return ignored
18
+ ignored.push(...unknownFields(assets, ['backgrounds', 'backgroundRegions', 'avatars', 'emblems'], '素材'))
19
+ ignored.push(...unknownFields(assets.backgrounds, transferSlots, '背景'))
20
+ ignored.push(...unknownFields(assets.backgroundRegions, transferSlots, '背景保护区域'))
21
+ for (const slot of transferSlots) ignored.push(...unknownFields(assets.backgroundRegions?.[slot], ['x', 'y', 'width', 'height'], `背景保护区域.${slot}`))
22
+ ignored.push(...unknownFields(assets.avatars, transferRoles, '头像'))
23
+ for (const role of transferRoles) {
24
+ ignored.push(...unknownFields(assets.avatars?.[role], ['image', 'crop'], `头像.${role}`))
25
+ ignored.push(...unknownFields(assets.avatars?.[role]?.crop, ['zoom', 'x', 'y'], `头像.${role}.crop`))
26
+ }
27
+ const emblems = assets.emblems
28
+ ignored.push(...unknownFields(emblems, ['items', 'hero', 'conversation'], '徽记'))
29
+ for (const [index, item] of (emblems?.items ?? []).entries()) {
30
+ ignored.push(...unknownFields(item, ['format', 'version', 'label', 'params', 'image'], `徽记.items[${index}]`))
31
+ ignored.push(...unknownFields(item?.params, ['mode', 'lineSource', 'cleanup', 'threshold', 'zoom', 'x', 'y'], `徽记.items[${index}].params`))
32
+ }
33
+ ignored.push(...unknownFields(emblems?.hero, ['kind', 'key', 'label', 'asset'], '徽记.hero'))
34
+ for (const [index, entry] of (emblems?.conversation ?? []).entries()) ignored.push(...unknownFields(entry, ['kind', 'key', 'label', 'asset'], `徽记.conversation[${index}]`))
35
+ return ignored
36
+ }
9
37
 
10
38
  export function validateThemePackage(value) {
11
39
  if (!record(value) || value.format !== 'prts-theme' || value.version !== 1) throw Error('不支持的主题文件格式或版本')
@@ -22,6 +50,7 @@ export function validateThemePackage(value) {
22
50
  const a = value.assets
23
51
  if (!record(a) || !record(a.backgrounds) || !record(a.avatars) || !record(a.emblems)) throw Error('主题素材数据不完整')
24
52
  for (const slot of transferSlots) if (a.backgrounds[slot] !== null && !encodedImage(a.backgrounds[slot], 10 * 1024 * 1024)) throw Error('背景图片数据无效')
53
+ if (a.backgroundRegions !== undefined && (!record(a.backgroundRegions) || transferSlots.some(slot => !validBackgroundRegion(a.backgroundRegions[slot] ?? null)))) throw Error('背景保护区域无效')
25
54
  for (const role of transferRoles) {
26
55
  const avatar = a.avatars[role]
27
56
  if (avatar === null) continue
@@ -72,10 +101,11 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
72
101
  const value = { format: 'prts-theme', version: 1, themeVersion, exportedAt: new Date().toISOString(), preferences: { ...getPreferences() } }
73
102
  const states = {}
74
103
  if (includeAssets) {
75
- const backgrounds = {}, avatars = {}, items = [], ids = new Map()
104
+ const backgrounds = {}, backgroundRegions = {}, avatars = {}, items = [], ids = new Map()
76
105
  states.backgrounds = await request('backgrounds/configuration')
77
106
  for (const slot of transferSlots) {
78
107
  const entry = states.backgrounds[slot]
108
+ backgroundRegions[slot] = entry?.region ?? null
79
109
  backgrounds[slot] = null
80
110
  if (entry) {
81
111
  if (!/^[a-f0-9-]{36}$/.test(entry.id)) throw Error('背景引用无效')
@@ -102,7 +132,7 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
102
132
  }
103
133
  const hero = await reference(states.emblems.configuration.hero), conversation = []
104
134
  for (const entry of states.emblems.configuration.conversation) conversation.push(await reference(entry))
105
- value.assets = { backgrounds, avatars, emblems: { items, hero, conversation } }
135
+ value.assets = { backgrounds, backgroundRegions, avatars, emblems: { items, hero, conversation } }
106
136
  for (const kind of ['backgrounds', 'avatars', 'emblems']) {
107
137
  if ((await request(kind + '/configuration')).revision !== states[kind].revision) throw Error('素材在导出期间发生变化,请重试')
108
138
  }
@@ -146,7 +176,7 @@ export function createThemeTransfer({ window, getPreferences, savePreferences, c
146
176
  for (const item of a.emblems.items) drafts.push(await request('emblems/import', item))
147
177
  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 }
148
178
  const configuration = { version: 2, hero: entry(a.emblems.hero, 'hero'), conversation: a.emblems.conversation.map(entry) }
149
- for (const slot of transferSlots) await write('backgrounds', { slot, image: a.backgrounds[slot] }, { slot, image: old.backgrounds[slot] })
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 })
150
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 })
151
181
  await write('emblems', { configuration }, { configuration: before.states.emblems.configuration })
152
182
  }
@@ -2,6 +2,7 @@ import { mkdir, readFile, writeFile, rename, rm } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import { randomUUID } from 'node:crypto'
4
4
  import sharp from 'sharp'
5
+ import { validBackgroundRegion } from '../shared/background-region.js'
5
6
  import { body, json, isBase64 } from './api.js'
6
7
  import { fault } from './emblems.js'
7
8
 
@@ -26,8 +27,11 @@ export async function createBackgroundService(directory) {
26
27
  const task = queue.then(async () => {
27
28
  if (!BACKGROUND_SLOTS.includes(data?.slot)) throw fault(400, '背景区域无效')
28
29
  if (data.revision !== state.revision) throw fault(409, '背景已在其他页面更新,请重新操作')
29
- let entry = null, output
30
- if (data.image !== null) {
30
+ const regionOnly = Object.hasOwn(data, 'region') && !Object.hasOwn(data, 'image')
31
+ if (Object.hasOwn(data, 'region') && !validBackgroundRegion(data.region)) throw fault(400, '背景保护区域无效')
32
+ if (regionOnly && !state[data.slot]) throw fault(400, '请先上传背景图片')
33
+ let entry = regionOnly ? { ...state[data.slot], region: data.region } : null, output
34
+ if (!regionOnly && data.image !== null) {
31
35
  if (!isBase64(data.image) || data.image.length > 14 * 1024 * 1024) throw fault(400, '请上传不超过 10 MB 的图片')
32
36
  const bytes = Buffer.from(data.image, 'base64')
33
37
  if (!bytes.length || bytes.length > 10 * 1024 * 1024) throw fault(400, '请上传不超过 10 MB 的图片')
@@ -37,22 +41,22 @@ export async function createBackgroundService(directory) {
37
41
  if (!['png', 'jpeg', 'webp', 'avif'].includes(metadata.format)) throw Error('format')
38
42
  const result = await input.rotate().resize({ width: 3840, height: 2160, fit: 'inside', withoutEnlargement: true }).webp({ quality: 86 }).toBuffer({ resolveWithObject: true })
39
43
  output = result.data
40
- entry = { id: randomUUID(), width: result.info.width, height: result.info.height }
44
+ entry = { id: randomUUID(), width: result.info.width, height: result.info.height, region: data.region ?? null }
41
45
  } catch { throw fault(400, '无法读取图片,请使用 PNG、JPEG、WebP 或 AVIF(最多 4000 万像素)') }
42
46
  }
43
47
  const next = { ...state, revision: state.revision + 1, [data.slot]: entry }
44
48
  const temporary = configurationPath + '.' + randomUUID() + '.tmp'
45
49
  try {
46
- if (entry) await writeFile(join(directory, entry.id + '.webp'), output, { mode: 0o600 })
50
+ if (output) await writeFile(join(directory, entry.id + '.webp'), output, { mode: 0o600 })
47
51
  await writeFile(temporary, JSON.stringify(next), { mode: 0o600 })
48
52
  await rename(temporary, configurationPath)
49
53
  } catch (error) {
50
- if (entry) await rm(join(directory, entry.id + '.webp'), { force: true }).catch(() => {})
54
+ if (output) await rm(join(directory, entry.id + '.webp'), { force: true }).catch(() => {})
51
55
  throw error
52
56
  } finally { await rm(temporary, { force: true }).catch(() => {}) }
53
57
  const previous = state[data.slot]
54
58
  state = next
55
- if (previous) await rm(join(directory, previous.id + '.webp'), { force: true }).catch(() => {})
59
+ if (previous && previous.id !== entry?.id) await rm(join(directory, previous.id + '.webp'), { force: true }).catch(() => {})
56
60
  for (const listener of listeners) { try { listener(state.revision) } catch {} }
57
61
  return snapshot()
58
62
  })
@@ -0,0 +1,21 @@
1
+ // Coordinates are fractions of the oriented, stored image.
2
+ export function validBackgroundRegion(region) {
3
+ return region === null || (region && typeof region === 'object' && !Array.isArray(region)
4
+ && ['x','y','width','height'].every(key => Number.isFinite(region[key]))
5
+ && region.x >= 0 && region.y >= 0 && region.width > 0 && region.height > 0
6
+ && region.x + region.width <= 1.000001 && region.y + region.height <= 1.000001)
7
+ }
8
+ export function backgroundPlacement(imageWidth, imageHeight, width, height, region = null) {
9
+ if (![imageWidth,imageHeight,width,height].every(v => Number.isFinite(v) && v > 0)) return {x:0,y:0,width:0,height:0,clipped:false}
10
+ const scale = Math.max(width / imageWidth, height / imageHeight)
11
+ const w = imageWidth * scale, h = imageHeight * scale
12
+ const clamp = (v,a,b) => Math.max(a,Math.min(b,v))
13
+ const axis = (size, viewport, start, length, vertical = false) => {
14
+ const centered = (viewport-size)/2
15
+ if (!region) return centered
16
+ if (length*size > viewport) return clamp(vertical ? -start*size : viewport/2-(start+length/2)*size,viewport-size,0)
17
+ return clamp(centered, Math.max(viewport-size,-start*size), Math.min(0,viewport-(start+length)*size))
18
+ }
19
+ return {x:axis(w,width,region?.x,region?.width),y:axis(h,height,region?.y,region?.height,true),width:w,height:h,
20
+ clipped:Boolean(region && (region.width*w > width+.01 || region.height*h > height+.01))}
21
+ }
@@ -33,3 +33,11 @@
33
33
  html[data-dsh-prts] :is([data-prts-emblem-sort-active],[data-prts-emblem-sort-active] *) {
34
34
  cursor: var(--prts-sort-cursor, grabbing) !important;
35
35
  }
36
+
37
+ /* The crop surface uses one native cursor family, including during pointer capture. */
38
+ html[data-dsh-prts] [data-prts-region-stage] { cursor:default; }
39
+ html[data-dsh-prts] [data-prts-region-stage] [data-prts-region-box] { cursor:move; }
40
+ html[data-dsh-prts] [data-prts-region-stage] [data-prts-region-handle] { cursor:ns-resize!important; }
41
+ html[data-dsh-prts] [data-prts-region-stage] :is([data-prts-region-handle="e"],[data-prts-region-handle="w"]) { cursor:ew-resize!important; }
42
+ html[data-dsh-prts] [data-prts-region-stage] :is([data-prts-region-handle="nw"],[data-prts-region-handle="se"]) { cursor:nwse-resize!important; }
43
+ html[data-dsh-prts] [data-prts-region-stage] :is([data-prts-region-handle="ne"],[data-prts-region-handle="sw"]) { cursor:nesw-resize!important; }
@@ -1,18 +1,37 @@
1
1
  /* Viewport-aligned wallpaper. */
2
2
  html[data-dsh-prts] { --prts-background-wash: color-mix(in srgb, var(--prts-canvas) var(--prts-background-shade, 45%), transparent); }
3
3
  html[data-dsh-prts][data-prts-background-mode="full"][data-prts-background-full] :is([data-prts-region="frame"], [data-prts-nav-rail]) {
4
- background: linear-gradient(var(--prts-background-wash), var(--prts-background-wash)), var(--prts-background-full) center / cover fixed;
4
+ background: linear-gradient(var(--prts-background-wash), var(--prts-background-wash)), var(--prts-background-full) var(--prts-protected-full-x, 50%) var(--prts-protected-full-y, 50%) / cover fixed;
5
5
  }
6
6
  html[data-dsh-prts][data-prts-background-mode="full"][data-prts-background-full] :is([data-prts-region="sessions"], [data-prts-region="operation"]) { background: transparent; }
7
7
  html[data-dsh-prts][data-prts-background-mode="split"][data-prts-background-sidebar] {
8
- --prts-wall-width:max(calc(var(--prts-sidebar-width,280px) + var(--prts-nav-width)),calc((100dvh - var(--prts-host-top-inset)) * var(--prts-sidebar-image-ratio,1)));
9
- --prts-wall-x:calc((var(--prts-sidebar-width,280px) + var(--prts-nav-width) - var(--prts-wall-width)) / 2);
10
- & :is([data-prts-region="sessions"],[data-prts-nav-rail]){background:linear-gradient(var(--prts-background-wash),var(--prts-background-wash)),var(--prts-background-sidebar) var(--prts-wall-x) center / var(--prts-wall-width) auto no-repeat}
11
- & [data-prts-region="sessions"]{background-position:center,calc(var(--prts-wall-x) - var(--prts-nav-width)) center}
8
+ --prts-wall-width:var(--prts-protected-sidebar-width,max(calc(var(--prts-sidebar-width,280px) + var(--prts-nav-width)),calc((100dvh - var(--prts-host-top-inset)) * var(--prts-sidebar-image-ratio,1))));
9
+ --prts-wall-x:var(--prts-protected-sidebar-x,calc((var(--prts-sidebar-width,280px) + var(--prts-nav-width) - var(--prts-wall-width)) / 2));
10
+ & :is([data-prts-region="sessions"],[data-prts-nav-rail]){background:linear-gradient(var(--prts-background-wash),var(--prts-background-wash)),var(--prts-background-sidebar) var(--prts-wall-x) var(--prts-protected-sidebar-y, 50%) / var(--prts-wall-width) auto no-repeat}
11
+ & [data-prts-region="sessions"]{background-position:center,calc(var(--prts-wall-x) - var(--prts-nav-width)) var(--prts-protected-sidebar-y, 50%)}
12
12
  }
13
13
  html[data-dsh-prts][data-prts-rail-mode="overlay"]:is([data-prts-background-mode="split"][data-prts-background-sidebar],[data-prts-background-mode="full"][data-prts-background-full]) [data-prts-nav-rail] { background: transparent; }
14
- html[data-dsh-prts][data-prts-background-mode="split"][data-prts-background-conversation] [data-prts-region="operation"] {
15
- background: linear-gradient(var(--prts-background-wash), var(--prts-background-wash)), var(--prts-background-conversation) center / cover no-repeat;
14
+ html[data-dsh-prts][data-prts-background-mode="split"][data-prts-background-conversation] {
15
+ & [data-prts-region="frame"] { background: linear-gradient(var(--prts-background-wash), var(--prts-background-wash)), var(--prts-background-conversation) var(--prts-protected-conversation-x, 50%) var(--prts-protected-conversation-y, 50%) / cover no-repeat; }
16
+ & [data-prts-region="operation"] { background: transparent; }
17
+ &:not([data-prts-background-sidebar]) [data-prts-region="sessions"] { background: transparent; }
18
+ }
19
+ /* The native right dock paints an opaque tab host over the frame wallpaper. */
20
+ 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="auxiliary"] { background: transparent; }
21
+ 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="auxiliary"] :is([data-dockkit-host="dock"] > :first-child,[data-dockkit-empty]) {
22
+ position: relative;
23
+ isolation: isolate;
24
+ background: transparent;
25
+ }
26
+ 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="auxiliary"] :is([data-dockkit-host="dock"] > :first-child,[data-dockkit-empty])::before {
27
+ content: "";
28
+ position: absolute;
29
+ inset: 0;
30
+ z-index: -1;
31
+ pointer-events: none;
32
+ background: color-mix(in srgb, var(--prts-panel) var(--prts-right-sidebar-opacity, 6%), transparent);
33
+ -webkit-backdrop-filter: blur(var(--prts-right-sidebar-blur, 8px)) saturate(1.15);
34
+ backdrop-filter: blur(var(--prts-right-sidebar-blur, 8px)) saturate(1.15);
16
35
  }
17
36
  html[data-dsh-prts][data-prts-background-mode="full"][data-prts-background-full] [data-prts-ambient-layer],
18
37
  html[data-dsh-prts][data-prts-background-mode="split"][data-prts-background-conversation] [data-prts-ambient-layer] { display: none !important; }
@@ -34,13 +53,23 @@ html[data-dsh-prts][data-prts-background-mode="split"][data-prts-background-conv
34
53
  [data-prts-background-images] { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
35
54
  [data-prts-background-card="full"] { grid-column: 1 / -1; }
36
55
  [data-prts-background-card-heading] { display: flex; justify-content: space-between; align-items: center; min-height: 30px; margin-bottom: 6px; font-size: 13px; }
37
- [data-prts-background-upload] { position: relative; display: grid; place-items: center; width: 100%; height: 120px; padding: 0; border: 1px dashed var(--prts-line); border-radius: 8px; color: var(--prts-muted); background: var(--prts-panel); overflow: hidden; cursor: pointer; }
38
- [data-prts-background-card="full"] [data-prts-background-upload] { height: 155px; }
39
- [data-prts-background-upload][data-has-image] { border-style: solid; }
40
- [data-prts-background-upload]:hover { border-color: var(--prts-cyan); color: var(--prts-ink); }
41
- [data-prts-background-thumbnail] { position: absolute; inset: 0; background: center / cover no-repeat; }
42
- [data-prts-background-caption] { position: relative; padding: 6px 12px; border-radius: 5px; font-size: 12px; }
43
- [data-has-image] [data-prts-background-caption] { align-self: end; justify-self: end; margin: 8px; background: var(--prts-panel); color: var(--prts-ink); box-shadow: 0 1px 5px #0003; }
56
+ [data-prts-background-image-controls] { position:relative;display:grid;place-items:center;min-height:140px;border:1px dashed var(--prts-line);border-radius:8px;background:var(--prts-panel);overflow:hidden; }
57
+ [data-prts-background-card="full"] [data-prts-background-image-controls] { min-height:180px; }
58
+ [data-prts-background-image-controls][data-has-image] { border-style:solid; }
59
+ [data-prts-background-thumbnail] { position:absolute;inset:0;background:center / cover no-repeat;pointer-events:none; }
60
+ [data-prts-background-empty] {font-size:12px;color:var(--prts-muted);padding-bottom:40px}
61
+ [data-has-image] [data-prts-background-empty] {visibility:hidden}
62
+ /* One compact toolbar stays legible over either bright or dark photography. */
63
+ [data-prts-background-image-actions] {position:absolute;bottom:10px;right:10px;display:flex;align-items:center;gap:2px;padding:2px;border:1px solid #ffffff26;border-radius:7px;background:rgb(20 26 30 / 82%);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);box-shadow:0 2px 8px #0002;color:#edf2f3}
64
+ [data-prts-background-image-actions] button {display:inline-flex;align-items:center;justify-content:center;gap:5px;min-height:26px;padding:4px 7px;border:0;border-radius:5px;background:transparent;color:inherit;box-shadow:none;font:500 11px/1.2 var(--prts-body);white-space:nowrap;cursor:pointer;transition:background-color 120ms ease,color 120ms ease}
65
+ [data-prts-background-image-actions] button + button {position:relative}
66
+ [data-prts-background-image-actions] button + button::before {content:"";position:absolute;left:-1px;top:7px;bottom:7px;width:1px;background:#ffffff24}
67
+ [data-prts-background-image-actions] button[hidden] + button::before {display:none}
68
+ [data-prts-background-image-actions] svg {width:13px;height:13px;flex:none;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round;opacity:.8}
69
+ [data-prts-background-image-actions] button:hover {background:#ffffff18;color:#fff}
70
+ [data-prts-background-image-actions] button:active {background:#ffffff28}
71
+ [data-prts-background-image-actions] button:disabled {opacity:.45;cursor:wait}
72
+ [data-prts-background-settings] [data-prts-background-image-actions] button:focus-visible {outline:2px solid #b9e6ee;outline-offset:-2px}
44
73
  [data-prts-background-remove] { padding: 4px 8px; background: transparent; color: var(--prts-muted); border: 0; border-radius: 4px; font-size: 12px; cursor: pointer; }
45
74
  [data-prts-background-remove]:hover { color: var(--prts-red); background: color-mix(in srgb, var(--prts-red) 8%, transparent); }
46
75
  [data-prts-background-shade] { display: grid; gap: 8px; font-size: 13px; padding-top: 14px; border-top: 1px solid var(--prts-line); }
@@ -64,8 +93,7 @@ html[data-dsh-prts][data-prts-background-mode="full"][data-prts-background-full]
64
93
  html[data-dsh-prts][data-prts-background-mode="split"][data-prts-background-sidebar] [data-prts-region="sessions"] [data-slot="sidebar.workspaces"] span[class$="_fade"] { display: none !important; }
65
94
 
66
95
  /* Filter the plate, not the ancestor of fixed controls. */
67
- html[data-dsh-prts][data-prts-background-mode="full"][data-prts-background-full] [data-prts-region="sessions"]::before,
68
- html[data-dsh-prts][data-prts-background-mode="split"][data-prts-background-sidebar] [data-prts-region="sessions"]::before {
96
+ html[data-dsh-prts]:is([data-prts-background-mode="full"][data-prts-background-full],[data-prts-background-mode="split"]:is([data-prts-background-sidebar],[data-prts-background-conversation])) [data-prts-region="sessions"]::before {
69
97
  content: "";
70
98
  position: absolute;
71
99
  inset: 0;
@@ -77,8 +105,7 @@ backdrop-filter: blur(var(--prts-sidebar-blur, 8px)) saturate(1.15);
77
105
  box-shadow: inset -1px 0 color-mix(in srgb, var(--prts-ink) 10%, transparent), inset 0 1px rgb(255 255 255 / 8%);
78
106
  }
79
107
  /* Raise sidebar controls above glass. */
80
- html[data-dsh-prts][data-prts-background-mode="full"][data-prts-background-full] [data-prts-region="sessions"] [data-slot="sidebar"] > :first-child,
81
- html[data-dsh-prts][data-prts-background-mode="split"][data-prts-background-sidebar] [data-prts-region="sessions"] [data-slot="sidebar"] > :first-child {
108
+ html[data-dsh-prts]:is([data-prts-background-mode="full"][data-prts-background-full],[data-prts-background-mode="split"]:is([data-prts-background-sidebar],[data-prts-background-conversation])) [data-prts-region="sessions"] [data-slot="sidebar"] > :first-child {
82
109
  position: relative;
83
110
  z-index: 1;
84
111
  }
@@ -263,13 +290,16 @@ border-radius: 6px;
263
290
  background: var(--prts-background-full, linear-gradient(135deg, #8bb6c2, #c7b69c)) center / cover;
264
291
  --prts-preview-surface: var(--prts-panel);
265
292
  }
266
- html[data-prts-background-mode="split"] :is([data-prts-material-preview="sidebarOpacity"],[data-prts-material-preview="railOpacity"]) { background-image: var(--prts-background-sidebar, linear-gradient(135deg, #8bb6c2, #c7b69c)); }
293
+ html[data-prts-background-mode="split"] [data-prts-material-preview="sidebarOpacity"] { background-image: var(--prts-background-sidebar, var(--prts-background-conversation, linear-gradient(135deg, #8bb6c2, #c7b69c))); }
294
+ html[data-prts-background-mode="split"] [data-prts-material-preview="rightSidebarOpacity"] { background-image: var(--prts-background-conversation, linear-gradient(135deg, #8bb6c2, #c7b69c)); }
295
+ html[data-prts-background-mode="split"] [data-prts-material-preview="railOpacity"] { background-image: var(--prts-background-sidebar, linear-gradient(135deg, #8bb6c2, #c7b69c)); }
267
296
  html[data-prts-background-mode="split"] [data-prts-material-preview="bakerOpacity"] { background-image: var(--prts-background-conversation, linear-gradient(135deg, #8bb6c2, #c7b69c)); }
268
297
  html[data-prts-background-mode="split"] [data-prts-material-preview="classicUserOpacity"] { background-image: var(--prts-background-conversation, linear-gradient(135deg, #8bb6c2, #c7b69c)); }
269
298
  [data-prts-material-preview]::before { content: ""; position: absolute; inset: 0; background: var(--prts-background-wash); }
270
299
  [data-prts-material-preview-surface] { position: absolute; inset: 0; display: grid; align-content: center; gap: 6px; padding: 10px; font-size: 10px; text-align: left; color: var(--prts-ink); background: color-mix(in srgb, var(--prts-preview-surface) var(--prts-sidebar-opacity, 6%), transparent); backdrop-filter: blur(var(--prts-sidebar-blur, 8px)) saturate(1.15); }
271
300
  [data-prts-material-preview-surface] i { display: block; height: 8px; border-radius: 2px; background: color-mix(in srgb, var(--prts-ink) 18%, transparent); }
272
301
  [data-prts-material-preview-surface] i:last-child { width: 65%; }
302
+ [data-prts-material-preview="rightSidebarOpacity"] [data-prts-material-preview-surface] { background: color-mix(in srgb, var(--prts-preview-surface) var(--prts-right-sidebar-opacity, 6%), transparent); backdrop-filter: blur(var(--prts-right-sidebar-blur, 8px)) saturate(1.15); }
273
303
  [data-prts-material-preview="railOpacity"] [data-prts-material-preview-surface] { inset: 0 auto 0 0; width: 40%; padding: 5px; justify-items: center; gap: 2px; background: color-mix(in srgb, var(--prts-preview-surface) var(--prts-rail-opacity, 30%), transparent); backdrop-filter: blur(var(--prts-rail-blur, 12px)) saturate(1.15); }
274
304
  [data-prts-material-preview="bakerOpacity"] [data-prts-material-preview-surface] { inset: 16px 10px; border-radius: 0 10px 10px 10px; background: rgb(69 70 68 / var(--prts-baker-opacity, 50%)); backdrop-filter: blur(var(--prts-baker-blur, 8px)) saturate(1.08); }
275
305
  html[data-prts-scheme="light"] [data-prts-material-preview="bakerOpacity"] [data-prts-material-preview-surface] { background: rgb(223 227 223 / var(--prts-baker-opacity, 50%)); }
@@ -343,3 +373,49 @@ html[data-dsh-prts] [data-prts-background-hero-setting][hidden] { display:none;
343
373
 
344
374
  /* Windows chrome must not clip a different canvas color into the conversation. */
345
375
  html[data-dsh-prts][data-windows-titlebar] [data-prts-region="operation"] { border-radius: 0; }
376
+
377
+ [data-prts-region-editor] { width:min(1000px,calc(100vw - 32px));max-width:none;max-height:calc(100dvh - 32px);box-sizing:border-box;margin:auto;padding:24px;border:1px solid var(--prts-line);border-radius:14px;background:var(--prts-panel);color:var(--prts-ink);overflow:auto; }
378
+ [data-prts-region-editor]:not([open]) {display:none}
379
+ [data-prts-region-editor]::backdrop {background:#0009;backdrop-filter:blur(5px)}
380
+ [data-prts-region-editor] > header {display:flex;justify-content:space-between;align-items:start;gap:20px;margin-bottom:24px}
381
+ [data-prts-region-editor] > header strong {font-size:18px}
382
+ [data-prts-region-workspace] {display:grid;grid-template-columns:minmax(0,1.5fr) minmax(290px,1fr);gap:28px}
383
+ [data-prts-region-original],[data-prts-region-samples] {display:grid;align-content:start;gap:12px;min-width:0}
384
+ [data-prts-region-original] {padding:0 8px 0 24px}
385
+ [data-prts-region-stage] {margin:24px 0 12px}
386
+ [data-prts-region-preview-grid] {display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:14px;align-items:start;margin-top:12px}
387
+ [data-prts-region-preview-grid] figure {display:grid;justify-items:center;align-content:start;gap:12px;margin:0;min-width:0}
388
+ [data-prts-region-preview-grid] figcaption {font-size:11px;color:var(--prts-muted)}
389
+ [data-prts-region-editor] > footer {display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:24px;padding-top:16px;border-top:1px solid var(--prts-line)}
390
+ [data-prts-region-editor] > footer > span {flex:1}
391
+ [data-prts-region-ruler] {position:absolute;pointer-events:none;color:var(--prts-muted);font:10px/1 var(--prts-mono)}
392
+ [data-prts-region-ruler="x"] {left:0;right:0;top:-20px;height:10px;border-bottom:1px solid var(--prts-line)}
393
+ [data-prts-region-ruler="y"] {top:0;bottom:0;left:-24px;width:16px;border-right:1px solid var(--prts-line)}
394
+ [data-prts-region-ruler] span,[data-prts-region-ruler] b {position:absolute;font-weight:400}
395
+ [data-prts-region-ruler="x"] span,[data-prts-region-ruler="x"] b {transform:translateX(-50%)}
396
+ [data-prts-region-ruler="y"] span,[data-prts-region-ruler="y"] b {transform:translateY(-50%)}
397
+ [data-prts-region-ruler] b {background:var(--prts-panel);color:var(--prts-cyan);z-index:1}
398
+ @media(max-width:760px){[data-prts-region-editor]{padding:18px;width:calc(100vw - 16px);max-height:calc(100dvh - 16px)}[data-prts-region-workspace]{grid-template-columns:1fr}[data-prts-region-preview-grid]{grid-template-columns:1fr 1fr}[data-prts-region-editor] > footer > span{display:none}}
399
+ [data-prts-region-editor] p { margin:0;font-size:12px;color:var(--prts-muted); }
400
+ [data-prts-region-stage] { position:relative;max-width:100%;justify-self:center;touch-action:none;user-select:none;cursor:default; }
401
+ [data-prts-region-stage] img { display:block;width:100%;height:auto;pointer-events:none; }
402
+ [data-prts-region-box] { position:absolute;box-sizing:border-box;border:2px solid #fff;outline:1px solid #111;box-shadow:0 0 0 1px #111;background:#ffffff08;cursor:move;touch-action:none; }
403
+ [data-prts-region-box] > span { position:absolute;top:4px;left:4px;background:#111b;color:#fff;font-size:10px;white-space:nowrap;pointer-events:none; }
404
+ [data-prts-region-handle] { position:absolute;width:16px!important;height:16px!important;min-width:0!important;padding:0!important;border:1px solid #111!important;background:#fff!important;border-radius:3px!important;touch-action:none; }
405
+ [data-prts-region-handle="nw"] {left:-8px;top:-8px;cursor:nwse-resize}
406
+ [data-prts-region-handle="ne"] {right:-8px;top:-8px;cursor:nesw-resize}
407
+ [data-prts-region-handle="sw"] {left:-8px;bottom:-8px;cursor:nesw-resize}
408
+ [data-prts-region-handle="se"] {right:-8px;bottom:-8px;cursor:nwse-resize}
409
+ [data-prts-region-preview] { position:relative;justify-self:center;max-width:100%;overflow:hidden;background-repeat:no-repeat;border:1px solid var(--prts-line);box-sizing:content-box; }
410
+ [data-prts-region-preview] i { position:absolute;border:1px solid #fff;box-shadow:0 0 0 1px #111;box-sizing:border-box;pointer-events:none; }
411
+ [data-prts-region-actions] { display:flex;gap:8px;flex-wrap:wrap; }
412
+ [data-prts-region-editor] button { padding:6px 10px;border:1px solid var(--prts-line);border-radius:5px;background:var(--prts-panel);color:var(--prts-ink);cursor:pointer;font-size:12px; }
413
+ [data-prts-region-save] { border-color:var(--prts-cyan)!important; }
414
+ [data-prts-region-status]:empty {display:none}
415
+
416
+ [data-prts-region-editor="sidebar"] [data-prts-region-preview-grid] {grid-template-columns:minmax(0,1fr) minmax(0,1.45fr)}
417
+
418
+ [data-prts-region-handle="n"] {left:50%;top:-8px;transform:translateX(-50%);cursor:ns-resize}
419
+ [data-prts-region-handle="s"] {left:50%;bottom:-8px;transform:translateX(-50%);cursor:ns-resize}
420
+ [data-prts-region-handle="w"] {left:-8px;top:50%;transform:translateY(-50%);cursor:ew-resize}
421
+ [data-prts-region-handle="e"] {right:-8px;top:50%;transform:translateY(-50%);cursor:ew-resize}
@@ -1907,12 +1907,12 @@ html[data-dsh-prts][data-prts-conversation-style="deck-chat"] {
1907
1907
  & [data-message-role="assistant"]::after,
1908
1908
  & :is([data-chat-flow-kind="user"], [data-submission-echo]:not([data-pending-steering])) [class$="_bubble"]::before,
1909
1909
  & [data-message-role="user"]::before {
1910
- content: ""; position: absolute; top: 0; left: -9px; width: 10px; height: 18px; pointer-events: none;
1910
+ content: ""; position: absolute; top: 0; left: -10px; width: 10px; height: 18px; pointer-events: none;
1911
1911
  background: var(--prts-bubble-surface, var(--prts-chat-user-surface));
1912
1912
  mask: 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") center / 100% 100% no-repeat;
1913
1913
  }
1914
1914
  & :is([data-chat-flow-kind="user"], [data-submission-echo]:not([data-pending-steering])) [class$="_bubble"]::before,
1915
- & [data-message-role="user"]::before { left: auto; right: -9px; transform: scaleX(-1); }
1915
+ & [data-message-role="user"]::before { left: auto; right: -10px; transform: scaleX(-1); }
1916
1916
  }
1917
1917
 
1918
1918
  /* The upstream component replaces only the composer's two background plates. */
@@ -393,6 +393,12 @@ html[data-dsh-prts] [data-prts-conversation-control="danger"] {
393
393
  }
394
394
 
395
395
  /* Empty session: emblem owns the upper field, composer anchors in the lower third. */
396
+ /* Reserve the native blank header before session restoration adds its controls.
397
+ Otherwise its height grows from 0 to 40px after the emblem has been positioned. */
398
+ html[data-dsh-prts] [data-phase="hero"] > [data-slot="conversation.header"] > header {
399
+ min-height: 40px;
400
+ }
401
+
396
402
  html[data-dsh-prts] [data-prts-hero-active] {
397
403
  --prts-hero-narrow-spacer: clamp(24px, 8dvh, 72px);
398
404
  --prts-hero-wide-spacer: clamp(96px, 22dvh, 340px);
@@ -970,6 +970,7 @@ html[data-dsh-prts] [data-prts-transfer] {
970
970
  & [data-prts-transfer-file-hint] { display:grid; place-items:center; flex:1; gap:6px; padding:12px; border:1px dashed var(--prts-line-strong); }
971
971
  & [data-prts-transfer-file-hint] > span { font-size:24px; color:var(--prts-muted); }
972
972
  & [data-prts-transfer-buttons] { display:flex; gap:8px; }
973
+ & [data-prts-transfer-compatibility] { margin:0; padding:10px 12px; border:1px solid var(--prts-yellow); overflow-wrap:anywhere; }
973
974
  & [data-prts-transfer-recovery] { border:0; border-top:1px solid var(--prts-line); padding:16px 0 0; }
974
975
  & [data-prts-transfer-status]:empty { display:none; }
975
976
  }