@ash-qw/dsh-theme-prts 0.1.98 → 0.1.100

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.98",
3
+ "version": "0.1.100",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Unofficial Arknights P.R.T.S.-inspired UI theme for DeepSeek Harness Web",
@@ -59,7 +59,7 @@
59
59
  ],
60
60
  "scripts": {
61
61
  "build": "node scripts/build.mjs",
62
- "test": "node --test tests/*.test.mjs",
62
+ "test": "node --test --test-concurrency=1 tests/*.test.mjs",
63
63
  "test:e2e": "playwright test",
64
64
  "check": "npm run build && npm test",
65
65
  "pack:plugin": "npm run check && npm pack",
@@ -61,6 +61,14 @@ function markPopup(popup, kind) {
61
61
  }
62
62
  }
63
63
 
64
+ function hasVisibleText(element) {
65
+ if (typeof element?.innerText === 'string') return element.innerText.trim().length > 0
66
+ const clone = element?.cloneNode?.(true)
67
+ if (!clone) return false
68
+ for (const hidden of clone.querySelectorAll?.('[hidden], [aria-hidden="true"]') ?? []) hidden.remove()
69
+ return clone.textContent?.trim().length > 0
70
+ }
71
+
64
72
  function fallbackComposerForms(document) {
65
73
  const forms = new Set()
66
74
  for (const root of document?.querySelectorAll?.(FALLBACK_COMPOSER_ROOT_SELECTOR) ?? []) {
@@ -147,12 +155,14 @@ export function createComposerGlassAdapter({ document, window }) {
147
155
  for (const trigger of composer.querySelectorAll('button[aria-haspopup="menu"], button[aria-haspopup="listbox"]')) {
148
156
  const menu = linkedMenu(document, trigger)
149
157
  ?? trigger.parentElement?.querySelector('[role="menu"][aria-busy], [role="listbox"][aria-busy]')
150
- const isModel = trigger.matches('[class*="_7KE1Ra_"]')
151
- || menu?.hasAttribute('aria-busy')
152
158
  const isCommands = trigger.getAttribute('aria-haspopup') === 'listbox'
153
159
  || trigger.getAttribute('aria-label') === 'Commands'
154
160
  || trigger.matches('[class*="uV2eYG_add"]')
155
- const kind = isModel ? 'model' : isCommands ? 'action' : 'action'
161
+ const isModel = !isCommands && (
162
+ menu?.hasAttribute('aria-busy')
163
+ || (trigger.getAttribute('aria-haspopup') === 'menu' && hasVisibleText(trigger))
164
+ )
165
+ const kind = isModel ? 'model' : 'action'
156
166
  mark(trigger, CONTROL_ATTRIBUTE, kind)
157
167
  markPopup(menu, kind)
158
168
  }
@@ -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,22 @@ 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
+ const filament = svgNode(document, 'g', {
127
+ 'data-prts-session-lifeline-filament': strand,
128
+ })
129
+ for (const layer of ['halo', 'body', 'core']) {
130
+ filament.append(svgNode(document, 'path', {
131
+ 'data-prts-session-lifeline-layer': layer,
132
+ }))
133
+ }
134
+ lifeline.append(filament)
135
+ }
136
+
120
137
  const pickup = svgNode(document, 'g', {
121
138
  'data-prts-session-pickup': '',
122
139
  'clip-path': `url(#${clipId})`,
@@ -146,9 +163,22 @@ function appendSessionPickup(document, graphic) {
146
163
  })
147
164
 
148
165
  graphic.insertBefore(defs, graphic.firstChild)
166
+ graphic.insertBefore(lifeline, graphic.querySelector('[data-prts-facility-layer="outline"]'))
149
167
  graphic.insertBefore(pickup, graphic.querySelector('[data-prts-facility-layer="outline"]'))
150
168
  }
151
169
 
170
+ function clamp(value, min, max) {
171
+ return Math.min(max, Math.max(min, value))
172
+ }
173
+
174
+ function sessionLifelineGeometry(width, height) {
175
+ const start = clamp(width * .04, 8, 12)
176
+ const end = Math.max(start + 1, width - 8)
177
+ const baseline = height * .56
178
+ const lift = clamp(height * .35, 10, 14)
179
+ return { start, end, baseline, lift }
180
+ }
181
+
152
182
  export function ensureFacilityGraphic(document, owner, { kind, topNotch = false, texture = 'none' } = {}) {
153
183
  if (!owner) return null
154
184
  let graphic = owner.querySelector?.(':scope > svg[data-prts-facility-svg]')
@@ -200,6 +230,8 @@ export function renderFacilityGraphic(owner, { width, height } = {}) {
200
230
  }
201
231
 
202
232
  const size = `${Number(width.toFixed(3))}x${Number(height.toFixed(3))}`
233
+ const lifeline = graphic.querySelector('[data-prts-session-lifeline]')
234
+ const lifelineGeometry = lifeline ? sessionLifelineGeometry(width, height) : undefined
203
235
  if (owner.getAttribute('data-prts-facility-size') !== size) {
204
236
  graphic.setAttribute('viewBox', `0 0 ${Number(width.toFixed(3))} ${Number(height.toFixed(3))}`)
205
237
  for (const layer of graphic.querySelectorAll('[data-prts-facility-layer], [data-prts-facility-clip]')) layer.setAttribute('d', path)
@@ -223,6 +255,9 @@ export function renderFacilityGraphic(owner, { width, height } = {}) {
223
255
  bar.setAttribute('y', String(Number((center - barHeight / 2).toFixed(3))))
224
256
  }
225
257
  }
258
+ if (lifeline) {
259
+ configureSessionLifeline(lifeline, lifelineGeometry)
260
+ }
226
261
  owner.setAttribute('data-prts-facility-size', size)
227
262
  }
228
263
  setReady(owner, true)
@@ -193,8 +193,24 @@ export function applyPrtsPlugin(ctx, environment) {
193
193
  persistAndApply(updatePreferenceValue(preferences, key, value))
194
194
  },
195
195
  onPreferencePreview(key, value) {
196
- if (key !== 'particleTraversalSpeed') return
197
- particleField.update(updatePreferenceValue(preferences, key, value))
196
+ if (key === 'particleTraversalSpeed') {
197
+ particleField.update(updatePreferenceValue(preferences, key, value))
198
+ return
199
+ }
200
+ if (key === 'sessionFlowSpeed') {
201
+ const preview = updatePreferenceValue(preferences, key, value)
202
+ document.documentElement.dataset.prtsSessionFlowSpeed = String(preview.sessionFlowSpeed)
203
+ return
204
+ }
205
+ const sessionFlowColorProperties = {
206
+ sessionFlowColorBack: '--prts-session-flow-back',
207
+ sessionFlowColorCore: '--prts-session-flow-core',
208
+ sessionFlowColorFront: '--prts-session-flow-front',
209
+ }
210
+ if (sessionFlowColorProperties[key]) {
211
+ document.documentElement.dataset.prtsSessionFlowPalette = 'custom'
212
+ document.documentElement.style.setProperty(sessionFlowColorProperties[key], String(value))
213
+ }
198
214
  },
199
215
  onVisualReset() {
200
216
  persistAndApply({
@@ -4,9 +4,20 @@ export const PARTICLE_TRAVERSAL_SPEED_MIN = 0
4
4
  export const PARTICLE_TRAVERSAL_SPEED_MAX = 2
5
5
  export const PARTICLE_TRAVERSAL_SPEED_STEP = 0.25
6
6
  export const PARTICLE_TRAVERSAL_SPEED_DEFAULT = 1
7
+ export const SESSION_FLOW_SPEED_MIN = 0.25
8
+ export const SESSION_FLOW_SPEED_MAX = 2
9
+ export const SESSION_FLOW_SPEED_STEP = 0.25
10
+ export const SESSION_FLOW_SPEED_DEFAULT = 1
11
+
12
+ export const SESSION_FLOW_PALETTES = Object.freeze({
13
+ triad: Object.freeze({ back: '#f7ddeb', core: '#65ddb0', front: '#6eb7f3' }),
14
+ rhodes: Object.freeze({ back: '#b9edf4', core: '#5ccddb', front: '#5a9ee6' }),
15
+ amber: Object.freeze({ back: '#ffe6a3', core: '#f0c800', front: '#ee8f42' }),
16
+ alert: Object.freeze({ back: '#ffc0b8', core: '#ee625a', front: '#db5b8c' }),
17
+ })
7
18
 
8
19
  export const DEFAULT_PREFERENCES = Object.freeze({
9
- version: 9,
20
+ version: 12,
10
21
  enabled: false,
11
22
  preset: 'standard-tactical',
12
23
  texture: 'full',
@@ -18,6 +29,13 @@ export const DEFAULT_PREFERENCES = Object.freeze({
18
29
  particleTraversalSpeed: PARTICLE_TRAVERSAL_SPEED_DEFAULT,
19
30
  particlePattern: 'orthogonal',
20
31
  railDefaultHidden: false,
32
+ sessionFlow: true,
33
+ sessionGlow: true,
34
+ sessionFlowPalette: 'triad',
35
+ sessionFlowColorBack: SESSION_FLOW_PALETTES.triad.back,
36
+ sessionFlowColorCore: SESSION_FLOW_PALETTES.triad.core,
37
+ sessionFlowColorFront: SESSION_FLOW_PALETTES.triad.front,
38
+ sessionFlowSpeed: SESSION_FLOW_SPEED_DEFAULT,
21
39
  conversationStyle: 'native',
22
40
  })
23
41
 
@@ -60,7 +78,7 @@ export const PREFERENCE_GROUPS = Object.freeze({
60
78
  material: Object.freeze(['glass']),
61
79
  accessibility: Object.freeze(['motion', 'bootAnimation']),
62
80
  navigation: Object.freeze(['railDefaultHidden']),
63
- conversation: Object.freeze(['conversationStyle']),
81
+ conversation: Object.freeze(['sessionFlow', 'sessionGlow', 'sessionFlowPalette', 'sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront', 'sessionFlowSpeed', 'conversationStyle']),
64
82
  })
65
83
 
66
84
  const TEXTURES = new Set(['off', 'restrained', 'full'])
@@ -69,6 +87,8 @@ const MOTIONS = new Set(['system', 'reduced'])
69
87
  const PARTICLE_DENSITIES = new Set(['sparse', 'light', 'standard', 'dense', 'ultra'])
70
88
  const PRESETS = new Set([...Object.keys(VISUAL_PRESETS), 'custom'])
71
89
  const CONVERSATION_STYLES = new Set(['native', 'deck-chat'])
90
+ const SESSION_FLOW_PALETTE_IDS = new Set([...Object.keys(SESSION_FLOW_PALETTES), 'custom'])
91
+ const SESSION_FLOW_COLOR_KEYS = new Set(['sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront'])
72
92
  const PRESET_LINKED_KEYS = new Set(Object.keys(VISUAL_PRESETS['standard-tactical']))
73
93
 
74
94
 
@@ -98,6 +118,10 @@ function enumOr(value, allowed, fallback) {
98
118
  return allowed.has(value) ? value : fallback
99
119
  }
100
120
 
121
+ function colorOr(value, fallback) {
122
+ return typeof value === 'string' && /^#[\da-f]{6}$/i.test(value) ? value.toLowerCase() : fallback
123
+ }
124
+
101
125
  function particleTraversalSpeedOr(value, fallback) {
102
126
  const numeric = Number(value)
103
127
  if (!Number.isFinite(numeric)) return fallback
@@ -105,6 +129,13 @@ function particleTraversalSpeedOr(value, fallback) {
105
129
  return Math.round(clamped / PARTICLE_TRAVERSAL_SPEED_STEP) * PARTICLE_TRAVERSAL_SPEED_STEP
106
130
  }
107
131
 
132
+ function sessionFlowSpeedOr(value, fallback) {
133
+ const numeric = Number(value)
134
+ if (!Number.isFinite(numeric)) return fallback
135
+ const clamped = Math.min(SESSION_FLOW_SPEED_MAX, Math.max(SESSION_FLOW_SPEED_MIN, numeric))
136
+ return Math.round(clamped / SESSION_FLOW_SPEED_STEP) * SESSION_FLOW_SPEED_STEP
137
+ }
138
+
108
139
  function normalizeGlass(input) {
109
140
  if (input.glassEnabled === false || input.glass === 'off') return 'off'
110
141
  if (input.glass === 'liquid') return 'clear'
@@ -131,8 +162,16 @@ export function normalizePreferences(value) {
131
162
  : input.preset
132
163
  const particlePreset = VISUAL_PRESETS[input.preset] ?? DEFAULT_PREFERENCES
133
164
  const legacyDensities = legacyParticleDensities(input)
165
+ const sessionFlowPalette = enumOr(input.sessionFlowPalette, SESSION_FLOW_PALETTE_IDS, DEFAULT_PREFERENCES.sessionFlowPalette)
166
+ const paletteColors = SESSION_FLOW_PALETTES[sessionFlowPalette]
167
+ const customColors = {
168
+ back: colorOr(input.sessionFlowColorBack, DEFAULT_PREFERENCES.sessionFlowColorBack),
169
+ core: colorOr(input.sessionFlowColorCore, DEFAULT_PREFERENCES.sessionFlowColorCore),
170
+ front: colorOr(input.sessionFlowColorFront, DEFAULT_PREFERENCES.sessionFlowColorFront),
171
+ }
172
+ const sessionFlowColors = paletteColors ?? customColors
134
173
  const normalized = {
135
- version: 9,
174
+ version: 12,
136
175
  enabled: booleanOr(input.enabled, DEFAULT_PREFERENCES.enabled),
137
176
  preset: enumOr(migratedPreset, PRESETS, DEFAULT_PREFERENCES.preset),
138
177
  texture: enumOr(input.texture, TEXTURES, DEFAULT_PREFERENCES.texture),
@@ -147,6 +186,13 @@ export function normalizePreferences(value) {
147
186
  ),
148
187
  particlePattern: 'orthogonal',
149
188
  railDefaultHidden: booleanOr(input.railDefaultHidden, DEFAULT_PREFERENCES.railDefaultHidden),
189
+ sessionFlow: booleanOr(input.sessionFlow, DEFAULT_PREFERENCES.sessionFlow),
190
+ sessionGlow: booleanOr(input.sessionGlow, DEFAULT_PREFERENCES.sessionGlow),
191
+ sessionFlowPalette,
192
+ sessionFlowColorBack: sessionFlowColors.back,
193
+ sessionFlowColorCore: sessionFlowColors.core,
194
+ sessionFlowColorFront: sessionFlowColors.front,
195
+ sessionFlowSpeed: sessionFlowSpeedOr(input.sessionFlowSpeed, DEFAULT_PREFERENCES.sessionFlowSpeed),
150
196
  conversationStyle: enumOr(input.conversationStyle, CONVERSATION_STYLES, DEFAULT_PREFERENCES.conversationStyle),
151
197
  }
152
198
  normalized.preset = matchedVisualPreset(normalized)
@@ -173,6 +219,26 @@ export function updatePreferenceValue(value, key, nextValue) {
173
219
  if (key === 'preset') return applyVisualPreset(value, nextValue)
174
220
  if (key === 'particleDetail') return applyParticleDetail(value, nextValue)
175
221
  const current = normalizePreferences(value)
222
+ if (key === 'sessionFlowPalette') {
223
+ const palette = enumOr(nextValue, SESSION_FLOW_PALETTE_IDS, current.sessionFlowPalette)
224
+ const colors = SESSION_FLOW_PALETTES[palette]
225
+ return normalizePreferences({
226
+ ...current,
227
+ sessionFlowPalette: palette,
228
+ ...(colors ? {
229
+ sessionFlowColorBack: colors.back,
230
+ sessionFlowColorCore: colors.core,
231
+ sessionFlowColorFront: colors.front,
232
+ } : {}),
233
+ })
234
+ }
235
+ if (SESSION_FLOW_COLOR_KEYS.has(key)) {
236
+ return normalizePreferences({
237
+ ...current,
238
+ sessionFlowPalette: 'custom',
239
+ [key]: colorOr(nextValue, current[key]),
240
+ })
241
+ }
176
242
  if (key === 'conversationParticleDensity' || key === 'heroParticleDensity') {
177
243
  const density = enumOr(nextValue, PARTICLE_DENSITIES, current[key])
178
244
  return normalizePreferences({ ...current, [key]: density })
@@ -194,7 +260,7 @@ export function loadPreferences(storage) {
194
260
  const raw = storage?.getItem(PRTS_STORAGE_KEY)
195
261
  if (raw === null || raw === undefined) return { ...DEFAULT_PREFERENCES }
196
262
  const parsed = JSON.parse(raw)
197
- if (![1, 2, 3, 4, 5, 6, 7, 8, 9].includes(parsed?.version)) return { ...DEFAULT_PREFERENCES }
263
+ if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(parsed?.version)) return { ...DEFAULT_PREFERENCES }
198
264
  return normalizePreferences(parsed)
199
265
  } catch {
200
266
  return { ...DEFAULT_PREFERENCES }
@@ -0,0 +1,270 @@
1
+ const HOVER_SETTLE = 180
2
+ const STATIC_TIME = 2.4
3
+ const POINT_COUNT = 9
4
+ const DEFAULT_FLOW_SPEED = 1
5
+
6
+ function clamp(value, min, max) {
7
+ return Math.min(max, Math.max(min, value))
8
+ }
9
+
10
+ function number(value) {
11
+ return Number(value.toFixed(3))
12
+ }
13
+
14
+ function readGeometry(lifeline) {
15
+ const start = Number(lifeline?.getAttribute?.('data-prts-lifeline-start'))
16
+ const end = Number(lifeline?.getAttribute?.('data-prts-lifeline-end'))
17
+ const baseline = Number(lifeline?.getAttribute?.('data-prts-lifeline-baseline'))
18
+ const lift = Number(lifeline?.getAttribute?.('data-prts-lifeline-lift'))
19
+ if (![start, end, baseline, lift].every(Number.isFinite) || end <= start || lift <= 0) return null
20
+ return { start, end, baseline, lift }
21
+ }
22
+
23
+ function filamentNodes(lifeline) {
24
+ if (!lifeline) return []
25
+ if (lifeline.matches?.('[data-prts-session-lifeline-filament]')) return [lifeline]
26
+ return Array.from(lifeline.querySelectorAll?.('[data-prts-session-lifeline-filament]') ?? [])
27
+ }
28
+
29
+ function setFilamentPath(filament, path) {
30
+ const layers = Array.from(filament?.querySelectorAll?.('[data-prts-session-lifeline-layer]') ?? [])
31
+ if (layers.length === 0) {
32
+ filament?.setAttribute?.('d', path)
33
+ return
34
+ }
35
+ layers.forEach(layer => layer.setAttribute('d', path))
36
+ }
37
+
38
+ function createSmoothPath(points) {
39
+ if (points.length < 2) return ''
40
+ const commands = [`M ${number(points[0].x)} ${number(points[0].y)}`]
41
+ for (let index = 0; index < points.length - 1; index += 1) {
42
+ const previous = points[Math.max(0, index - 1)]
43
+ const current = points[index]
44
+ const next = points[index + 1]
45
+ const following = points[Math.min(points.length - 1, index + 2)]
46
+ const firstControl = {
47
+ x: current.x + (next.x - previous.x) / 6,
48
+ y: current.y + (next.y - previous.y) / 6,
49
+ }
50
+ const secondControl = {
51
+ x: next.x - (following.x - current.x) / 6,
52
+ y: next.y - (following.y - current.y) / 6,
53
+ }
54
+ commands.push([
55
+ 'C',
56
+ number(firstControl.x),
57
+ number(firstControl.y),
58
+ number(secondControl.x),
59
+ number(secondControl.y),
60
+ number(next.x),
61
+ number(next.y),
62
+ ].join(' '))
63
+ }
64
+ return commands.join(' ')
65
+ }
66
+
67
+ function sampleFlow(u, time, strand) {
68
+ const edgeEnvelope = Math.sin(Math.PI * u) ** .88
69
+ const slowTurn = Math.sin(time * .43 + u * Math.PI * 1.16 + Math.sin(time * .13) * .48)
70
+ const crossCurrent = Math.sin(time * -.29 + u * Math.PI * 2.42 + .7)
71
+ const fineCurrent = Math.sin(time * .19 + u * Math.PI * 3.76 - .9)
72
+ const sharedFlow = slowTurn * .52 + crossCurrent * .24 + fineCurrent * .08
73
+
74
+ const fan = Math.sin(Math.PI * u) ** 1.35
75
+ const separation = strand * (.15 + Math.sin(time * .31 + u * Math.PI * 1.48 + strand * .8) * .1)
76
+ const independentDrift = Math.sin(time * (.36 + strand * .021) + u * Math.PI * 2.18 + strand * 1.35) * .055
77
+ return edgeEnvelope * clamp(sharedFlow + fan * separation + independentDrift, -.9, .9)
78
+ }
79
+
80
+ export function createSessionLifelinePath(geometry, time = STATIC_TIME, strength = 1, strand = 0) {
81
+ const { start, end, baseline, lift } = geometry
82
+ const span = end - start
83
+ const tension = clamp(strength, 0, 1)
84
+ const points = []
85
+ for (let index = 0; index < POINT_COUNT; index += 1) {
86
+ const u = index / (POINT_COUNT - 1)
87
+ points.push({
88
+ x: start + span * u,
89
+ y: baseline - lift * sampleFlow(u, time, strand) * tension,
90
+ })
91
+ }
92
+ return createSmoothPath(points)
93
+ }
94
+
95
+ export function configureSessionLifeline(lifeline, { start, end, baseline, lift }) {
96
+ if (!lifeline) return false
97
+ const geometry = { start, end, baseline, lift }
98
+ lifeline.setAttribute('data-prts-lifeline-start', String(number(start)))
99
+ lifeline.setAttribute('data-prts-lifeline-end', String(number(end)))
100
+ lifeline.setAttribute('data-prts-lifeline-baseline', String(number(baseline)))
101
+ lifeline.setAttribute('data-prts-lifeline-lift', String(number(lift)))
102
+ filamentNodes(lifeline).forEach((filament, index, filaments) => {
103
+ const strand = filaments.length === 1 ? 0 : index - (filaments.length - 1) / 2
104
+ setFilamentPath(filament, createSessionLifelinePath(geometry, STATIC_TIME, 1, strand))
105
+ })
106
+ return true
107
+ }
108
+
109
+ export function createSessionLifelineAnimator({ document, window }) {
110
+ const requestFrame = window?.requestAnimationFrame?.bind(window)
111
+ ?? (callback => window?.setTimeout?.(() => callback(Date.now()), 1000 / 60))
112
+ const cancelFrame = window?.cancelAnimationFrame?.bind(window)
113
+ ?? window?.clearTimeout?.bind(window)
114
+ const reducedMotion = window?.matchMedia?.('(prefers-reduced-motion: reduce)')
115
+ const MotionObserver = window?.MutationObserver
116
+
117
+ let frame
118
+ let lastTime
119
+ let flowTime = STATIC_TIME
120
+ let strength = 1
121
+ let row
122
+ let lifeline
123
+ let filaments = []
124
+ let hovered = false
125
+ let motionObserver
126
+
127
+ function render() {
128
+ const geometry = readGeometry(lifeline)
129
+ if (!geometry || filaments.length === 0) return false
130
+ filaments.forEach((filament, index) => {
131
+ const strand = filaments.length === 1 ? 0 : index - (filaments.length - 1) / 2
132
+ setFilamentPath(filament, createSessionLifelinePath(geometry, flowTime, strength, strand))
133
+ })
134
+ return true
135
+ }
136
+
137
+ function canAnimate() {
138
+ return Boolean(
139
+ row?.isConnected
140
+ && lifeline?.isConnected
141
+ && row.getAttribute('aria-selected') === 'true'
142
+ && document?.visibilityState !== 'hidden'
143
+ && document?.documentElement?.dataset?.prtsSessionFlow !== 'off'
144
+ && document?.documentElement?.dataset?.prtsMotion !== 'reduced'
145
+ && !reducedMotion?.matches
146
+ )
147
+ }
148
+
149
+ function currentFlowSpeed() {
150
+ const value = Number(document?.documentElement?.dataset?.prtsSessionFlowSpeed)
151
+ return Number.isFinite(value) ? clamp(value, .25, 2) : DEFAULT_FLOW_SPEED
152
+ }
153
+
154
+ function cancel() {
155
+ if (frame !== undefined) cancelFrame?.(frame)
156
+ frame = undefined
157
+ lastTime = undefined
158
+ }
159
+
160
+ function needsFrame() {
161
+ return canAnimate() && (!hovered || strength > 0)
162
+ }
163
+
164
+ function schedule() {
165
+ if (frame !== undefined || !needsFrame()) return
166
+ frame = requestFrame?.(tick)
167
+ }
168
+
169
+ function tick(time) {
170
+ frame = undefined
171
+ if (!canAnimate()) {
172
+ lastTime = undefined
173
+ return
174
+ }
175
+ const now = Number.isFinite(time) ? time : Date.now()
176
+ if (lastTime === undefined) lastTime = now
177
+ const elapsed = Math.min(100, Math.max(0, now - lastTime))
178
+ lastTime = now
179
+ flowTime += elapsed / 1000 * currentFlowSpeed()
180
+ const target = hovered ? 0 : 1
181
+ if (strength !== target) {
182
+ const step = elapsed / HOVER_SETTLE
183
+ strength = target > strength
184
+ ? Math.min(target, strength + step)
185
+ : Math.max(target, strength - step)
186
+ }
187
+ render()
188
+ schedule()
189
+ }
190
+
191
+ function motionReduced() {
192
+ return document?.documentElement?.dataset?.prtsMotion === 'reduced' || reducedMotion?.matches
193
+ }
194
+
195
+ function syncMotion() {
196
+ if (canAnimate()) {
197
+ schedule()
198
+ return
199
+ }
200
+ cancel()
201
+ if (lifeline && motionReduced()) {
202
+ flowTime = STATIC_TIME
203
+ strength = 1
204
+ render()
205
+ }
206
+ }
207
+
208
+ function onPointerEnter() {
209
+ hovered = true
210
+ syncMotion()
211
+ }
212
+
213
+ function onPointerLeave() {
214
+ hovered = false
215
+ syncMotion()
216
+ }
217
+
218
+ function detachRow() {
219
+ row?.removeEventListener?.('pointerenter', onPointerEnter)
220
+ row?.removeEventListener?.('pointerleave', onPointerLeave)
221
+ }
222
+
223
+ function attachObservers() {
224
+ document?.addEventListener?.('visibilitychange', syncMotion)
225
+ reducedMotion?.addEventListener?.('change', syncMotion)
226
+ if (!motionObserver && typeof MotionObserver === 'function' && document?.documentElement) {
227
+ motionObserver = new MotionObserver(syncMotion)
228
+ motionObserver.observe(document.documentElement, {
229
+ attributes: true,
230
+ attributeFilter: ['data-prts-motion', 'data-prts-session-flow', 'data-prts-session-flow-speed'],
231
+ })
232
+ }
233
+ }
234
+
235
+ return {
236
+ start() {
237
+ attachObservers()
238
+ syncMotion()
239
+ },
240
+ setTarget(nextRow, nextLifeline) {
241
+ if (row === nextRow && lifeline === nextLifeline) {
242
+ syncMotion()
243
+ return
244
+ }
245
+ cancel()
246
+ detachRow()
247
+ row = nextRow
248
+ lifeline = nextLifeline
249
+ filaments = filamentNodes(lifeline)
250
+ hovered = Boolean(row?.matches?.(':hover'))
251
+ flowTime = STATIC_TIME
252
+ strength = hovered ? 0 : 1
253
+ row?.addEventListener?.('pointerenter', onPointerEnter)
254
+ row?.addEventListener?.('pointerleave', onPointerLeave)
255
+ render()
256
+ syncMotion()
257
+ },
258
+ dispose() {
259
+ cancel()
260
+ detachRow()
261
+ row = undefined
262
+ lifeline = undefined
263
+ filaments = []
264
+ motionObserver?.disconnect()
265
+ motionObserver = undefined
266
+ document?.removeEventListener?.('visibilitychange', syncMotion)
267
+ reducedMotion?.removeEventListener?.('change', syncMotion)
268
+ },
269
+ }
270
+ }
@@ -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
  }
@@ -7,8 +7,17 @@ const ROOT_ATTRIBUTES = [
7
7
  'data-prts-glass',
8
8
  'data-prts-motion',
9
9
  'data-prts-particle-pattern',
10
+ 'data-prts-session-flow',
11
+ 'data-prts-session-glow',
12
+ 'data-prts-session-flow-palette',
13
+ 'data-prts-session-flow-speed',
10
14
  'data-prts-conversation-style',
11
15
  ]
16
+ const SESSION_FLOW_COLOR_PROPERTIES = {
17
+ '--prts-session-flow-back': 'sessionFlowColorBack',
18
+ '--prts-session-flow-core': 'sessionFlowColorCore',
19
+ '--prts-session-flow-front': 'sessionFlowColorFront',
20
+ }
12
21
 
13
22
  const TRANSITION_ATTRIBUTE = 'data-prts-scheme-transition'
14
23
  const TRANSITION_MODE_ATTRIBUTE = 'data-prts-scheme-transition-mode'
@@ -101,6 +110,7 @@ export function createThemeController({ document, window, cssText, service, onTr
101
110
 
102
111
  function clearThemeState() {
103
112
  for (const attribute of ROOT_ATTRIBUTES) root.removeAttribute(attribute)
113
+ for (const property of Object.keys(SESSION_FLOW_COLOR_PROPERTIES)) root.style.removeProperty(property)
104
114
  clearTransitionState()
105
115
  }
106
116
 
@@ -716,6 +726,11 @@ export function createThemeController({ document, window, cssText, service, onTr
716
726
  root.dataset.prtsGlass = preferences.glass
717
727
  root.dataset.prtsMotion = preferences.motion
718
728
  root.dataset.prtsParticlePattern = 'orthogonal'
729
+ root.dataset.prtsSessionFlow = preferences.sessionFlow ? 'on' : 'off'
730
+ root.dataset.prtsSessionGlow = preferences.sessionGlow ? 'on' : 'off'
731
+ root.dataset.prtsSessionFlowPalette = preferences.sessionFlowPalette
732
+ root.dataset.prtsSessionFlowSpeed = String(preferences.sessionFlowSpeed)
733
+ for (const [property, key] of Object.entries(SESSION_FLOW_COLOR_PROPERTIES)) root.style.setProperty(property, preferences[key])
719
734
  root.dataset.prtsConversationStyle = preferences.conversationStyle
720
735
  refresh()
721
736
  },