@ash-qw/dsh-theme-prts 0.1.97 → 0.1.99

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": "0.1.97",
3
+ "version": "0.1.99",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Unofficial Arknights P.R.T.S.-inspired UI theme for DeepSeek Harness Web",
@@ -2,8 +2,13 @@ const CONTROL_ATTRIBUTE = 'data-prts-glass-control'
2
2
  const MENU_ATTRIBUTE = 'data-prts-glass-menu'
3
3
  const FALLBACK_COMPOSER_ATTRIBUTE = 'data-prts-composer-fallback'
4
4
  const COMPOSER_SIGNAL_ATTRIBUTE = 'data-prts-composer-signal'
5
+ const FALLBACK_COMPOSER_ROOT_SELECTOR = [
6
+ '[data-slot="conversation.composer"]',
7
+ '[data-composer-seat]',
8
+ ].join(', ')
5
9
  const COMPOSER_RELEVANCE_SELECTOR = [
6
10
  '[data-composer-card]',
11
+ FALLBACK_COMPOSER_ROOT_SELECTOR,
7
12
  'form textarea',
8
13
  'button[aria-haspopup="menu"]',
9
14
  'button[aria-haspopup="listbox"]',
@@ -56,6 +61,18 @@ function markPopup(popup, kind) {
56
61
  }
57
62
  }
58
63
 
64
+ function fallbackComposerForms(document) {
65
+ const forms = new Set()
66
+ for (const root of document?.querySelectorAll?.(FALLBACK_COMPOSER_ROOT_SELECTOR) ?? []) {
67
+ if (root.querySelector('[data-composer-card]')) continue
68
+ for (const textarea of root.querySelectorAll('form textarea')) {
69
+ const form = textarea.closest('form')
70
+ if (form && root.contains(form)) forms.add(form)
71
+ }
72
+ }
73
+ return forms
74
+ }
75
+
59
76
  export function createComposerGlassAdapter({ document, window }) {
60
77
  let observer
61
78
  let scanFrame
@@ -104,17 +121,17 @@ export function createComposerGlassAdapter({ document, window }) {
104
121
  signal.remove?.()
105
122
  ownedSignals.delete(signal)
106
123
  }
124
+ const fallbackForms = fallbackComposerForms(document)
107
125
  for (const form of [...ownedFallbacks]) {
108
- if (!form.isConnected || form.hasAttribute('data-composer-card')) {
109
- form.removeAttribute?.(FALLBACK_COMPOSER_ATTRIBUTE)
110
- ownedFallbacks.delete(form)
111
- }
126
+ if (form.isConnected && fallbackForms.has(form) && !form.hasAttribute('data-composer-card')) continue
127
+ form.removeAttribute?.(FALLBACK_COMPOSER_ATTRIBUTE)
128
+ ownedFallbacks.delete(form)
129
+ if (form.hasAttribute('data-composer-card')) continue
130
+ const signal = form.querySelector?.(`:scope > [${COMPOSER_SIGNAL_ATTRIBUTE}]`)
131
+ if (!ownedSignals.has(signal)) continue
132
+ signal.remove?.()
133
+ ownedSignals.delete(signal)
112
134
  }
113
- const fallbackForms = new Set(
114
- [...(document?.querySelectorAll?.('form textarea') ?? [])]
115
- .map(textarea => textarea.closest('form'))
116
- .filter(form => form && !form.querySelector('[data-composer-card]')),
117
- )
118
135
  for (const form of fallbackForms) {
119
136
  form.setAttribute(FALLBACK_COMPOSER_ATTRIBUTE, '')
120
137
  ownedFallbacks.add(form)
@@ -1,4 +1,5 @@
1
1
  import { createFacilityPath } from './facility-geometry.js'
2
+ import { configureSessionLifeline } from './session-lifeline.js'
2
3
 
3
4
  const SVG_NS = 'http://www.w3.org/2000/svg'
4
5
  const SILHOUETTE_TRACK_WIDTH = 512
@@ -117,6 +118,16 @@ function appendSessionPickup(document, graphic) {
117
118
  clip.append(svgNode(document, 'path', { 'data-prts-facility-clip': '' }))
118
119
  defs.append(clip)
119
120
 
121
+ const lifeline = svgNode(document, 'g', {
122
+ 'data-prts-session-lifeline': '',
123
+ 'clip-path': `url(#${clipId})`,
124
+ })
125
+ for (const strand of ['back', 'core', 'front']) {
126
+ lifeline.append(svgNode(document, 'path', {
127
+ 'data-prts-session-lifeline-filament': strand,
128
+ }))
129
+ }
130
+
120
131
  const pickup = svgNode(document, 'g', {
121
132
  'data-prts-session-pickup': '',
122
133
  'clip-path': `url(#${clipId})`,
@@ -146,9 +157,22 @@ function appendSessionPickup(document, graphic) {
146
157
  })
147
158
 
148
159
  graphic.insertBefore(defs, graphic.firstChild)
160
+ graphic.insertBefore(lifeline, graphic.querySelector('[data-prts-facility-layer="outline"]'))
149
161
  graphic.insertBefore(pickup, graphic.querySelector('[data-prts-facility-layer="outline"]'))
150
162
  }
151
163
 
164
+ function clamp(value, min, max) {
165
+ return Math.min(max, Math.max(min, value))
166
+ }
167
+
168
+ function sessionLifelineGeometry(width, height) {
169
+ const start = clamp(width * .04, 8, 12)
170
+ const end = Math.max(start + 1, width - 8)
171
+ const baseline = height * .56
172
+ const lift = clamp(height * .35, 10, 14)
173
+ return { start, end, baseline, lift }
174
+ }
175
+
152
176
  export function ensureFacilityGraphic(document, owner, { kind, topNotch = false, texture = 'none' } = {}) {
153
177
  if (!owner) return null
154
178
  let graphic = owner.querySelector?.(':scope > svg[data-prts-facility-svg]')
@@ -200,6 +224,8 @@ export function renderFacilityGraphic(owner, { width, height } = {}) {
200
224
  }
201
225
 
202
226
  const size = `${Number(width.toFixed(3))}x${Number(height.toFixed(3))}`
227
+ const lifeline = graphic.querySelector('[data-prts-session-lifeline]')
228
+ const lifelineGeometry = lifeline ? sessionLifelineGeometry(width, height) : undefined
203
229
  if (owner.getAttribute('data-prts-facility-size') !== size) {
204
230
  graphic.setAttribute('viewBox', `0 0 ${Number(width.toFixed(3))} ${Number(height.toFixed(3))}`)
205
231
  for (const layer of graphic.querySelectorAll('[data-prts-facility-layer], [data-prts-facility-clip]')) layer.setAttribute('d', path)
@@ -223,6 +249,9 @@ export function renderFacilityGraphic(owner, { width, height } = {}) {
223
249
  bar.setAttribute('y', String(Number((center - barHeight / 2).toFixed(3))))
224
250
  }
225
251
  }
252
+ if (lifeline) {
253
+ configureSessionLifeline(lifeline, lifelineGeometry)
254
+ }
226
255
  owner.setAttribute('data-prts-facility-size', size)
227
256
  }
228
257
  setReady(owner, true)
@@ -44,7 +44,6 @@ export function applyPrtsPlugin(ctx, environment) {
44
44
  const safeMode = isSafeMode(window?.location?.search ?? '')
45
45
  const storage = window?.localStorage
46
46
  const themeService = contextService(ctx, 'theme')
47
- const startup = createPrtsStartupSequence({ document, window, prtsEmblem: assets.prtsEmblem, rhodesEmblem: assets.emblem, timings: startupTimings })
48
47
  const composerGlass = createComposerGlassAdapter({ document, window })
49
48
  const assistantGlass = createAssistantGlassAdapter({
50
49
  document,
@@ -62,6 +61,19 @@ export function applyPrtsPlugin(ctx, environment) {
62
61
  sync()
63
62
  },
64
63
  })
64
+ const startup = createPrtsStartupSequence({
65
+ document,
66
+ window,
67
+ prtsEmblem: assets.prtsEmblem,
68
+ rhodesEmblem: assets.emblem,
69
+ timings: startupTimings,
70
+ onActiveChange(active) {
71
+ particleField.setStartupActive(active)
72
+ },
73
+ onReady() {
74
+ particleField.setStartupActive(false)
75
+ },
76
+ })
65
77
  const theme = createThemeController({
66
78
  document,
67
79
  window,
@@ -144,6 +156,7 @@ export function applyPrtsPlugin(ctx, environment) {
144
156
  }
145
157
 
146
158
  const disposeVisualState = () => {
159
+ particleField.update({ ...preferences, enabled: false })
147
160
  startup.stop({ restoreFocus: false })
148
161
  stopStartupReadyWatch()
149
162
  stopMountWatch()
@@ -151,7 +164,6 @@ export function applyPrtsPlugin(ctx, environment) {
151
164
  disposeOperations()
152
165
  composerGlass.dispose()
153
166
  assistantGlass.dispose()
154
- particleField.update({ ...preferences, enabled: false })
155
167
  toBottom.dispose()
156
168
  conversationControls.dispose()
157
169
  layoutDiagnostics.dispose()
@@ -227,9 +239,11 @@ export function applyPrtsPlugin(ctx, environment) {
227
239
  phase: result.persisted ? 'saved' : 'error',
228
240
  revision: settingsPersistence.revision + 1,
229
241
  }
242
+ const shouldPlayStartup = !safeMode && !wasEnabled && preferences.enabled && preferences.bootAnimation
243
+ if (shouldPlayStartup) particleField.setStartupActive(true)
230
244
  applyVisualState()
231
245
  sync()
232
- if (!safeMode && !wasEnabled && preferences.enabled && preferences.bootAnimation) startup.play()
246
+ if (shouldPlayStartup && !startup.play()) particleField.setStartupActive(false)
233
247
  }
234
248
 
235
249
 
@@ -302,13 +316,13 @@ export function applyPrtsPlugin(ctx, environment) {
302
316
  }
303
317
  ctx.effect(() => () => {
304
318
  statusSource.dispose()
319
+ particleField.dispose()
305
320
  startup.dispose({ restoreFocus: false })
306
321
  stopStartupReadyWatch()
307
322
  stopMountWatch()
308
323
  disposeOperations()
309
324
  composerGlass.dispose()
310
325
  assistantGlass.dispose()
311
- particleField.dispose()
312
326
  toBottom.dispose()
313
327
  conversationControls.dispose()
314
328
  layoutDiagnostics.dispose()
@@ -27,7 +27,10 @@ function markup(emblem) {
27
27
  <svg data-prts-scheme-icon="dark" viewBox="0 0 24 24" aria-hidden="true"><path d="M20.2 15.4A8.4 8.4 0 0 1 8.6 3.8 8.5 8.5 0 1 0 20.2 15.4Z"></path></svg>
28
28
  <small>明暗</small>
29
29
  </button>
30
- <button type="button" data-prts-theme-disable aria-label="关闭 P.R.T.S. 主题"><b aria-hidden="true">×</b><small>关闭主题</small></button>
30
+ <button type="button" data-prts-theme-disable aria-label="退出 P.R.T.S.,返回 Harness 原生界面">
31
+ <svg data-prts-theme-disable-icon viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v8"></path><path d="M7.1 6.8a7 7 0 1 0 9.8 0"></path></svg>
32
+ <small>退出 P.R.T.S.</small>
33
+ </button>
31
34
  </div>
32
35
  </nav>
33
36
  ${themeSettingsMarkup()}
@@ -326,7 +326,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
326
326
  const rhodesIndex = Math.max(0, emblemSequence.findIndex(entry => entry.key === 'rhodes-island'))
327
327
  const suppliedHeroMask = normalizeEmblemMasks(heroEmblemMask ? [heroEmblemMask] : [], window).get('rhodes-island-hero')
328
328
  const empty = { mounted: false, hero: false, side: -1, transition: null, anchor: { horizontalProgress: 0, verticalProgress: 0 }, phase: 'error', error: '徽记资源异常' }
329
- if (!document || !window) return { update() {}, setSchemeTransitionActive() {}, inspect: () => empty, dispose() {} }
329
+ if (!document || !window) return { update() {}, setStartupActive() {}, setSchemeTransitionActive() {}, inspect: () => empty, dispose() {} }
330
330
 
331
331
  let preferences = {}
332
332
  let operation
@@ -396,6 +396,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
396
396
  let pauseStartedAt
397
397
  let hydrationPaused = false
398
398
  let schemeTransitionPaused = false
399
+ let startupPaused = false
399
400
  let hydrationTimer
400
401
  let hydrationFrame
401
402
  let hydrationStableFrames = 0
@@ -429,7 +430,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
429
430
  : preferences.conversationParticleDensity
430
431
  const densityProfile = density => PARTICLE_DENSITY_PROFILES[density] ?? PARTICLE_DENSITY_PROFILES.standard
431
432
  const particleRadius = density => densityProfile(density ?? activeDensity()).radius
432
- const canRenderFrame = () => !disposed && documentVisible && canvasVisible && !hydrationPaused && !schemeTransitionPaused && !resizing
433
+ const canRenderFrame = () => !disposed && documentVisible && canvasVisible && !hydrationPaused && !startupPaused && !schemeTransitionPaused && !resizing
433
434
  const layoutWidth = () => width
434
435
  const layoutHeight = () => height
435
436
  const layoutShiftX = () => 0
@@ -707,6 +708,7 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
707
708
  }
708
709
 
709
710
  function buildParticles() {
711
+ if (startupPaused) return false
710
712
  const selected = populationSource()
711
713
  if (!selected.length) {
712
714
  reportState({ phase: 'error', error: '徽记资源异常' })
@@ -1646,14 +1648,17 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1646
1648
  cancelFrame()
1647
1649
  return
1648
1650
  }
1649
- if (pauseStartedAt !== undefined) {
1650
- resumePausedTimelines(now)
1651
- if (canvas && particles.length) {
1652
- syncCanvasBackingStore()
1653
- cancelFrame()
1654
- draw(now)
1655
- return
1656
- }
1651
+ const resumedFromPause = pauseStartedAt !== undefined
1652
+ if (resumedFromPause) resumePausedTimelines(now)
1653
+ let particlesBuilt = false
1654
+ if (canvas && !particles.length && maskRecords.length) {
1655
+ particlesBuilt = buildParticles()
1656
+ }
1657
+ if (canvas && particles.length && (resumedFromPause || particlesBuilt)) {
1658
+ syncCanvasBackingStore()
1659
+ cancelFrame()
1660
+ draw(now)
1661
+ return
1657
1662
  }
1658
1663
  scheduleFrame()
1659
1664
  }
@@ -1819,6 +1824,12 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1819
1824
  if (!mediaQuery?.addEventListener) mediaQuery?.addListener?.(mediaListener)
1820
1825
  document.addEventListener?.('visibilitychange', onVisibilityChange)
1821
1826
  return {
1827
+ setStartupActive(active) {
1828
+ const next = Boolean(active)
1829
+ if (startupPaused === next) return
1830
+ startupPaused = next
1831
+ updateFrameGate()
1832
+ },
1822
1833
  setSchemeTransitionActive(active) {
1823
1834
  const next = Boolean(active)
1824
1835
  if (schemeTransitionPaused === next) return
@@ -1903,12 +1914,13 @@ export function createParticleFieldAdapter({ document, window, emblem = '', embl
1903
1914
  : transition?.toCount !== undefined
1904
1915
  ? Math.round(transition.fromCount + (transition.toCount - transition.fromCount) * easeInOut(transition.progress))
1905
1916
  : particles.length,
1906
- targetParticles: transition?.toCount ?? targetParticleCount(),
1917
+ targetParticles: startupPaused && !particles.length ? 0 : transition?.toCount ?? targetParticleCount(),
1907
1918
  density: activeDensity() || 'standard',
1908
1919
  firstFrameReady: canvas?.hasAttribute('data-prts-particle-ready') === true,
1909
1920
  hydrationPaused,
1910
1921
  sleeping: Boolean(context && particles.length && frame === undefined && canRenderFrame() && !motionReduced() && !phoneStatic()),
1911
1922
  schemeTransitionPaused,
1923
+ startupPaused,
1912
1924
  pointer: {
1913
1925
  active: pointerActive,
1914
1926
  clientX: pointerClientX,
@@ -18,7 +18,8 @@ export const DEFAULT_PREFERENCES = Object.freeze({
18
18
  particleTraversalSpeed: PARTICLE_TRAVERSAL_SPEED_DEFAULT,
19
19
  particlePattern: 'orthogonal',
20
20
  railDefaultHidden: false,
21
- conversationStyle: 'deck-chat',
21
+ sessionFlow: true,
22
+ conversationStyle: 'native',
22
23
  })
23
24
 
24
25
  export const VISUAL_PRESETS = Object.freeze({
@@ -147,6 +148,7 @@ export function normalizePreferences(value) {
147
148
  ),
148
149
  particlePattern: 'orthogonal',
149
150
  railDefaultHidden: booleanOr(input.railDefaultHidden, DEFAULT_PREFERENCES.railDefaultHidden),
151
+ sessionFlow: booleanOr(input.sessionFlow, DEFAULT_PREFERENCES.sessionFlow),
150
152
  conversationStyle: enumOr(input.conversationStyle, CONVERSATION_STYLES, DEFAULT_PREFERENCES.conversationStyle),
151
153
  }
152
154
  normalized.preset = matchedVisualPreset(normalized)
@@ -0,0 +1,255 @@
1
+ const HOVER_SETTLE = 180
2
+ const STATIC_TIME = 2.4
3
+ const POINT_COUNT = 9
4
+
5
+ function clamp(value, min, max) {
6
+ return Math.min(max, Math.max(min, value))
7
+ }
8
+
9
+ function number(value) {
10
+ return Number(value.toFixed(3))
11
+ }
12
+
13
+ function readGeometry(lifeline) {
14
+ const start = Number(lifeline?.getAttribute?.('data-prts-lifeline-start'))
15
+ const end = Number(lifeline?.getAttribute?.('data-prts-lifeline-end'))
16
+ const baseline = Number(lifeline?.getAttribute?.('data-prts-lifeline-baseline'))
17
+ const lift = Number(lifeline?.getAttribute?.('data-prts-lifeline-lift'))
18
+ if (![start, end, baseline, lift].every(Number.isFinite) || end <= start || lift <= 0) return null
19
+ return { start, end, baseline, lift }
20
+ }
21
+
22
+ function filamentNodes(lifeline) {
23
+ if (!lifeline) return []
24
+ if (lifeline.matches?.('[data-prts-session-lifeline-filament]')) return [lifeline]
25
+ return Array.from(lifeline.querySelectorAll?.('[data-prts-session-lifeline-filament]') ?? [])
26
+ }
27
+
28
+ function createSmoothPath(points) {
29
+ if (points.length < 2) return ''
30
+ const commands = [`M ${number(points[0].x)} ${number(points[0].y)}`]
31
+ for (let index = 0; index < points.length - 1; index += 1) {
32
+ const previous = points[Math.max(0, index - 1)]
33
+ const current = points[index]
34
+ const next = points[index + 1]
35
+ const following = points[Math.min(points.length - 1, index + 2)]
36
+ const firstControl = {
37
+ x: current.x + (next.x - previous.x) / 6,
38
+ y: current.y + (next.y - previous.y) / 6,
39
+ }
40
+ const secondControl = {
41
+ x: next.x - (following.x - current.x) / 6,
42
+ y: next.y - (following.y - current.y) / 6,
43
+ }
44
+ commands.push([
45
+ 'C',
46
+ number(firstControl.x),
47
+ number(firstControl.y),
48
+ number(secondControl.x),
49
+ number(secondControl.y),
50
+ number(next.x),
51
+ number(next.y),
52
+ ].join(' '))
53
+ }
54
+ return commands.join(' ')
55
+ }
56
+
57
+ function sampleFlow(u, time, strand) {
58
+ const edgeEnvelope = Math.sin(Math.PI * u) ** .88
59
+ const slowTurn = Math.sin(time * .43 + u * Math.PI * 1.16 + Math.sin(time * .13) * .48)
60
+ const crossCurrent = Math.sin(time * -.29 + u * Math.PI * 2.42 + .7)
61
+ const fineCurrent = Math.sin(time * .19 + u * Math.PI * 3.76 - .9)
62
+ const sharedFlow = slowTurn * .52 + crossCurrent * .24 + fineCurrent * .08
63
+
64
+ const fan = Math.sin(Math.PI * u) ** 1.35
65
+ const separation = strand * (.15 + Math.sin(time * .31 + u * Math.PI * 1.48 + strand * .8) * .1)
66
+ const independentDrift = Math.sin(time * (.36 + strand * .021) + u * Math.PI * 2.18 + strand * 1.35) * .055
67
+ return edgeEnvelope * clamp(sharedFlow + fan * separation + independentDrift, -.9, .9)
68
+ }
69
+
70
+ export function createSessionLifelinePath(geometry, time = STATIC_TIME, strength = 1, strand = 0) {
71
+ const { start, end, baseline, lift } = geometry
72
+ const span = end - start
73
+ const tension = clamp(strength, 0, 1)
74
+ const points = []
75
+ for (let index = 0; index < POINT_COUNT; index += 1) {
76
+ const u = index / (POINT_COUNT - 1)
77
+ points.push({
78
+ x: start + span * u,
79
+ y: baseline - lift * sampleFlow(u, time, strand) * tension,
80
+ })
81
+ }
82
+ return createSmoothPath(points)
83
+ }
84
+
85
+ export function configureSessionLifeline(lifeline, { start, end, baseline, lift }) {
86
+ if (!lifeline) return false
87
+ const geometry = { start, end, baseline, lift }
88
+ lifeline.setAttribute('data-prts-lifeline-start', String(number(start)))
89
+ lifeline.setAttribute('data-prts-lifeline-end', String(number(end)))
90
+ lifeline.setAttribute('data-prts-lifeline-baseline', String(number(baseline)))
91
+ lifeline.setAttribute('data-prts-lifeline-lift', String(number(lift)))
92
+ filamentNodes(lifeline).forEach((filament, index, filaments) => {
93
+ const strand = filaments.length === 1 ? 0 : index - (filaments.length - 1) / 2
94
+ filament.setAttribute('d', createSessionLifelinePath(geometry, STATIC_TIME, 1, strand))
95
+ })
96
+ return true
97
+ }
98
+
99
+ export function createSessionLifelineAnimator({ document, window }) {
100
+ const requestFrame = window?.requestAnimationFrame?.bind(window)
101
+ ?? (callback => window?.setTimeout?.(() => callback(Date.now()), 1000 / 60))
102
+ const cancelFrame = window?.cancelAnimationFrame?.bind(window)
103
+ ?? window?.clearTimeout?.bind(window)
104
+ const reducedMotion = window?.matchMedia?.('(prefers-reduced-motion: reduce)')
105
+ const MotionObserver = window?.MutationObserver
106
+
107
+ let frame
108
+ let lastTime
109
+ let flowTime = STATIC_TIME
110
+ let strength = 1
111
+ let row
112
+ let lifeline
113
+ let filaments = []
114
+ let hovered = false
115
+ let motionObserver
116
+
117
+ function render() {
118
+ const geometry = readGeometry(lifeline)
119
+ if (!geometry || filaments.length === 0) return false
120
+ filaments.forEach((filament, index) => {
121
+ const strand = filaments.length === 1 ? 0 : index - (filaments.length - 1) / 2
122
+ filament.setAttribute('d', createSessionLifelinePath(geometry, flowTime, strength, strand))
123
+ })
124
+ return true
125
+ }
126
+
127
+ function canAnimate() {
128
+ return Boolean(
129
+ row?.isConnected
130
+ && lifeline?.isConnected
131
+ && row.getAttribute('aria-selected') === 'true'
132
+ && document?.visibilityState !== 'hidden'
133
+ && document?.documentElement?.dataset?.prtsSessionFlow !== 'off'
134
+ && document?.documentElement?.dataset?.prtsMotion !== 'reduced'
135
+ && !reducedMotion?.matches
136
+ )
137
+ }
138
+
139
+ function cancel() {
140
+ if (frame !== undefined) cancelFrame?.(frame)
141
+ frame = undefined
142
+ lastTime = undefined
143
+ }
144
+
145
+ function needsFrame() {
146
+ return canAnimate() && (!hovered || strength > 0)
147
+ }
148
+
149
+ function schedule() {
150
+ if (frame !== undefined || !needsFrame()) return
151
+ frame = requestFrame?.(tick)
152
+ }
153
+
154
+ function tick(time) {
155
+ frame = undefined
156
+ if (!canAnimate()) {
157
+ lastTime = undefined
158
+ return
159
+ }
160
+ const now = Number.isFinite(time) ? time : Date.now()
161
+ if (lastTime === undefined) lastTime = now
162
+ const elapsed = Math.min(100, Math.max(0, now - lastTime))
163
+ lastTime = now
164
+ flowTime += elapsed / 1000
165
+ const target = hovered ? 0 : 1
166
+ if (strength !== target) {
167
+ const step = elapsed / HOVER_SETTLE
168
+ strength = target > strength
169
+ ? Math.min(target, strength + step)
170
+ : Math.max(target, strength - step)
171
+ }
172
+ render()
173
+ schedule()
174
+ }
175
+
176
+ function motionReduced() {
177
+ return document?.documentElement?.dataset?.prtsMotion === 'reduced' || reducedMotion?.matches
178
+ }
179
+
180
+ function syncMotion() {
181
+ if (canAnimate()) {
182
+ schedule()
183
+ return
184
+ }
185
+ cancel()
186
+ if (lifeline && motionReduced()) {
187
+ flowTime = STATIC_TIME
188
+ strength = 1
189
+ render()
190
+ }
191
+ }
192
+
193
+ function onPointerEnter() {
194
+ hovered = true
195
+ syncMotion()
196
+ }
197
+
198
+ function onPointerLeave() {
199
+ hovered = false
200
+ syncMotion()
201
+ }
202
+
203
+ function detachRow() {
204
+ row?.removeEventListener?.('pointerenter', onPointerEnter)
205
+ row?.removeEventListener?.('pointerleave', onPointerLeave)
206
+ }
207
+
208
+ function attachObservers() {
209
+ document?.addEventListener?.('visibilitychange', syncMotion)
210
+ reducedMotion?.addEventListener?.('change', syncMotion)
211
+ if (!motionObserver && typeof MotionObserver === 'function' && document?.documentElement) {
212
+ motionObserver = new MotionObserver(syncMotion)
213
+ motionObserver.observe(document.documentElement, {
214
+ attributes: true,
215
+ attributeFilter: ['data-prts-motion', 'data-prts-session-flow'],
216
+ })
217
+ }
218
+ }
219
+
220
+ return {
221
+ start() {
222
+ attachObservers()
223
+ syncMotion()
224
+ },
225
+ setTarget(nextRow, nextLifeline) {
226
+ if (row === nextRow && lifeline === nextLifeline) {
227
+ syncMotion()
228
+ return
229
+ }
230
+ cancel()
231
+ detachRow()
232
+ row = nextRow
233
+ lifeline = nextLifeline
234
+ filaments = filamentNodes(lifeline)
235
+ hovered = Boolean(row?.matches?.(':hover'))
236
+ flowTime = STATIC_TIME
237
+ strength = hovered ? 0 : 1
238
+ row?.addEventListener?.('pointerenter', onPointerEnter)
239
+ row?.addEventListener?.('pointerleave', onPointerLeave)
240
+ render()
241
+ syncMotion()
242
+ },
243
+ dispose() {
244
+ cancel()
245
+ detachRow()
246
+ row = undefined
247
+ lifeline = undefined
248
+ filaments = []
249
+ motionObserver?.disconnect()
250
+ motionObserver = undefined
251
+ document?.removeEventListener?.('visibilitychange', syncMotion)
252
+ reducedMotion?.removeEventListener?.('change', syncMotion)
253
+ },
254
+ }
255
+ }
@@ -1,4 +1,5 @@
1
1
  import { ensureFacilityGraphic, ensureFacilityTextureDefs, renderFacilityGraphic } from './facility-vector.js'
2
+ import { createSessionLifelineAnimator } from './session-lifeline.js'
2
3
 
3
4
  const OWNED_ATTRIBUTES = [
4
5
  'data-prts-row-title',
@@ -128,6 +129,7 @@ export function createSidebarControlAdapter({ document, window }) {
128
129
  let vectorOwners = new Set()
129
130
  const dirtyVectors = new Map()
130
131
  const vectorSizes = new WeakMap()
132
+ const lifelineAnimator = createSessionLifelineAnimator({ document, window })
131
133
 
132
134
  function scheduleVectors() {
133
135
  if (resizeFrame !== undefined) return
@@ -191,6 +193,8 @@ export function createSidebarControlAdapter({ document, window }) {
191
193
  const root = document?.querySelector?.('[data-prts-region="sessions"] [data-slot="sidebar.workspaces"]')
192
194
  const next = new Set()
193
195
  const nextVectorOwners = new Set()
196
+ let activeLifelineRow
197
+ let activeLifeline
194
198
  if (root) {
195
199
  for (const projection of root.querySelectorAll('[data-prts-owned-projection]')) projection.remove()
196
200
  for (const row of root.querySelectorAll('div[role="treeitem"][aria-expanded]')) {
@@ -222,6 +226,10 @@ export function createSidebarControlAdapter({ document, window }) {
222
226
  const spine = ensureFacilitySpine(document, row, 'session', next)
223
227
  registerVector(face, { kind: 'face', topNotch: true, texture: 'pickup' }, next, nextVectorOwners)
224
228
  registerVector(spine, { kind: 'spine' }, next, nextVectorOwners)
229
+ if (!activeLifelineRow && row.getAttribute('aria-selected') === 'true') {
230
+ activeLifelineRow = row
231
+ activeLifeline = face.querySelector('[data-prts-session-lifeline]')
232
+ }
225
233
  markSidebarNode(row, 'data-prts-session-row', next)
226
234
  row.dataset.prtsSessionIndex = sessionIndex
227
235
  const title = resolveRowTitle(row, 'session', controls.actions)
@@ -236,6 +244,7 @@ export function createSidebarControlAdapter({ document, window }) {
236
244
  registerVector(controls.menu, { kind: 'button' }, next, nextVectorOwners)
237
245
  }
238
246
  }
247
+ lifelineAnimator.setTarget(activeLifelineRow, activeLifeline)
239
248
  for (const node of owned) {
240
249
  if (next.has(node)) continue
241
250
  if (node.hasAttribute?.('data-prts-owned-projection')
@@ -252,6 +261,7 @@ export function createSidebarControlAdapter({ document, window }) {
252
261
  }
253
262
 
254
263
  function clear() {
264
+ lifelineAnimator.setTarget(undefined, undefined)
255
265
  for (const node of document?.querySelectorAll?.('[data-prts-owned-projection], [data-prts-owned-facility], [data-prts-owned-facility-spine], [data-prts-owned-facility-vector], [data-prts-owned-facility-defs]') ?? []) node.remove()
256
266
  for (const node of owned) {
257
267
  for (const attribute of OWNED_ATTRIBUTES) node.removeAttribute?.(attribute)
@@ -267,6 +277,7 @@ export function createSidebarControlAdapter({ document, window }) {
267
277
  return {
268
278
  start() {
269
279
  if (!document) return
280
+ lifelineAnimator.start()
270
281
  ensureFacilityTextureDefs(document)
271
282
  const ResizeObserver = window?.ResizeObserver
272
283
  if (!resizeObserver && typeof ResizeObserver === 'function') {
@@ -311,6 +322,7 @@ export function createSidebarControlAdapter({ document, window }) {
311
322
  resizeFrame = undefined
312
323
  if (resizeTimer !== undefined) window?.clearTimeout?.(resizeTimer)
313
324
  resizeTimer = undefined
325
+ lifelineAnimator.dispose()
314
326
  clear()
315
327
  },
316
328
  }