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

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.99",
3
+ "version": "0.1.101",
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
  }
@@ -7,6 +7,9 @@ export const FACILITY_GEOMETRY = Object.freeze({
7
7
  topOpening: 50,
8
8
  topInner: 40,
9
9
  topDepth: 10 / 3,
10
+ sessionTopOpening: 50,
11
+ sessionTopInner: 36.5,
12
+ sessionTopDepth: 4.5,
10
13
  })
11
14
 
12
15
  function number(value) {
@@ -17,7 +20,7 @@ function point(x, y) {
17
20
  return `${number(x)} ${number(y)}`
18
21
  }
19
22
 
20
- export function createFacilityPath({ width, height, topNotch = false } = {}) {
23
+ export function createFacilityPath({ width, height, topNotch = false, sessionNotch = false } = {}) {
21
24
  if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 2 || height <= FACILITY_GEOMETRY.sideOpening + 2) return ''
22
25
 
23
26
  const halfStroke = FACILITY_GEOMETRY.stroke / 2
@@ -29,12 +32,15 @@ export function createFacilityPath({ width, height, topNotch = false } = {}) {
29
32
  const centerY = height / 2
30
33
  const points = [point(left + FACILITY_GEOMETRY.corner, top)]
31
34
 
32
- if (topNotch && width >= FACILITY_GEOMETRY.topOpening + FACILITY_GEOMETRY.corner * 2 + FACILITY_GEOMETRY.stroke) {
35
+ const topOpening = sessionNotch ? FACILITY_GEOMETRY.sessionTopOpening : FACILITY_GEOMETRY.topOpening
36
+ const topInner = sessionNotch ? FACILITY_GEOMETRY.sessionTopInner : FACILITY_GEOMETRY.topInner
37
+ const topDepth = sessionNotch ? FACILITY_GEOMETRY.sessionTopDepth : FACILITY_GEOMETRY.topDepth
38
+ if (topNotch && width >= topOpening + FACILITY_GEOMETRY.corner * 2 + FACILITY_GEOMETRY.stroke) {
33
39
  points.push(
34
- point(centerX - FACILITY_GEOMETRY.topOpening / 2, top),
35
- point(centerX - FACILITY_GEOMETRY.topInner / 2, top + FACILITY_GEOMETRY.topDepth),
36
- point(centerX + FACILITY_GEOMETRY.topInner / 2, top + FACILITY_GEOMETRY.topDepth),
37
- point(centerX + FACILITY_GEOMETRY.topOpening / 2, top),
40
+ point(centerX - topOpening / 2, top),
41
+ point(centerX - topInner / 2, top + topDepth),
42
+ point(centerX + topInner / 2, top + topDepth),
43
+ point(centerX + topOpening / 2, top),
38
44
  )
39
45
  }
40
46
 
@@ -1,4 +1,4 @@
1
- import { createFacilityPath } from './facility-geometry.js'
1
+ import { createFacilityPath, FACILITY_GEOMETRY } from './facility-geometry.js'
2
2
  import { configureSessionLifeline } from './session-lifeline.js'
3
3
 
4
4
  const SVG_NS = 'http://www.w3.org/2000/svg'
@@ -123,9 +123,15 @@ function appendSessionPickup(document, graphic) {
123
123
  'clip-path': `url(#${clipId})`,
124
124
  })
125
125
  for (const strand of ['back', 'core', 'front']) {
126
- lifeline.append(svgNode(document, 'path', {
126
+ const filament = svgNode(document, 'g', {
127
127
  'data-prts-session-lifeline-filament': strand,
128
- }))
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)
129
135
  }
130
136
 
131
137
  const pickup = svgNode(document, 'g', {
@@ -161,6 +167,28 @@ function appendSessionPickup(document, graphic) {
161
167
  graphic.insertBefore(pickup, graphic.querySelector('[data-prts-facility-layer="outline"]'))
162
168
  }
163
169
 
170
+ function appendSessionStatusUnderlay(document, graphic) {
171
+ const surface = graphic.querySelector('[data-prts-facility-layer="surface"]')
172
+ if (!surface) return
173
+ if (!graphic.querySelector('[data-prts-session-status-underlay]')) {
174
+ graphic.insertBefore(svgNode(document, 'path', {
175
+ 'data-prts-session-status-underlay': '',
176
+ 'aria-hidden': 'true',
177
+ transform: 'translate(3 2)',
178
+ 'shape-rendering': 'geometricPrecision',
179
+ }), surface)
180
+ }
181
+ if (!graphic.querySelector('[data-prts-session-idle-indicator]')) {
182
+ graphic.insertBefore(svgNode(document, 'rect', {
183
+ 'data-prts-session-idle-indicator': '',
184
+ 'aria-hidden': 'true',
185
+ y: '2.5',
186
+ width: '50',
187
+ height: '2.5',
188
+ }), surface)
189
+ }
190
+ }
191
+
164
192
  function clamp(value, min, max) {
165
193
  return Math.min(max, Math.max(min, value))
166
194
  }
@@ -173,7 +201,7 @@ function sessionLifelineGeometry(width, height) {
173
201
  return { start, end, baseline, lift }
174
202
  }
175
203
 
176
- export function ensureFacilityGraphic(document, owner, { kind, topNotch = false, texture = 'none' } = {}) {
204
+ export function ensureFacilityGraphic(document, owner, { kind, topNotch = false, sessionNotch = false, texture = 'none' } = {}) {
177
205
  if (!owner) return null
178
206
  let graphic = owner.querySelector?.(':scope > svg[data-prts-facility-svg]')
179
207
  if (!graphic) {
@@ -199,9 +227,13 @@ export function ensureFacilityGraphic(document, owner, { kind, topNotch = false,
199
227
  owner.insertBefore(graphic, owner.firstChild)
200
228
  }
201
229
  if (texture === 'silhouette' && !graphic.querySelector('[data-prts-facility-silhouette]')) appendSilhouetteScene(document, graphic)
202
- if (texture === 'pickup' && !graphic.querySelector('[data-prts-session-pickup]')) appendSessionPickup(document, graphic)
230
+ if (texture === 'pickup') {
231
+ appendSessionStatusUnderlay(document, graphic)
232
+ if (!graphic.querySelector('[data-prts-session-pickup]')) appendSessionPickup(document, graphic)
233
+ }
203
234
  graphic.setAttribute('data-prts-facility-svg', kind)
204
235
  graphic.toggleAttribute('data-prts-top-notch', Boolean(topNotch))
236
+ graphic.toggleAttribute('data-prts-session-notch', Boolean(sessionNotch))
205
237
  if (!owner.hasAttribute('data-prts-facility-texture')) owner.setAttribute('data-prts-facility-texture', texture)
206
238
  return graphic
207
239
  }
@@ -216,19 +248,24 @@ function setReady(owner, ready) {
216
248
  export function renderFacilityGraphic(owner, { width, height } = {}) {
217
249
  const graphic = owner?.querySelector?.(':scope > svg[data-prts-facility-svg]')
218
250
  const topNotch = graphic?.hasAttribute('data-prts-top-notch')
219
- const path = createFacilityPath({ width, height, topNotch })
251
+ const sessionNotch = graphic?.hasAttribute('data-prts-session-notch')
252
+ const path = createFacilityPath({ width, height, topNotch, sessionNotch })
220
253
  if (!graphic || !path) {
221
254
  owner?.removeAttribute?.('data-prts-facility-size')
222
255
  setReady(owner, false)
223
256
  return false
224
257
  }
225
258
 
226
- const size = `${Number(width.toFixed(3))}x${Number(height.toFixed(3))}`
259
+ const size = `${Number(width.toFixed(3))}x${Number(height.toFixed(3))}:${topNotch ? 't' : '-'}${sessionNotch ? 's' : '-'}`
227
260
  const lifeline = graphic.querySelector('[data-prts-session-lifeline]')
228
261
  const lifelineGeometry = lifeline ? sessionLifelineGeometry(width, height) : undefined
229
262
  if (owner.getAttribute('data-prts-facility-size') !== size) {
230
263
  graphic.setAttribute('viewBox', `0 0 ${Number(width.toFixed(3))} ${Number(height.toFixed(3))}`)
231
264
  for (const layer of graphic.querySelectorAll('[data-prts-facility-layer], [data-prts-facility-clip]')) layer.setAttribute('d', path)
265
+ const statusUnderlay = graphic.querySelector('[data-prts-session-status-underlay]')
266
+ statusUnderlay?.setAttribute('d', createFacilityPath({ width, height }))
267
+ const idleIndicator = graphic.querySelector('[data-prts-session-idle-indicator]')
268
+ idleIndicator?.setAttribute('x', String(Number((width / 2 - 25).toFixed(3))))
232
269
  const silhouette = graphic.querySelector('[data-prts-facility-silhouette]')
233
270
  if (silhouette) {
234
271
  const availableHeight = Math.max(1, height - 2)
@@ -86,7 +86,12 @@ export function applyPrtsPlugin(ctx, environment) {
86
86
  const toBottom = createToBottomAdapter({ document, window })
87
87
  const conversationControls = createConversationControlAdapter({ document, window })
88
88
  const layoutDiagnostics = createLayoutStabilityDiagnostics({ document, window })
89
- const sidebarControls = createSidebarControlAdapter({ document, window })
89
+ const sidebarControls = createSidebarControlAdapter({
90
+ document,
91
+ window,
92
+ sessions: contextService(ctx, 'sessions'),
93
+ workspaces: contextService(ctx, 'workspaces'),
94
+ })
90
95
  const floatingGlass = createFloatingGlassAdapter({ document, window })
91
96
  const adapter = createRc7Adapter({ document })
92
97
  const hostGeometry = createHostGeometryAdapter({ document, window })
@@ -193,8 +198,24 @@ export function applyPrtsPlugin(ctx, environment) {
193
198
  persistAndApply(updatePreferenceValue(preferences, key, value))
194
199
  },
195
200
  onPreferencePreview(key, value) {
196
- if (key !== 'particleTraversalSpeed') return
197
- particleField.update(updatePreferenceValue(preferences, key, value))
201
+ if (key === 'particleTraversalSpeed') {
202
+ particleField.update(updatePreferenceValue(preferences, key, value))
203
+ return
204
+ }
205
+ if (key === 'sessionFlowSpeed') {
206
+ const preview = updatePreferenceValue(preferences, key, value)
207
+ document.documentElement.dataset.prtsSessionFlowSpeed = String(preview.sessionFlowSpeed)
208
+ return
209
+ }
210
+ const sessionFlowColorProperties = {
211
+ sessionFlowColorBack: '--prts-session-flow-back',
212
+ sessionFlowColorCore: '--prts-session-flow-core',
213
+ sessionFlowColorFront: '--prts-session-flow-front',
214
+ }
215
+ if (sessionFlowColorProperties[key]) {
216
+ document.documentElement.dataset.prtsSessionFlowPalette = 'custom'
217
+ document.documentElement.style.setProperty(sessionFlowColorProperties[key], String(value))
218
+ }
198
219
  },
199
220
  onVisualReset() {
200
221
  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',
@@ -19,6 +30,12 @@ export const DEFAULT_PREFERENCES = Object.freeze({
19
30
  particlePattern: 'orthogonal',
20
31
  railDefaultHidden: false,
21
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,
22
39
  conversationStyle: 'native',
23
40
  })
24
41
 
@@ -61,7 +78,7 @@ export const PREFERENCE_GROUPS = Object.freeze({
61
78
  material: Object.freeze(['glass']),
62
79
  accessibility: Object.freeze(['motion', 'bootAnimation']),
63
80
  navigation: Object.freeze(['railDefaultHidden']),
64
- conversation: Object.freeze(['conversationStyle']),
81
+ conversation: Object.freeze(['sessionFlow', 'sessionGlow', 'sessionFlowPalette', 'sessionFlowColorBack', 'sessionFlowColorCore', 'sessionFlowColorFront', 'sessionFlowSpeed', 'conversationStyle']),
65
82
  })
66
83
 
67
84
  const TEXTURES = new Set(['off', 'restrained', 'full'])
@@ -70,6 +87,8 @@ const MOTIONS = new Set(['system', 'reduced'])
70
87
  const PARTICLE_DENSITIES = new Set(['sparse', 'light', 'standard', 'dense', 'ultra'])
71
88
  const PRESETS = new Set([...Object.keys(VISUAL_PRESETS), 'custom'])
72
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'])
73
92
  const PRESET_LINKED_KEYS = new Set(Object.keys(VISUAL_PRESETS['standard-tactical']))
74
93
 
75
94
 
@@ -99,6 +118,10 @@ function enumOr(value, allowed, fallback) {
99
118
  return allowed.has(value) ? value : fallback
100
119
  }
101
120
 
121
+ function colorOr(value, fallback) {
122
+ return typeof value === 'string' && /^#[\da-f]{6}$/i.test(value) ? value.toLowerCase() : fallback
123
+ }
124
+
102
125
  function particleTraversalSpeedOr(value, fallback) {
103
126
  const numeric = Number(value)
104
127
  if (!Number.isFinite(numeric)) return fallback
@@ -106,6 +129,13 @@ function particleTraversalSpeedOr(value, fallback) {
106
129
  return Math.round(clamped / PARTICLE_TRAVERSAL_SPEED_STEP) * PARTICLE_TRAVERSAL_SPEED_STEP
107
130
  }
108
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
+
109
139
  function normalizeGlass(input) {
110
140
  if (input.glassEnabled === false || input.glass === 'off') return 'off'
111
141
  if (input.glass === 'liquid') return 'clear'
@@ -132,8 +162,16 @@ export function normalizePreferences(value) {
132
162
  : input.preset
133
163
  const particlePreset = VISUAL_PRESETS[input.preset] ?? DEFAULT_PREFERENCES
134
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
135
173
  const normalized = {
136
- version: 9,
174
+ version: 12,
137
175
  enabled: booleanOr(input.enabled, DEFAULT_PREFERENCES.enabled),
138
176
  preset: enumOr(migratedPreset, PRESETS, DEFAULT_PREFERENCES.preset),
139
177
  texture: enumOr(input.texture, TEXTURES, DEFAULT_PREFERENCES.texture),
@@ -149,6 +187,12 @@ export function normalizePreferences(value) {
149
187
  particlePattern: 'orthogonal',
150
188
  railDefaultHidden: booleanOr(input.railDefaultHidden, DEFAULT_PREFERENCES.railDefaultHidden),
151
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),
152
196
  conversationStyle: enumOr(input.conversationStyle, CONVERSATION_STYLES, DEFAULT_PREFERENCES.conversationStyle),
153
197
  }
154
198
  normalized.preset = matchedVisualPreset(normalized)
@@ -175,6 +219,26 @@ export function updatePreferenceValue(value, key, nextValue) {
175
219
  if (key === 'preset') return applyVisualPreset(value, nextValue)
176
220
  if (key === 'particleDetail') return applyParticleDetail(value, nextValue)
177
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
+ }
178
242
  if (key === 'conversationParticleDensity' || key === 'heroParticleDensity') {
179
243
  const density = enumOr(nextValue, PARTICLE_DENSITIES, current[key])
180
244
  return normalizePreferences({ ...current, [key]: density })
@@ -196,7 +260,7 @@ export function loadPreferences(storage) {
196
260
  const raw = storage?.getItem(PRTS_STORAGE_KEY)
197
261
  if (raw === null || raw === undefined) return { ...DEFAULT_PREFERENCES }
198
262
  const parsed = JSON.parse(raw)
199
- 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 }
200
264
  return normalizePreferences(parsed)
201
265
  } catch {
202
266
  return { ...DEFAULT_PREFERENCES }
@@ -1,6 +1,7 @@
1
1
  const HOVER_SETTLE = 180
2
2
  const STATIC_TIME = 2.4
3
3
  const POINT_COUNT = 9
4
+ const DEFAULT_FLOW_SPEED = 1
4
5
 
5
6
  function clamp(value, min, max) {
6
7
  return Math.min(max, Math.max(min, value))
@@ -25,6 +26,15 @@ function filamentNodes(lifeline) {
25
26
  return Array.from(lifeline.querySelectorAll?.('[data-prts-session-lifeline-filament]') ?? [])
26
27
  }
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
+
28
38
  function createSmoothPath(points) {
29
39
  if (points.length < 2) return ''
30
40
  const commands = [`M ${number(points[0].x)} ${number(points[0].y)}`]
@@ -91,7 +101,7 @@ export function configureSessionLifeline(lifeline, { start, end, baseline, lift
91
101
  lifeline.setAttribute('data-prts-lifeline-lift', String(number(lift)))
92
102
  filamentNodes(lifeline).forEach((filament, index, filaments) => {
93
103
  const strand = filaments.length === 1 ? 0 : index - (filaments.length - 1) / 2
94
- filament.setAttribute('d', createSessionLifelinePath(geometry, STATIC_TIME, 1, strand))
104
+ setFilamentPath(filament, createSessionLifelinePath(geometry, STATIC_TIME, 1, strand))
95
105
  })
96
106
  return true
97
107
  }
@@ -119,7 +129,7 @@ export function createSessionLifelineAnimator({ document, window }) {
119
129
  if (!geometry || filaments.length === 0) return false
120
130
  filaments.forEach((filament, index) => {
121
131
  const strand = filaments.length === 1 ? 0 : index - (filaments.length - 1) / 2
122
- filament.setAttribute('d', createSessionLifelinePath(geometry, flowTime, strength, strand))
132
+ setFilamentPath(filament, createSessionLifelinePath(geometry, flowTime, strength, strand))
123
133
  })
124
134
  return true
125
135
  }
@@ -136,6 +146,11 @@ export function createSessionLifelineAnimator({ document, window }) {
136
146
  )
137
147
  }
138
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
+
139
154
  function cancel() {
140
155
  if (frame !== undefined) cancelFrame?.(frame)
141
156
  frame = undefined
@@ -161,7 +176,7 @@ export function createSessionLifelineAnimator({ document, window }) {
161
176
  if (lastTime === undefined) lastTime = now
162
177
  const elapsed = Math.min(100, Math.max(0, now - lastTime))
163
178
  lastTime = now
164
- flowTime += elapsed / 1000
179
+ flowTime += elapsed / 1000 * currentFlowSpeed()
165
180
  const target = hovered ? 0 : 1
166
181
  if (strength !== target) {
167
182
  const step = elapsed / HOVER_SETTLE
@@ -212,7 +227,7 @@ export function createSessionLifelineAnimator({ document, window }) {
212
227
  motionObserver = new MotionObserver(syncMotion)
213
228
  motionObserver.observe(document.documentElement, {
214
229
  attributes: true,
215
- attributeFilter: ['data-prts-motion', 'data-prts-session-flow'],
230
+ attributeFilter: ['data-prts-motion', 'data-prts-session-flow', 'data-prts-session-flow-speed'],
216
231
  })
217
232
  }
218
233
  }