dsh-smooth-stream 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,672 @@
1
+ import { useLayoutEffect, useRef, type RefObject } from 'react'
2
+ import { FrameCoordinator } from './FrameCoordinator.ts'
3
+ import css from './LogarithmicFade.module.css'
4
+
5
+ export const FADE_DURATION_MS = 240
6
+ export const FADE_TAIL_SIZE = 24
7
+ export const FADE_MAX_TAIL_SIZE = 160
8
+ export const FADE_MIN_OPACITY = 0
9
+ const FADE_STEPS = 32
10
+ const PREFIX = 'dsh-smooth-stream-log-fade-'
11
+ const COLOR_PROPERTY = '--dsh-smooth-stream-fade-color'
12
+ // Lightning CSS scopes highlight identifiers as well as class names.
13
+ const highlightName = (index: number): string => css[`${PREFIX}${index}`] ?? `${PREFIX}${index}`
14
+ const EXCLUDED = 'pre,code,math,.katex,.katex-display,mjx-container,svg,script,style,textarea,input,button,select,[role="button"],[contenteditable],[hidden],[aria-hidden="true"],[aria-live]'
15
+ // Attributes that can start or stop an EXCLUDED match mid-stream (`hidden` and
16
+ // `aria-hidden` flip whole subtrees, `class` carries the math renderers,
17
+ // `role`/`contenteditable` match their own selectors). `style` is deliberately
18
+ // absent: every colour write below is a style property, so observing it would
19
+ // make the fade re-enter its own observer.
20
+ const EXCLUDED_ATTRIBUTES = ['hidden', 'aria-hidden', 'role', 'contenteditable', 'class']
21
+ // Attributes on <html>/<body> that can move the resolved `currentColor`, i.e.
22
+ // an app theme switch that is not delivered through the media query.
23
+ const APPEARANCE_ATTRIBUTES = ['class', 'style', 'data-theme', 'data-color-scheme', 'data-appearance']
24
+
25
+ export function logarithmicOpacity(progress: number): number {
26
+ const p = Number.isFinite(progress) ? Math.max(0, Math.min(1, progress)) : 1
27
+ // Reverse the log easing: preserve translucency early, then settle to ink.
28
+ return FADE_MIN_OPACITY + (1 - FADE_MIN_OPACITY) * (1 - Math.log1p(5 * (1 - p)) / Math.log(6))
29
+ }
30
+
31
+ export function fadeTailSize(speedCps: number): number {
32
+ const speed = Number.isFinite(speedCps) ? Math.max(0, speedCps) : 0
33
+ return Math.min(FADE_MAX_TAIL_SIZE, Math.max(FADE_TAIL_SIZE, Math.ceil(speed * FADE_DURATION_MS / 1000)))
34
+ }
35
+
36
+ interface FadeCharacter {
37
+ start: number
38
+ end: number
39
+ born: number
40
+ range: Range
41
+ bucket: number
42
+ }
43
+
44
+ /** One text node of the fade root with its span in the concatenated source text. */
45
+ interface TextNodeEntry {
46
+ node: Text
47
+ start: number
48
+ end: number
49
+ eligible: boolean
50
+ }
51
+
52
+ interface PreservedColor {
53
+ value: string
54
+ priority: string
55
+ /** Reconcile pass that last wanted this element, so stale entries can be pruned. */
56
+ generation: number
57
+ }
58
+
59
+ interface Scheduler {
60
+ highlights: Highlight[]
61
+ clients: Set<LogarithmicFadeController>
62
+ pending: Set<LogarithmicFadeController>
63
+ /** Task handle on the shared frame clock; non-null while a client is live. */
64
+ taskId: string | null
65
+ registry: HighlightRegistry
66
+ window: Window
67
+ coordinator: FrameCoordinator
68
+ /** Detaches the document-level appearance watcher when the last client goes. */
69
+ unwatchAppearance: () => void
70
+ }
71
+
72
+ const schedulers = new WeakMap<Document, Scheduler>()
73
+ const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
74
+
75
+ /**
76
+ * Hot-path counters for the streaming benchmark. Increments only — this is the
77
+ * evidence that the per-frame pass count collapsed, not a control input.
78
+ */
79
+ export const fadeHotPathStats = {
80
+ /** Observer batches that reached reconcile(). */
81
+ reconcileCalls: 0,
82
+ /** Reconciles that did real work (the rest are dirty-check no-ops). */
83
+ reconcilePasses: 0,
84
+ /** Full text-node table rebuilds (structural DOM change). */
85
+ nodeScans: 0,
86
+ /** Forced style resolutions for newly faded elements. */
87
+ styleReads: 0,
88
+ }
89
+
90
+ export function resetFadeHotPathStats(): void {
91
+ fadeHotPathStats.reconcileCalls = 0
92
+ fadeHotPathStats.reconcilePasses = 0
93
+ fadeHotPathStats.nodeScans = 0
94
+ fadeHotPathStats.styleReads = 0
95
+ }
96
+
97
+ function schedule(scheduler: Scheduler): void {
98
+ if (scheduler.taskId !== null || scheduler.pending.size === 0) return
99
+ // One fade task per document on the shared clock, so the fade's bucket
100
+ // bookkeeping shares the reveal/follow frame instead of racing it.
101
+ scheduler.taskId = scheduler.coordinator.registerTask({
102
+ onSimulate: (_dtMs, now) => {
103
+ for (const client of scheduler.pending) {
104
+ if (!client.paint(now)) scheduler.pending.delete(client)
105
+ }
106
+ if (scheduler.pending.size === 0) {
107
+ scheduler.taskId = null
108
+ return false
109
+ }
110
+ return true
111
+ },
112
+ })
113
+ }
114
+
115
+ /**
116
+ * Whether an attribute record actually moved the attribute's value. jsdom (and
117
+ * some engines) report a write even when the serialized value is unchanged, and
118
+ * this controller writes the scope class on every enable/disable transition —
119
+ * counting a no-op record as a DOM change would re-enter the observer forever.
120
+ * Requires `attributeOldValue` on the observing call.
121
+ * @param record - attribute mutation record from either observer.
122
+ * @returns true only when the value differs from the recorded old value.
123
+ */
124
+ function attributeChanged(record: MutationRecord): boolean {
125
+ const attribute = record.attributeName
126
+ if (attribute === null) return true
127
+ return record.oldValue !== (record.target as Element).getAttribute(attribute)
128
+ }
129
+
130
+ /**
131
+ * One appearance watcher per document. A theme switch arrives either as an OS
132
+ * media-query flip (the app's default `preference: system`) or as an attribute
133
+ * write on <html>/<body>, so both are observed; the fade only ever writes
134
+ * custom properties on its own root, never on those elements, so this watcher
135
+ * cannot observe the controller's own output.
136
+ * @param scheduler - document-scoped registry notified when the look changes.
137
+ * @returns teardown for the last client's dispose.
138
+ */
139
+ function watchAppearance(scheduler: Scheduler): () => void {
140
+ // The scheduler already resolved the root's realm; re-state it so the DOM
141
+ // constructors resolve off that window rather than the ambient global.
142
+ const win = scheduler.window as Window & typeof globalThis
143
+ const doc = win.document
144
+ const invalidate = (): void => {
145
+ for (const client of scheduler.clients) client.invalidateColors()
146
+ }
147
+ const observer = new win.MutationObserver((records) => {
148
+ if (records.some(attributeChanged)) invalidate()
149
+ })
150
+ const watched = doc.body === null ? [doc.documentElement] : [doc.documentElement, doc.body]
151
+ for (const element of watched) {
152
+ observer.observe(element, {
153
+ attributes: true,
154
+ attributeFilter: APPEARANCE_ATTRIBUTES,
155
+ attributeOldValue: true,
156
+ })
157
+ }
158
+ const query = typeof win.matchMedia === 'function'
159
+ ? win.matchMedia('(prefers-color-scheme: dark)')
160
+ : null
161
+ if (query !== null) query.addEventListener('change', invalidate)
162
+ return () => {
163
+ observer.disconnect()
164
+ query?.removeEventListener('change', invalidate)
165
+ }
166
+ }
167
+
168
+ function schedulerFor(root: HTMLElement): Scheduler | null {
169
+ const doc = root.ownerDocument
170
+ const win = doc.defaultView
171
+ if (win === null) return null
172
+ // Use the root's realm, including when a renderer lives in another document.
173
+ const realm = win as Window & typeof globalThis
174
+ if (typeof realm.Highlight !== 'function' || !realm.CSS?.highlights
175
+ || !realm.CSS.supports('color', 'color-mix(in srgb, currentColor 15%, transparent)')) return null
176
+ let scheduler = schedulers.get(doc)
177
+ if (scheduler === undefined) {
178
+ const highlights = Array.from({ length: FADE_STEPS }, () => new realm.Highlight())
179
+ for (const [index, highlight] of highlights.entries()) realm.CSS.highlights.set(highlightName(index), highlight)
180
+ scheduler = {
181
+ highlights,
182
+ clients: new Set(),
183
+ pending: new Set(),
184
+ taskId: null,
185
+ registry: realm.CSS.highlights,
186
+ window: win,
187
+ coordinator: FrameCoordinator.forDocument(doc),
188
+ unwatchAppearance: () => {},
189
+ }
190
+ scheduler.unwatchAppearance = watchAppearance(scheduler)
191
+ schedulers.set(doc, scheduler)
192
+ }
193
+ return scheduler
194
+ }
195
+
196
+ /**
197
+ * Owns ranges only: React retains ownership of every element and Text node.
198
+ *
199
+ * The controller used to re-derive its whole view of the subtree (textContent,
200
+ * prefix walk, full TreeWalker, an O(tail x #nodes) filter) on *every* commit
201
+ * AND on every observer batch for that same commit. It now keeps an
202
+ * incremental text-node table, reconciles at most once per observed DOM
203
+ * change, and preserves the per-element colour memo across passes.
204
+ */
205
+ export class LogarithmicFadeController {
206
+ private previous = ''
207
+ private characters: FadeCharacter[] = []
208
+ private colors = new Map<HTMLElement, PreservedColor>()
209
+ private enabled = false
210
+ private active = false
211
+ private speedCps = 100
212
+ private pausedAt: number | null = null
213
+ private disposed = false
214
+ private readonly observer: MutationObserver
215
+ /** Text nodes of the root with their source offsets; survives data-only edits. */
216
+ private nodes: TextNodeEntry[] = []
217
+ /** Ineligible nodes in nodes[0..index): a span is fadeable when its bounds match. */
218
+ private ineligiblePrefix: number[] = [0]
219
+ /**
220
+ * `closest(EXCLUDED)` derived once per element instead of once per Text node:
221
+ * exclusion is inherited from the ancestor chain, so a memo stays usable only
222
+ * while the element keeps its own parent. Moving a subtree into `code` — or
223
+ * into anything else that matches — invalidates exactly the moved chain the
224
+ * next time it is read.
225
+ */
226
+ private excludedElements = new WeakMap<Element, { parent: Element | null, excluded: boolean }>()
227
+ /** Own tag/attribute match per element; dropped when an exclusion attribute moves. */
228
+ private ownExcluded = new WeakMap<Element, boolean>()
229
+ /** Bumped by every observed mutation; reconcile() no-ops while it does not move. */
230
+ private domRevision = 0
231
+ /** Bumped only by structural (childList) mutations; the node table is rebuilt then. */
232
+ private structureRevision = 0
233
+ private scannedStructureRevision = -1
234
+ private reconciledRevision = -1
235
+ private reconciledEnabled = false
236
+ private reconciledActive = false
237
+ private reconciledPaused = false
238
+ private reconciledTailSize = 0
239
+ private colorGeneration = 0
240
+
241
+ private constructor(private readonly root: HTMLElement, private readonly scheduler: Scheduler) {
242
+ scheduler.clients.add(this)
243
+ const win = root.ownerDocument.defaultView as Window & typeof globalThis
244
+ // The observer is the single invalidation source. It watches structure,
245
+ // text and only the exclusion-relevant attributes; every colour write below
246
+ // is a `style` property, which stays unobserved, so it cannot re-enter.
247
+ this.observer = new win.MutationObserver((records) => { this.onDomMutation(records) })
248
+ this.observer.observe(root, {
249
+ subtree: true,
250
+ childList: true,
251
+ characterData: true,
252
+ attributes: true,
253
+ attributeFilter: EXCLUDED_ATTRIBUTES,
254
+ attributeOldValue: true,
255
+ })
256
+ }
257
+
258
+ static create(root: HTMLElement): LogarithmicFadeController | null {
259
+ const scheduler = schedulerFor(root)
260
+ return scheduler === null ? null : new LogarithmicFadeController(root, scheduler)
261
+ }
262
+
263
+ update(enabled: boolean, active: boolean, speedCps = 100, paused = false): void {
264
+ const now = this.scheduler.window.performance.now()
265
+ if (paused && this.pausedAt === null) this.pausedAt = now
266
+ if (!paused && this.pausedAt !== null) {
267
+ const pauseDuration = now - this.pausedAt
268
+ for (const character of this.characters) character.born += pauseDuration
269
+ this.pausedAt = null
270
+ }
271
+ this.enabled = enabled
272
+ this.active = active
273
+ this.speedCps = speedCps
274
+ this.reconcile()
275
+ }
276
+
277
+ /** Invalidate and reconcile from one observer batch (one batch per commit). */
278
+ private onDomMutation(records: MutationRecord[]): void {
279
+ let changed = false
280
+ let exclusionAttributesMoved = false
281
+ for (const record of records) {
282
+ if (record.type === 'childList') {
283
+ this.structureRevision += 1
284
+ changed = true
285
+ continue
286
+ }
287
+ if (record.type === 'characterData') {
288
+ changed = true
289
+ continue
290
+ }
291
+ // Only EXCLUDED_ATTRIBUTES reach this branch, and only value changes
292
+ // count: this controller writes its own scope class below, which must not
293
+ // be mistaken for a DOM change (it re-renders the same token list).
294
+ if (!attributeChanged(record)) continue
295
+ exclusionAttributesMoved = true
296
+ changed = true
297
+ }
298
+ // A batch made only of this controller's own no-op writes is not a DOM
299
+ // change. Reconciling on it would re-add the scope class, whose record is
300
+ // another such batch, and the observer would re-enter itself forever.
301
+ if (!changed) return
302
+ if (exclusionAttributesMoved) {
303
+ // `hidden`/`aria-hidden`/`class` on an ancestor decides the eligibility of
304
+ // every descendant Text node, so the whole memo is stale — not just the
305
+ // mutation target — and the next pass has to re-derive the table.
306
+ this.excludedElements = new WeakMap()
307
+ this.ownExcluded = new WeakMap()
308
+ this.structureRevision += 1
309
+ }
310
+ this.domRevision += 1
311
+ this.reconcile()
312
+ }
313
+
314
+ /**
315
+ * Whether this element matches a SKIP rule itself or inherits one from an
316
+ * ancestor — the memoised form of `element.closest(EXCLUDED) !== null`.
317
+ * Memoised per element and revalidated through the parent chain, so a node
318
+ * that moves into `code` — or whose ancestor does — is re-decided without
319
+ * paying the selector walk once per Text node per pass.
320
+ * @param element - element owning the Text node being classified.
321
+ * @returns true when text under this element must stay out of the fade.
322
+ */
323
+ private elementExcluded(element: Element): boolean {
324
+ const cached = this.excludedElements.get(element)
325
+ if (cached !== undefined && cached.parent === element.parentElement) return cached.excluded
326
+ const parent = element.parentElement
327
+ const inherited = parent === null ? false : this.elementExcluded(parent)
328
+ let own = this.ownExcluded.get(element)
329
+ if (own === undefined) {
330
+ own = element.matches(EXCLUDED)
331
+ this.ownExcluded.set(element, own)
332
+ }
333
+ const excluded = inherited || own
334
+ this.excludedElements.set(element, { parent, excluded })
335
+ return excluded
336
+ }
337
+
338
+ /** Rebuild the text-node table after a structural change. */
339
+ private scanNodes(): void {
340
+ const nodes: TextNodeEntry[] = []
341
+ const walker = this.root.ownerDocument.createTreeWalker(this.root, NodeFilter.SHOW_TEXT)
342
+ for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) {
343
+ const text = node as Text
344
+ const parent = text.parentElement
345
+ // Derived through the parent element, never cached on Text identity: a
346
+ // node that was moved keeps its identity, so a Text-keyed memo would keep
347
+ // answering for the tree it used to live in.
348
+ const eligible = parent !== null && !this.elementExcluded(parent)
349
+ nodes.push({ node: text, start: 0, end: 0, eligible })
350
+ }
351
+ this.nodes = nodes
352
+ this.scannedStructureRevision = this.structureRevision
353
+ fadeHotPathStats.nodeScans += 1
354
+ }
355
+
356
+ /**
357
+ * Re-derive the source text, the per-node spans and the eligibility prefix
358
+ * sums from the cached table. No DOM read, no selector match, no allocation
359
+ * beyond the string itself.
360
+ */
361
+ private readText(): string {
362
+ if (this.scannedStructureRevision !== this.structureRevision) this.scanNodes()
363
+ // The commit-driven pass runs before the observer microtask for the same
364
+ // commit, so a replaced text node can still be in the table. A removed
365
+ // node keeps its data, which would silently hand a detached node to the
366
+ // Range and colour code, so re-scan on the first eviction.
367
+ for (const entry of this.nodes) {
368
+ if (entry.node.parentNode !== null) continue
369
+ this.scanNodes()
370
+ break
371
+ }
372
+ const nodes = this.nodes
373
+ const prefix = this.ineligiblePrefix
374
+ if (prefix.length < nodes.length + 1) prefix.length = nodes.length + 1
375
+ prefix[0] = 0
376
+ let text = ''
377
+ let offset = 0
378
+ for (let index = 0; index < nodes.length; index += 1) {
379
+ const entry = nodes[index]!
380
+ const data = entry.node.data
381
+ entry.start = offset
382
+ offset += data.length
383
+ entry.end = offset
384
+ text += data
385
+ prefix[index + 1] = prefix[index]! + (entry.eligible ? 0 : 1)
386
+ }
387
+ prefix.length = nodes.length + 1
388
+ this.ineligiblePrefix = prefix
389
+ return text
390
+ }
391
+
392
+ /** First cached node whose span ends after `offset` (binary search). */
393
+ private firstNodeEndingAfter(offset: number): number {
394
+ const nodes = this.nodes
395
+ let low = 0
396
+ let high = nodes.length
397
+ while (low < high) {
398
+ const mid = (low + high) >> 1
399
+ if (nodes[mid]!.end > offset) high = mid
400
+ else low = mid + 1
401
+ }
402
+ return low
403
+ }
404
+
405
+ /** Last cached node whose span starts before `offset` (binary search). */
406
+ private lastNodeStartingBefore(offset: number): number {
407
+ const nodes = this.nodes
408
+ let low = -1
409
+ let high = nodes.length - 1
410
+ while (low < high) {
411
+ const mid = (low + high + 1) >> 1
412
+ if (nodes[mid]!.start < offset) low = mid
413
+ else high = mid - 1
414
+ }
415
+ return low
416
+ }
417
+
418
+ private clearRanges(): void {
419
+ for (const character of this.characters) {
420
+ this.scheduler.highlights[character.bucket]?.delete(character.range)
421
+ }
422
+ this.characters = []
423
+ }
424
+
425
+ private restoreColor(element: HTMLElement, preserved: PreservedColor): void {
426
+ if (preserved.value === '') element.style.removeProperty(COLOR_PROPERTY)
427
+ else element.style.setProperty(COLOR_PROPERTY, preserved.value, preserved.priority)
428
+ }
429
+
430
+ private rootColorSet = false
431
+
432
+ private ensureRootColor(): void {
433
+ if (this.rootColorSet) return
434
+ const win = this.scheduler.window
435
+ const color = win.getComputedStyle(this.root).color || 'currentColor'
436
+ this.root.style.setProperty(COLOR_PROPERTY, color)
437
+ this.rootColorSet = true
438
+ }
439
+
440
+ private restoreColors(): void {
441
+ if (this.rootColorSet) {
442
+ this.root.style.removeProperty(COLOR_PROPERTY)
443
+ this.rootColorSet = false
444
+ }
445
+ for (const [element, preserved] of this.colors) this.restoreColor(element, preserved)
446
+ this.colors.clear()
447
+ }
448
+
449
+ /**
450
+ * The document's appearance moved (theme switch, restyle): the captured ink
451
+ * colour is stale, so drop it and let the next pass read the new one. Costs
452
+ * nothing while no colour is captured, which is the common case — the read
453
+ * still happens only when text is actually fading.
454
+ */
455
+ invalidateColors(): void {
456
+ if (this.disposed || (!this.rootColorSet && this.colors.size === 0)) return
457
+ this.restoreColors()
458
+ // Force the next reconcile past its dirty check; it re-reads the colour and
459
+ // re-cuts the live tail, so the characters already fading recolour in place
460
+ // instead of finishing in the previous theme's ink.
461
+ this.domRevision += 1
462
+ this.reconcile()
463
+ }
464
+
465
+
466
+ /**
467
+ * Refresh the fade ranges. Two triggers used to run this 2-3x per frame with
468
+ * no dirty check: the commit-driven `update()` and the observer batch for the
469
+ * very same DOM write. Both converge here, and the pass is skipped unless the
470
+ * DOM revision, the gate, the fade window or the pause state actually moved.
471
+ */
472
+ private reconcile(): void {
473
+ if (this.disposed) return
474
+ fadeHotPathStats.reconcileCalls += 1
475
+ const tailSize = fadeTailSize(this.speedCps)
476
+ const paused = this.pausedAt !== null
477
+ const domChanged = this.domRevision !== this.reconciledRevision
478
+ // The window only matters while characters are still fading: an idle
479
+ // controller has nothing to re-cut, and the next append reconciles anyway.
480
+ const paramsChanged = this.enabled !== this.reconciledEnabled
481
+ || this.active !== this.reconciledActive
482
+ || paused !== this.reconciledPaused
483
+ || (tailSize !== this.reconciledTailSize && this.characters.length > 0)
484
+ if (!domChanged && !paramsChanged) return
485
+ fadeHotPathStats.reconcilePasses += 1
486
+ this.reconciledRevision = this.domRevision
487
+ this.reconciledEnabled = this.enabled
488
+ this.reconciledActive = this.active
489
+ this.reconciledPaused = paused
490
+ this.reconciledTailSize = tailSize
491
+
492
+ const text = this.readText()
493
+ const previous = this.previous
494
+ this.previous = text
495
+ const old = this.characters
496
+ this.clearRanges()
497
+ if (!this.enabled) {
498
+ this.root.classList.remove(css.scope!)
499
+ this.restoreColors()
500
+ this.scheduler.pending.delete(this)
501
+ this.stopIfIdle()
502
+ return
503
+ }
504
+ this.root.classList.add(css.scope!)
505
+ this.ensureRootColor()
506
+ const now = this.pausedAt ?? this.scheduler.window.performance.now()
507
+ const appended = text.startsWith(previous)
508
+ let prefix = appended ? previous.length : 0
509
+ if (!appended) {
510
+ while (prefix < previous.length && prefix < text.length && previous[prefix] === text[prefix]) prefix += 1
511
+ }
512
+ const nodes = this.nodes
513
+ const ineligible = this.ineligiblePrefix
514
+ // Keep existing characters alive when the engine resets its speed during
515
+ // completion. A shrinking window must not abruptly darken the old tail.
516
+ const oldestLiveStart = old.reduce((start, character) => (
517
+ now - character.born < FADE_DURATION_MS && character.end <= prefix
518
+ ? Math.min(start, character.start)
519
+ : start
520
+ ), Infinity)
521
+ const retainedBySpan = new Map<string, FadeCharacter>()
522
+ for (const character of old) retainedBySpan.set(`${String(character.start)}:${String(character.end)}`, character)
523
+ this.colorGeneration += 1
524
+
525
+ // Windowed segmentation: fade only affects the active tail. Windowing to the
526
+ // safe boundary keeps Intl.Segmenter at O(1) constant time (<0.1ms) even for
527
+ // long texts with tens of thousands of characters.
528
+ const windowStart = Math.max(
529
+ 0,
530
+ Math.min(
531
+ text.length - FADE_MAX_TAIL_SIZE * 2,
532
+ oldestLiveStart < Infinity ? oldestLiveStart : text.length,
533
+ ),
534
+ )
535
+ const tailText = windowStart > 0 ? text.slice(windowStart) : text
536
+ const segments = segmenter.segment(tailText)
537
+
538
+ let end = text.length
539
+ for (let count = 0; count < FADE_MAX_TAIL_SIZE && end > 0 && (count < tailSize || end > oldestLiveStart); count += 1) {
540
+ const localEnd = end - windowStart
541
+ if (localEnd <= 0) break
542
+ const segment = segments.containing(localEnd - 1)
543
+ if (segment === undefined) break
544
+ const start = segment.index + windowStart
545
+ const retained = end <= prefix
546
+ ? retainedBySpan.get(`${String(start)}:${String(end)}`)
547
+ : undefined
548
+ const born = retained?.born ?? (this.active && appended && start >= previous.length ? now : null)
549
+ if (born !== null && now - born < FADE_DURATION_MS && segment.segment.trim() !== '') {
550
+ // One binary-searched node span replaces the former O(tail x #nodes)
551
+ // filter; the prefix sums answer "is every part eligible" in O(1).
552
+ const first = this.firstNodeEndingAfter(start)
553
+ const last = this.lastNodeStartingBefore(end)
554
+ if (last >= first && first < nodes.length && nodes[last]!.end > start
555
+ && ineligible[last + 1] === ineligible[first]) {
556
+ const firstPart = nodes[first]!
557
+ const lastPart = nodes[last]!
558
+ const range = this.root.ownerDocument.createRange()
559
+ range.setStart(firstPart.node, start - firstPart.start)
560
+ range.setEnd(lastPart.node, end - lastPart.start)
561
+ this.characters.push({ start, end, born, range, bucket: -1 })
562
+ }
563
+ }
564
+ end = start
565
+ }
566
+ if (this.paint(now)) {
567
+ if (this.pausedAt === null) {
568
+ this.scheduler.pending.add(this)
569
+ schedule(this.scheduler)
570
+ } else {
571
+ this.scheduler.pending.delete(this)
572
+ this.stopIfIdle()
573
+ }
574
+ } else {
575
+ this.scheduler.pending.delete(this)
576
+ this.stopIfIdle()
577
+ }
578
+ }
579
+
580
+ paint(now: number): boolean {
581
+ this.characters = this.characters.filter((character) => {
582
+ const progress = (now - character.born) / FADE_DURATION_MS
583
+ if (progress >= 1 || !this.root.isConnected || !this.root.contains(character.range.startContainer)) {
584
+ this.scheduler.highlights[character.bucket]?.delete(character.range)
585
+ return false
586
+ }
587
+ const bucket = Math.min(FADE_STEPS - 1, Math.round((logarithmicOpacity(progress) - FADE_MIN_OPACITY) / (1 - FADE_MIN_OPACITY) * (FADE_STEPS - 1)))
588
+ if (bucket !== character.bucket) {
589
+ this.scheduler.highlights[character.bucket]?.delete(character.range)
590
+ this.scheduler.highlights[bucket]!.add(character.range)
591
+ character.bucket = bucket
592
+ }
593
+ return true
594
+ })
595
+ if (this.characters.length === 0) this.restoreColors()
596
+ return this.characters.length > 0
597
+ }
598
+
599
+ private stopIfIdle(): void {
600
+ if (this.scheduler.pending.size !== 0) return
601
+ if (this.scheduler.taskId === null) return
602
+ this.scheduler.coordinator.unregisterTask(this.scheduler.taskId)
603
+ this.scheduler.taskId = null
604
+ }
605
+
606
+ dispose(): void {
607
+ if (this.disposed) return
608
+ this.disposed = true
609
+ this.observer.disconnect()
610
+ this.clearRanges()
611
+ this.restoreColors()
612
+ this.nodes = []
613
+ this.scannedStructureRevision = -1
614
+ this.root.classList.remove(css.scope!)
615
+ this.scheduler.pending.delete(this)
616
+ this.scheduler.clients.delete(this)
617
+ this.stopIfIdle()
618
+ if (this.scheduler.clients.size === 0) {
619
+ for (const [index, highlight] of this.scheduler.highlights.entries()) {
620
+ const name = highlightName(index)
621
+ if (this.scheduler.registry.get(name) === highlight) this.scheduler.registry.delete(name)
622
+ }
623
+ // "Fade off" must cost nothing: no observer, no frame seat, no media
624
+ // listener left behind for a document that no longer fades.
625
+ this.scheduler.unwatchAppearance()
626
+ schedulers.delete(this.root.ownerDocument)
627
+ }
628
+ }
629
+ }
630
+
631
+ /** active admits new characters; enabled=false also cancels completion linger. */
632
+ export function useLogarithmicFade(
633
+ rootRef: RefObject<HTMLElement | null>,
634
+ enabled: boolean,
635
+ active: boolean,
636
+ speedCpsRef?: { current: number },
637
+ paused = false,
638
+ ): void {
639
+ const controller = useRef<LogarithmicFadeController | null>(null)
640
+ const committed = useRef(false)
641
+ useLayoutEffect(() => {
642
+ return () => {
643
+ controller.current?.dispose()
644
+ controller.current = null
645
+ }
646
+ }, [rootRef])
647
+ // Deliberately commit-driven, including Markdown updates with unchanged
648
+ // source: React may swap text nodes for identical text, and the controller's
649
+ // observer cannot report that before the commit's layout effects. The
650
+ // controller itself drops the redundant half of those passes now.
651
+ useLayoutEffect(() => {
652
+ const root = rootRef.current
653
+ if (!enabled) {
654
+ // "Fade off" must cost nothing: no observer, no reconcile, no rAF seat,
655
+ // no leftover scope class or colour property.
656
+ if (controller.current !== null) {
657
+ controller.current.dispose()
658
+ controller.current = null
659
+ }
660
+ committed.current = true
661
+ return
662
+ }
663
+ // Settled history allocates neither observers nor highlight buckets.
664
+ if (controller.current === null && root !== null && active) {
665
+ controller.current = LogarithmicFadeController.create(root)
666
+ // Enabling midway through a message must not replay readable text.
667
+ if (committed.current) controller.current?.update(false, false)
668
+ }
669
+ controller.current?.update(enabled, active, speedCpsRef?.current, paused)
670
+ committed.current = true
671
+ })
672
+ }