@ash-qw/dsh-theme-prts 0.1.92 → 0.1.93

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.
@@ -9,6 +9,38 @@ const ROOT_ATTRIBUTES = [
9
9
  'data-prts-particle-pattern',
10
10
  ]
11
11
 
12
+ const TRANSITION_ATTRIBUTE = 'data-prts-scheme-transition'
13
+ const TRANSITION_MODE_ATTRIBUTE = 'data-prts-scheme-transition-mode'
14
+ const TRANSITION_INTERACTIVE_ATTRIBUTE = 'data-prts-scheme-transition-interactive'
15
+ const TRANSITION_ARMED_ATTRIBUTE = 'data-prts-scheme-transition-armed'
16
+ const TRANSITION_COMPLETE_ATTRIBUTE = 'data-prts-scheme-transition-complete'
17
+ const TRANSITION_PROPERTIES = [
18
+ '--prts-scheme-origin-x',
19
+ '--prts-scheme-origin-y',
20
+ '--prts-scheme-duration',
21
+ '--prts-scheme-reveal-x',
22
+ '--prts-scheme-control-progress',
23
+ '--prts-scheme-control-meter-height',
24
+ '--prts-scheme-control-value-image',
25
+ ]
26
+ const REVEAL_EDGE_OVERSCAN = 32
27
+ const REVEAL_GRID_WIDTH = 220
28
+ const REVEAL_TIMING_LIMITS = {
29
+ desktop: { minimum: 480, maximum: 560, widthFactor: .38 },
30
+ mobile: { minimum: 320, maximum: 380, widthFactor: .9 },
31
+ }
32
+ const revealValueImages = new Map()
33
+
34
+ function revealValueImageUrl(value) {
35
+ const percentage = Math.min(100, Math.max(0, Math.round(Number(value) || 0)))
36
+ if (revealValueImages.has(percentage)) return revealValueImages.get(percentage)
37
+ const label = String(percentage).padStart(3, '0')
38
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="11" viewBox="0 0 24 11"><text x="0" y="9" fill="#ffd400" font-family="monospace" font-size="10" font-weight="700">${label}</text></svg>`
39
+ const url = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
40
+ revealValueImages.set(percentage, url)
41
+ return url
42
+ }
43
+
12
44
  function themeIdentity(value) {
13
45
  if (typeof value === 'string') return value
14
46
  if (!value || typeof value !== 'object') return ''
@@ -25,23 +57,57 @@ function resolveScheme(value) {
25
57
  return identity.includes('dark') || identity.includes('night') ? 'dark' : 'light'
26
58
  }
27
59
 
28
- export function createThemeController({ document, window, cssText, service }) {
29
- if (!document || !window) return { apply() {}, sync() {}, refresh() {}, setTheme() {}, toggle() {}, dispose() {} }
60
+ export function createThemeController({ document, window, cssText, service, onTransitionStateChange = () => {} }) {
61
+ if (!document || !window) {
62
+ const gesture = { update() {}, finish() {}, cancel() {} }
63
+ return { apply() {}, sync() {}, refresh() {}, setTheme() {}, beginThemeTransition() { return gesture }, toggle() {}, dispose() {} }
64
+ }
30
65
 
31
66
  const root = document.documentElement
32
67
  let style
33
68
  let disposed = false
34
69
  let refreshRevision = 0
35
70
  let setRevision = 0
71
+ let transitionRevision = 0
72
+ let activeTransition
73
+
74
+ let transitionStateActive = false
75
+
76
+ function setTransitionState(active) {
77
+ const next = Boolean(active)
78
+ if (transitionStateActive === next) return
79
+ transitionStateActive = next
80
+ try { onTransitionStateChange(next) } catch {}
81
+ }
82
+
83
+ function clearTransitionState() {
84
+ root.removeAttribute(TRANSITION_ATTRIBUTE)
85
+ root.removeAttribute(TRANSITION_MODE_ATTRIBUTE)
86
+ root.removeAttribute(TRANSITION_INTERACTIVE_ATTRIBUTE)
87
+ root.removeAttribute(TRANSITION_ARMED_ATTRIBUTE)
88
+ root.removeAttribute(TRANSITION_COMPLETE_ATTRIBUTE)
89
+ for (const property of TRANSITION_PROPERTIES) root.style.removeProperty(property)
90
+ }
91
+
92
+ function cancelActiveTransition({ keepPaused = false } = {}) {
93
+ const current = activeTransition
94
+ activeTransition = undefined
95
+ current?.cancel?.()
96
+ current?.viewTransition?.skipTransition?.()
97
+ clearTransitionState()
98
+ if (!keepPaused) setTransitionState(false)
99
+ }
36
100
 
37
101
  function clearThemeState() {
38
102
  for (const attribute of ROOT_ATTRIBUTES) root.removeAttribute(attribute)
103
+ clearTransitionState()
39
104
  }
40
105
 
41
106
  function removeOwnedState() {
42
107
  disposed = true
43
108
  refreshRevision += 1
44
109
  setRevision += 1
110
+ cancelActiveTransition()
45
111
  clearThemeState()
46
112
  root.removeAttribute('data-dsh-prts-settings')
47
113
  style?.remove()
@@ -59,9 +125,22 @@ export function createThemeController({ document, window, cssText, service }) {
59
125
  root.setAttribute('data-dsh-prts-settings', '')
60
126
  }
61
127
 
128
+ function applyScheme(value) {
129
+ if (disposed) return
130
+ const next = resolveScheme(value)
131
+ root.dataset.prtsScheme = next
132
+ return next
133
+ }
134
+
62
135
  function sync(value) {
63
136
  if (disposed) return
64
- root.dataset.prtsScheme = resolveScheme(value)
137
+ const next = resolveScheme(value)
138
+ if (activeTransition) {
139
+ if (next === activeTransition.target) return root.dataset.prtsScheme
140
+ if (activeTransition.interactive && next === activeTransition.source) return root.dataset.prtsScheme
141
+ if (next !== root.dataset.prtsScheme) cancelActiveTransition()
142
+ }
143
+ return applyScheme(next)
65
144
  }
66
145
 
67
146
  function fallbackTheme() {
@@ -84,25 +163,537 @@ export function createThemeController({ document, window, cssText, service }) {
84
163
  return value
85
164
  }
86
165
 
87
- function setTheme(id) {
166
+ function prefersReducedMotion() {
167
+ if (root.dataset.prtsMotion === 'reduced') return true
168
+ try { return window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true } catch { return false }
169
+ }
170
+
171
+ function canAnimateScheme(options) {
172
+ const animationAvailable = typeof document.startViewTransition === 'function'
173
+ && typeof root.animate === 'function'
174
+ return options?.animate === true
175
+ && animationAvailable
176
+ && !prefersReducedMotion()
177
+ }
178
+
179
+ function transitionOrigin(origin) {
180
+ const width = Math.max(Number(window.innerWidth) || 0, Number(root.clientWidth) || 0)
181
+ const height = Math.max(Number(window.innerHeight) || 0, Number(root.clientHeight) || 0)
182
+ const clamp = (value, maximum, fallback) => Math.min(maximum, Math.max(0, Number.isFinite(Number(value)) ? Number(value) : fallback))
183
+ const x = clamp(origin?.x, width, width / 2)
184
+ const y = clamp(origin?.y, height, height / 2)
185
+ return { x, y }
186
+ }
187
+
188
+ function revealViewport(origin) {
189
+ const geometry = transitionOrigin(origin)
190
+ const width = Math.max(1, Number(window.innerWidth) || 0, Number(root.clientWidth) || 0)
191
+ const height = Math.max(1, Number(window.innerHeight) || 0, Number(root.clientHeight) || 0)
192
+ return { ...geometry, width, height }
193
+ }
194
+
195
+ function revealDuration(width) {
196
+ const limits = width <= 720 ? REVEAL_TIMING_LIMITS.mobile : REVEAL_TIMING_LIMITS.desktop
197
+ return Math.min(limits.maximum, Math.max(limits.minimum, Math.round(width * limits.widthFactor)))
198
+ }
199
+
200
+ function revealProgress(value) {
201
+ const numeric = Number(value)
202
+ if (!Number.isFinite(numeric)) return 0
203
+ return Math.min(1, Math.max(0, numeric))
204
+ }
205
+
206
+ function removeRevealHud(session) {
207
+ const hud = session?.hud
208
+ if (!hud) return
209
+ try {
210
+ if (hud.matches?.(':popover-open')) hud.hidePopover?.()
211
+ } catch {}
212
+ hud.remove()
213
+ session.hud = undefined
214
+ session.hudGrid = undefined
215
+ session.hudEdge = undefined
216
+ session.hudMark = undefined
217
+ session.hudMeterFill = undefined
218
+ session.hudLabel = undefined
219
+ session.hudValue = undefined
220
+ }
221
+
222
+ function updateRevealHud(session) {
223
+ if (!session || session.settled) return
224
+ const percentage = Math.round(revealProgress(session.progress) * 100)
225
+ if (session.hudEdge) session.hudEdge.dataset.prtsSchemeRevealSide = 'left'
226
+ root.style.setProperty('--prts-scheme-control-progress', String(session.progress))
227
+ root.style.setProperty('--prts-scheme-control-meter-height', `${revealProgress(session.progress) * 48}px`)
228
+ root.style.setProperty('--prts-scheme-control-value-image', revealValueImageUrl(percentage))
229
+ root.toggleAttribute(TRANSITION_ARMED_ATTRIBUTE, session.interactive && session.progress >= .5)
230
+ }
231
+
232
+ function releaseRevealHud(session) {
233
+ if (!session?.hud || session.settled) return Promise.resolve()
234
+ session.hud.dataset.prtsSchemeRevealState = 'complete'
235
+ root.setAttribute(TRANSITION_COMPLETE_ATTRIBUTE, '')
236
+ return new Promise(resolve => window.setTimeout(resolve, 100))
237
+ }
238
+
239
+ function mountRevealHud(session) {
240
+ if (session.settled || session.hud || !document.body) return
241
+ const hud = document.createElement('div')
242
+ hud.dataset.prtsSchemeReveal = session.target
243
+ hud.dataset.prtsSchemeRevealMode = session.interactive ? 'interactive' : 'automatic'
244
+ hud.setAttribute('aria-hidden', 'true')
245
+ hud.setAttribute('popover', 'manual')
246
+ const grid = document.createElement('i')
247
+ grid.dataset.prtsSchemeRevealGrid = ''
248
+ const edge = document.createElement('span')
249
+ edge.dataset.prtsSchemeRevealEdge = ''
250
+ const blend = document.createElement('i')
251
+ blend.dataset.prtsSchemeRevealBlend = ''
252
+ const node = document.createElement('span')
253
+ node.dataset.prtsSchemeRevealNode = ''
254
+ const meter = document.createElement('i')
255
+ meter.dataset.prtsSchemeRevealMeter = ''
256
+ const meterFill = document.createElement('i')
257
+ meterFill.dataset.prtsSchemeRevealMeterFill = ''
258
+ const label = document.createElement('span')
259
+ label.dataset.prtsSchemeRevealLabel = ''
260
+ label.textContent = 'CTRL'
261
+ const mark = document.createElement('i')
262
+ mark.dataset.prtsSchemeRevealMark = ''
263
+ const value = document.createElement('span')
264
+ value.dataset.prtsSchemeRevealValue = ''
265
+ meter.appendChild(meterFill)
266
+ node.append(meter, label, value, mark)
267
+ edge.append(grid, blend, node)
268
+ hud.appendChild(edge)
269
+ document.body.appendChild(hud)
270
+ session.hud = hud
271
+ session.hudGrid = grid
272
+ session.hudEdge = edge
273
+ session.hudMark = mark
274
+ session.hudMeterFill = meterFill
275
+ session.hudLabel = label
276
+ session.hudValue = value
277
+ updateRevealHud(session)
278
+ try { hud.showPopover?.() } catch {}
279
+ }
280
+
281
+ function revealTargetX(session, progress) {
282
+ if (progress === 0) return -REVEAL_EDGE_OVERSCAN
283
+ if (progress === 1) return session.geometry.width + REVEAL_EDGE_OVERSCAN
284
+ return progress * session.geometry.width
285
+ }
286
+
287
+ function revealPixels(value) {
288
+ const rounded = Math.round(value * 1000) / 1000
289
+ return `${Object.is(rounded, -0) ? 0 : rounded}px`
290
+ }
291
+
292
+ function revealClipPath(session, x) {
293
+ return `inset(0 ${revealPixels(Math.max(0, session.geometry.width - x))} 0 0)`
294
+ }
295
+
296
+ function revealEdgeTransform(x) {
297
+ return `translate3d(${revealPixels(x - 1)}, 0, 0)`
298
+ }
299
+
300
+ function revealGridPosition(x) {
301
+ return `${revealPixels(REVEAL_GRID_WIDTH - x)} 0px`
302
+ }
303
+
304
+ function revealGroupTranslate(x) {
305
+ return `${revealPixels(x)} 0px`
306
+ }
307
+
308
+ function revealNamedAnimation(keyframes, options, pseudoElement) {
309
+ return root.animate(keyframes, { ...options, pseudoElement })
310
+ }
311
+
312
+ function createInteractiveScrub(session) {
313
+ if (!session.interactive || session.settled || session.scrubAnimations) return
314
+ try {
315
+ const options = {
316
+ duration: session.duration,
317
+ easing: 'linear',
318
+ fill: 'both',
319
+ }
320
+ const clip = revealNamedAnimation([
321
+ { clipPath: revealClipPath(session, 0) },
322
+ { clipPath: revealClipPath(session, session.geometry.width) },
323
+ ], options, '::view-transition-new(root)')
324
+ const edge = revealNamedAnimation([
325
+ { translate: revealGroupTranslate(0) },
326
+ { translate: revealGroupTranslate(session.geometry.width) },
327
+ ], options, '::view-transition-group(prts-scheme-edge)')
328
+ const node = revealNamedAnimation([
329
+ { translate: revealGroupTranslate(0) },
330
+ { translate: revealGroupTranslate(session.geometry.width) },
331
+ ], options, '::view-transition-group(prts-scheme-node)')
332
+ const grid = revealNamedAnimation([
333
+ { translate: revealGroupTranslate(0) },
334
+ { translate: revealGroupTranslate(session.geometry.width) },
335
+ ], options, '::view-transition-group(prts-scheme-grid)')
336
+ const gridPhase = revealNamedAnimation([
337
+ { backgroundPosition: revealGridPosition(0) },
338
+ { backgroundPosition: revealGridPosition(session.geometry.width) },
339
+ ], options, '::view-transition-new(prts-scheme-grid)')
340
+ const mark = revealNamedAnimation([
341
+ { offset: 0, translate: revealGroupTranslate(0), rotate: '45deg' },
342
+ { offset: .49, translate: revealGroupTranslate(session.geometry.width * .49), rotate: '45deg' },
343
+ { offset: .51, translate: revealGroupTranslate(session.geometry.width * .51), rotate: '225deg' },
344
+ { offset: 1, translate: revealGroupTranslate(session.geometry.width), rotate: '225deg' },
345
+ ], options, '::view-transition-group(prts-scheme-mark)')
346
+ session.scrubAnimations = [clip, edge, node, grid, gridPhase, mark]
347
+ for (const animation of session.scrubAnimations) {
348
+ animation.pause?.()
349
+ animation.currentTime = session.progress * session.duration
350
+ }
351
+ } catch {}
352
+ }
353
+
354
+ function setRevealProgress(session, value) {
355
+ if (!session || session.settled) return
356
+ session.progress = revealProgress(value)
357
+ session.revealX = session.progress * session.geometry.width
358
+ root.style.setProperty('--prts-scheme-reveal-x', `${session.revealX}px`)
359
+ if (session.scrubAnimations) {
360
+ try {
361
+ for (const animation of session.scrubAnimations) {
362
+ animation.pause?.()
363
+ animation.currentTime = session.progress * session.duration
364
+ }
365
+ } catch {}
366
+ }
367
+ updateRevealHud(session)
368
+ }
369
+
370
+ function cancelRevealAnimation(session) {
371
+ const animations = session?.progressAnimations || []
372
+ session.progressAnimations = undefined
373
+ for (const animation of animations) {
374
+ try { animation?.cancel?.() } catch {}
375
+ }
376
+ for (const animation of session?.completedAnimations || []) {
377
+ try { animation?.cancel?.() } catch {}
378
+ }
379
+ if (session) session.completedAnimations = undefined
380
+ for (const animation of session?.scrubAnimations || []) {
381
+ try { animation?.cancel?.() } catch {}
382
+ }
383
+ if (session) session.scrubAnimations = undefined
384
+ }
385
+
386
+ function animateRevealTo(session, value, duration, easing) {
387
+ if (!session || session.settled) return Promise.resolve(false)
388
+ cancelRevealAnimation(session)
389
+ const targetProgress = revealProgress(value)
390
+ const targetX = revealTargetX(session, targetProgress)
391
+ if (!(duration > 0)) {
392
+ setRevealProgress(session, targetProgress)
393
+ return Promise.resolve(true)
394
+ }
395
+ let animations
396
+ try {
397
+ const options = {
398
+ duration: Math.round(duration),
399
+ easing,
400
+ fill: 'forwards',
401
+ }
402
+ const clip = root.animate([
403
+ { clipPath: revealClipPath(session, session.revealX) },
404
+ { clipPath: revealClipPath(session, targetX) },
405
+ ], {
406
+ ...options,
407
+ pseudoElement: '::view-transition-new(root)',
408
+ })
409
+ const edge = revealNamedAnimation([
410
+ { translate: revealGroupTranslate(session.revealX) },
411
+ { translate: revealGroupTranslate(targetX) },
412
+ ], options, '::view-transition-group(prts-scheme-edge)')
413
+ const node = revealNamedAnimation([
414
+ { translate: revealGroupTranslate(session.revealX) },
415
+ { translate: revealGroupTranslate(targetX) },
416
+ ], options, '::view-transition-group(prts-scheme-node)')
417
+ const grid = revealNamedAnimation([
418
+ { translate: revealGroupTranslate(session.revealX) },
419
+ { translate: revealGroupTranslate(targetX) },
420
+ ], options, '::view-transition-group(prts-scheme-grid)')
421
+ const gridPhase = revealNamedAnimation([
422
+ { backgroundPosition: revealGridPosition(session.revealX) },
423
+ { backgroundPosition: revealGridPosition(targetX) },
424
+ ], options, '::view-transition-new(prts-scheme-grid)')
425
+ const nodeProgress = revealNamedAnimation([
426
+ { backgroundSize: `2px ${session.progress * 48}px` },
427
+ { backgroundSize: `2px ${targetProgress * 48}px` },
428
+ ], options, '::view-transition-new(prts-scheme-node)')
429
+ const mark = revealNamedAnimation([
430
+ { translate: revealGroupTranslate(session.revealX), rotate: session.progress >= .5 ? '225deg' : '45deg' },
431
+ { translate: revealGroupTranslate(targetX), rotate: targetProgress >= .5 ? '225deg' : '45deg' },
432
+ ], options, '::view-transition-group(prts-scheme-mark)')
433
+ animations = [clip, edge, node, grid, gridPhase, nodeProgress, mark]
434
+ } catch {
435
+ setRevealProgress(session, targetProgress)
436
+ return Promise.resolve(true)
437
+ }
438
+ session.progressAnimations = animations
439
+ return Promise.all(animations.map(animation => Promise.resolve(animation?.finished).then(() => true, () => false))).then(results => {
440
+ const completed = results.every(Boolean)
441
+ if (!completed || session.settled || session.progressAnimations !== animations) return false
442
+ session.progressAnimations = undefined
443
+ session.completedAnimations = animations
444
+ session.progress = targetProgress
445
+ session.revealX = targetX
446
+ root.style.setProperty('--prts-scheme-reveal-x', `${session.revealX}px`)
447
+ updateRevealHud(session)
448
+ return true
449
+ })
450
+ }
451
+
452
+ function requestHostScheme(value) {
453
+ try {
454
+ return Promise.resolve(service?.setTheme?.(value)).then(() => true, () => false)
455
+ } catch {
456
+ return Promise.resolve(false)
457
+ }
458
+ }
459
+
460
+ function restoreRevealSource(session) {
461
+ if (!session) return Promise.resolve(false)
462
+ session.restoreRequested = true
463
+ if (session.restorePromise) return session.restorePromise
464
+ session.restorePromise = Promise.resolve(session.hostRequest).then(async accepted => {
465
+ if (accepted && !(await requestHostScheme(session.source))) return false
466
+ applyScheme(session.source)
467
+ session.hostAccepted = false
468
+ return true
469
+ })
470
+ return session.restorePromise
471
+ }
472
+
473
+ function finishRevealSession(session, { commit }) {
474
+ if (!session || session.settled) return
475
+ session.committed = commit
476
+ session.settled = true
477
+ cancelRevealAnimation(session)
478
+ if (!commit) applyScheme(session.source)
479
+ try { session.viewTransition?.skipTransition?.() } catch {}
480
+ removeRevealHud(session)
481
+ if (activeTransition?.token === session.token) activeTransition = undefined
482
+ clearTransitionState()
483
+ setTransitionState(false)
484
+ }
485
+
486
+ function createRevealSession(target, options = {}) {
487
+ cancelActiveTransition({ keepPaused: true })
488
+ setTransitionState(true)
489
+ const source = root.dataset.prtsScheme === 'dark' ? 'dark' : 'light'
490
+ const geometry = revealViewport(options.origin)
491
+ const interactive = options.interactive === true
492
+ const initialProgress = interactive ? revealProgress(options.progress) : 0
493
+ const duration = revealDuration(geometry.width)
494
+ const token = ++transitionRevision
495
+ let resolveHostReady
496
+ const session = {
497
+ token,
498
+ target,
499
+ source,
500
+ geometry,
501
+ interactive,
502
+ progress: initialProgress,
503
+ revealX: interactive ? initialProgress * geometry.width : -REVEAL_EDGE_OVERSCAN,
504
+ duration,
505
+ settled: false,
506
+ committed: false,
507
+ hostAccepted: false,
508
+ hostReady: new Promise(resolve => { resolveHostReady = resolve }),
509
+ }
510
+ root.style.setProperty('--prts-scheme-origin-x', `${geometry.x}px`)
511
+ root.style.setProperty('--prts-scheme-origin-y', `${geometry.y}px`)
512
+ root.style.setProperty('--prts-scheme-duration', `${interactive ? 3600000 : duration}ms`)
513
+ root.style.setProperty('--prts-scheme-reveal-x', `${session.revealX}px`)
514
+ root.style.setProperty('--prts-scheme-control-progress', String(initialProgress))
515
+ root.style.setProperty('--prts-scheme-control-meter-height', `${initialProgress * 48}px`)
516
+ root.style.setProperty('--prts-scheme-control-value-image', revealValueImageUrl(initialProgress * 100))
517
+ root.setAttribute(TRANSITION_ATTRIBUTE, target)
518
+ root.setAttribute(TRANSITION_MODE_ATTRIBUTE, 'view')
519
+ root.toggleAttribute(TRANSITION_INTERACTIVE_ATTRIBUTE, interactive)
520
+
521
+ session.cancel = () => {
522
+ if (session.settled) return
523
+ session.settled = true
524
+ cancelRevealAnimation(session)
525
+ if (session.interactive && !session.committed) void restoreRevealSource(session)
526
+ removeRevealHud(session)
527
+ }
528
+ activeTransition = session
529
+
530
+ let viewTransition
531
+ try {
532
+ viewTransition = document.startViewTransition(async () => {
533
+ session.hostRequest = requestHostScheme(target)
534
+ const accepted = await session.hostRequest
535
+ session.hostAccepted = accepted
536
+ if (!accepted) {
537
+ applyScheme(source)
538
+ resolveHostReady(false)
539
+ return
540
+ }
541
+ if (session.settled || session.restoreRequested) {
542
+ await restoreRevealSource(session)
543
+ resolveHostReady(false)
544
+ return
545
+ }
546
+ applyScheme(target)
547
+ mountRevealHud(session)
548
+ resolveHostReady(true)
549
+ })
550
+ } catch {
551
+ resolveHostReady(false)
552
+ if (activeTransition?.token === token) activeTransition = undefined
553
+ removeRevealHud(session)
554
+ clearTransitionState()
555
+ applyScheme(source)
556
+ setTransitionState(false)
557
+ return undefined
558
+ }
559
+ session.viewTransition = viewTransition
560
+ session.ready = Promise.all([
561
+ Promise.resolve(viewTransition?.ready).then(() => true, () => false),
562
+ session.hostReady,
563
+ ]).then(([viewReady, hostReady]) => {
564
+ const ready = viewReady && hostReady
565
+ if (ready && activeTransition?.token === token && !session.settled) {
566
+ createInteractiveScrub(session)
567
+ }
568
+ return ready
569
+ })
570
+ return session
571
+ }
572
+
573
+ async function commitRevealScheme(target, origin) {
574
+ const session = createRevealSession(target, { origin })
575
+ if (!session) return root.dataset.prtsScheme
576
+ const ready = await session.ready
577
+ if (!ready) {
578
+ if (!session.settled) finishRevealSession(session, { commit: false })
579
+ return session.source
580
+ }
581
+ if (activeTransition?.token !== session.token || session.settled) return root.dataset.prtsScheme
582
+ await animateRevealTo(session, 1, session.duration, 'cubic-bezier(.4, 0, .2, 1)')
583
+ await releaseRevealHud(session)
584
+ if (activeTransition?.token === session.token && !session.settled) finishRevealSession(session, { commit: true })
585
+ return target
586
+ }
587
+
588
+ function fallbackGesture(target, source) {
589
+ let progress = 0
590
+ let settled = false
591
+ return {
592
+ update(value) { if (!settled) progress = revealProgress(value) },
593
+ finish(commit = progress >= .5) {
594
+ if (settled) return root.dataset.prtsScheme
595
+ settled = true
596
+ return commit ? setTheme(target, { animate: false }) : source
597
+ },
598
+ cancel() {
599
+ if (settled) return source
600
+ settled = true
601
+ return source
602
+ },
603
+ }
604
+ }
605
+
606
+ function beginThemeTransition(id, options = {}) {
607
+ const target = id === 'dark' ? 'dark' : 'light'
608
+ const source = root.dataset.prtsScheme === 'dark' ? 'dark' : 'light'
609
+ if (disposed || target === source || !canAnimateScheme({ ...options, animate: true })) return fallbackGesture(target, source)
610
+ const session = createRevealSession(target, { ...options, interactive: true })
611
+ if (!session) return fallbackGesture(target, source)
612
+ let finishPromise
613
+
614
+ const finish = commit => {
615
+ if (finishPromise) return finishPromise
616
+ if (session.settled || activeTransition?.token !== session.token) return Promise.resolve(root.dataset.prtsScheme)
617
+ root.removeAttribute(TRANSITION_INTERACTIVE_ATTRIBUTE)
618
+ root.toggleAttribute(TRANSITION_ARMED_ATTRIBUTE, commit)
619
+ if (session.hud) session.hud.dataset.prtsSchemeRevealMode = 'settling'
620
+ const visualDuration = commit
621
+ ? Math.max(90, Math.round(session.duration * (1 - session.progress)))
622
+ : Math.max(90, Math.round(session.duration * session.progress * .72))
623
+ const visual = session.ready.then(ready => {
624
+ if (!ready || session.settled) return false
625
+ return animateRevealTo(
626
+ session,
627
+ commit ? 1 : 0,
628
+ visualDuration,
629
+ commit ? 'cubic-bezier(.22, 1, .36, 1)' : 'cubic-bezier(.4, 0, .2, 1)',
630
+ )
631
+ })
632
+
633
+ if (!commit) {
634
+ finishPromise = visual.then(async () => {
635
+ await releaseRevealHud(session)
636
+ await restoreRevealSource(session)
637
+ if (!session.settled) finishRevealSession(session, { commit: false })
638
+ return session.source
639
+ })
640
+ return finishPromise
641
+ }
642
+
643
+ finishPromise = visual.then(async () => {
644
+ if (!session.hostAccepted) {
645
+ if (!session.settled) finishRevealSession(session, { commit: false })
646
+ refresh()
647
+ return root.dataset.prtsScheme
648
+ }
649
+ await releaseRevealHud(session)
650
+ if (!session.settled) finishRevealSession(session, { commit: true })
651
+ return target
652
+ })
653
+ return finishPromise
654
+ }
655
+
656
+ return {
657
+ update(progress) {
658
+ if (session.settled || activeTransition?.token !== session.token) return
659
+ setRevealProgress(session, progress)
660
+ },
661
+ finish,
662
+ cancel() { return finish(false) },
663
+ }
664
+ }
665
+
666
+ function commitAnimatedScheme(target, origin) {
667
+ return commitRevealScheme(target, origin)
668
+ }
669
+
670
+ function setTheme(id, options = {}) {
88
671
  const target = id === 'dark' ? 'dark' : 'light'
89
672
  const revision = ++setRevision
90
- try {
91
- const result = service?.setTheme?.(target)
673
+ const animate = canAnimateScheme(options) && target !== root.dataset.prtsScheme
674
+
675
+ if (animate) return commitAnimatedScheme(target, options.origin)
676
+
677
+ const commit = () => {
678
+ if (disposed || revision !== setRevision) return root.dataset.prtsScheme
679
+ return applyScheme(target)
680
+ }
681
+ const fail = () => {
682
+ if (disposed || revision !== setRevision) return root.dataset.prtsScheme
683
+ const result = refresh()
92
684
  if (result && typeof result.then === 'function') {
93
- return result.then(() => {
94
- if (!disposed && revision === setRevision) sync(target)
95
- return target
96
- }).catch(() => {
97
- if (!disposed && revision === setRevision) refresh()
98
- return root.dataset.prtsScheme
99
- })
685
+ return result.then(() => root.dataset.prtsScheme)
100
686
  }
101
- if (!disposed && revision === setRevision) sync(target)
687
+ return root.dataset.prtsScheme
688
+ }
689
+
690
+ try {
691
+ const result = service?.setTheme?.(target)
692
+ if (result && typeof result.then === 'function') return result.then(commit).catch(fail)
693
+ return commit()
102
694
  } catch {
103
- if (!disposed && revision === setRevision) sync(target)
695
+ return fail()
104
696
  }
105
- return target
106
697
  }
107
698
 
108
699
  function toggle() {
@@ -115,6 +706,7 @@ export function createThemeController({ document, window, cssText, service }) {
115
706
  const preferences = normalizePreferences(value)
116
707
  ensureStyle()
117
708
  if (!preferences.enabled) {
709
+ cancelActiveTransition()
118
710
  clearThemeState()
119
711
  return
120
712
  }
@@ -128,6 +720,7 @@ export function createThemeController({ document, window, cssText, service }) {
128
720
  sync,
129
721
  refresh,
130
722
  setTheme,
723
+ beginThemeTransition,
131
724
  toggle,
132
725
  dispose: removeOwnedState,
133
726
  }