dsh-smooth-stream 0.1.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,468 @@
1
+ /**
2
+ * Stream-smoothing reveal hook.
3
+ *
4
+ * Buffers the model's chunked text and reveals it at a cadence that tracks
5
+ * the observed arrival rate, so a long reply never dumps whole paragraphs at
6
+ * once and a fast stream never stutters. Port of lobe-ui's smoother: EMA
7
+ * arrival cps + chunk size, backlog pressure, commit-interval widening with
8
+ * tail length, and a flush-speed settle drain once the input idles. The
9
+ * reveal decision is the pure {@link computeRevealStep} for unit tests.
10
+ *
11
+ * `shouldHoldBack` is the performance guard's veto: while it returns true the
12
+ * loop keeps measuring but skips the DOM commit, so an offscreen reply never
13
+ * competes with visible frames when the frame rate is degraded.
14
+ */
15
+
16
+ import { useCallback, useEffect, useRef, useState } from 'react'
17
+
18
+ export type StreamSmoothingPreset = 'realtime' | 'balanced' | 'silky'
19
+
20
+ export interface StreamSmoothingPresetConfig {
21
+ readonly activeInputWindowMs: number
22
+ readonly defaultCps: number
23
+ readonly emaAlpha: number
24
+ readonly flushCps: number
25
+ readonly largeAppendChars: number
26
+ readonly maxActiveCps: number
27
+ readonly maxCps: number
28
+ readonly maxFlushCps: number
29
+ readonly minCommitIntervalMs: number
30
+ readonly minCps: number
31
+ readonly settleAfterMs: number
32
+ readonly settleDrainMaxMs: number
33
+ readonly settleDrainMinMs: number
34
+ readonly targetBufferMs: number
35
+ }
36
+
37
+ export const PRESET_CONFIG: Record<StreamSmoothingPreset, StreamSmoothingPresetConfig> = {
38
+ balanced: {
39
+ activeInputWindowMs: 220,
40
+ defaultCps: 80,
41
+ emaAlpha: 0.35,
42
+ flushCps: 180,
43
+ largeAppendChars: 120,
44
+ maxActiveCps: 360,
45
+ maxCps: 240,
46
+ maxFlushCps: 480,
47
+ minCommitIntervalMs: 16,
48
+ minCps: 24,
49
+ settleAfterMs: 280,
50
+ settleDrainMaxMs: 420,
51
+ settleDrainMinMs: 120,
52
+ targetBufferMs: 40,
53
+ },
54
+ realtime: {
55
+ activeInputWindowMs: 140,
56
+ defaultCps: 120,
57
+ emaAlpha: 0.45,
58
+ flushCps: 240,
59
+ largeAppendChars: 180,
60
+ maxActiveCps: 480,
61
+ maxCps: 320,
62
+ maxFlushCps: 640,
63
+ minCommitIntervalMs: 16,
64
+ minCps: 32,
65
+ settleAfterMs: 200,
66
+ settleDrainMaxMs: 280,
67
+ settleDrainMinMs: 100,
68
+ targetBufferMs: 24,
69
+ },
70
+ silky: {
71
+ activeInputWindowMs: 280,
72
+ defaultCps: 64,
73
+ emaAlpha: 0.28,
74
+ flushCps: 140,
75
+ largeAppendChars: 100,
76
+ maxActiveCps: 280,
77
+ maxCps: 180,
78
+ maxFlushCps: 400,
79
+ minCommitIntervalMs: 16,
80
+ minCps: 20,
81
+ settleAfterMs: 360,
82
+ settleDrainMaxMs: 520,
83
+ settleDrainMinMs: 160,
84
+ targetBufferMs: 56,
85
+ },
86
+ }
87
+
88
+ const MAX_COMMIT_INTERVAL_MS = 16
89
+ /** Demo queue divisor: each frame reveals about `backlog / 8` characters. */
90
+ export const QUEUE_REVEAL_DIVISOR = 8
91
+ const QUEUE_FRAME_MS = 16.67
92
+ /** Hard cap on how far display may trail a live stream, in characters. */
93
+ export const LIVE_LAG_CHAR_CEILING = 32
94
+ const CATCHUP_SECONDS = 0.15
95
+
96
+ export const clamp = (value: number, min: number, max: number): number => {
97
+ return Math.min(max, Math.max(min, value))
98
+ }
99
+
100
+ /**
101
+ * Demo per-frame reveal: drain `backlog / 8` characters, at least 1, scaled
102
+ * by frame time. A small queue types one glyph per frame; a burst raises the
103
+ * step so the display does not fall behind.
104
+ * @param backlog - Unrevealed characters.
105
+ * @param dtMs - Frame delta in ms.
106
+ * @returns Characters to reveal this frame.
107
+ */
108
+ export function computeQueueReveal(backlog: number, dtMs: number): number {
109
+ if (backlog <= 0 || dtMs <= 0) return 0
110
+ return Math.min(backlog, Math.max(1, Math.ceil((backlog / QUEUE_REVEAL_DIVISOR) * (dtMs / QUEUE_FRAME_MS))))
111
+ }
112
+
113
+ /** Counts user-perceived characters (code points), not UTF-16 units. */
114
+ export const countChars = (text: string): number => {
115
+ let count = 0
116
+ for (const char of text) {
117
+ void char
118
+ count += 1
119
+ }
120
+ return count
121
+ }
122
+
123
+ /**
124
+ * Drain rate for backlog beyond the tail-lag ceiling: the displayed text may
125
+ * trail the stream by at most `backlogCharCeiling` characters for at most
126
+ * `backlogSecondCeiling` seconds. The constants are a smoothness invariant
127
+ * (bounded lag is the reason the reveal never stalls mid-reply), so they are
128
+ * not config fields; the preset multiplier is how a deployment tunes them.
129
+ */
130
+ export const BACKLOG_CHAR_CEILING = 300
131
+ export const BACKLOG_SECOND_CEILING = 2
132
+
133
+ export interface SettleDrainInput {
134
+ readonly backlog: number
135
+ readonly inputActive: boolean
136
+ readonly settling: boolean
137
+ }
138
+
139
+ /** Pure settle-drain decision shared by the frame loop and its tests. */
140
+ export function computeSettleDrain(config: StreamSmoothingPresetConfig, input: SettleDrainInput): number {
141
+ if (input.inputActive || !input.settling) return 0
142
+ // A settled stream must drain fast enough to finish the reply promptly:
143
+ // lag beyond the ceiling drains within the second ceiling.
144
+ const overflow = Math.max(0, input.backlog - BACKLOG_CHAR_CEILING)
145
+ const overflowCps = (overflow * 1000) / BACKLOG_SECOND_CEILING
146
+ const drainTargetMs = clamp(input.backlog * 8, config.settleDrainMinMs, config.settleDrainMaxMs)
147
+ const settleCps = (input.backlog * 1000) / drainTargetMs
148
+ return clamp(Math.max(settleCps, overflowCps), config.flushCps, config.maxFlushCps)
149
+ }
150
+
151
+ /**
152
+ * Drain rate multiplier once the input ends: leftover backlog reveals at
153
+ * this multiple of the steady rate, so the end never drags.
154
+ */
155
+ export const SETTLE_DRAIN_MULTIPLIER = 1.8
156
+
157
+ export interface RevealStepInput {
158
+ readonly backlog: number
159
+ readonly chunkSizeEma: number
160
+ readonly arrivalCpsEma: number
161
+ readonly emaCps: number
162
+ readonly inputActive: boolean
163
+ readonly settling: boolean
164
+ /** Fixed reveal rate; overrides the arrival-tracking cadence entirely. */
165
+ readonly steadyCps?: number | undefined
166
+ }
167
+
168
+ export interface RevealStepResult {
169
+ readonly revealChars: number
170
+ readonly targetLagChars: number
171
+ }
172
+
173
+ /** Pure per-frame reveal decision shared by the loop and its tests. */
174
+ export function computeRevealStep(config: StreamSmoothingPresetConfig, input: RevealStepInput, dtSeconds: number): RevealStepResult {
175
+ // Track the faster of the two EMAs and do not clamp to the old low maxCps
176
+ // ceiling — that cap is why a fast model left hundreds of chars unrevealed.
177
+ const trackedCps = Math.max(input.emaCps, input.arrivalCpsEma)
178
+ const baseCps = clamp(trackedCps, config.minCps, config.maxFlushCps)
179
+ const targetLagChars = input.inputActive
180
+ ? Math.max(2, Math.round((baseCps * config.targetBufferMs) / 1000))
181
+ : 0
182
+
183
+ let currentCps: number
184
+ if (input.steadyCps !== undefined) {
185
+ currentCps = input.inputActive || input.settling
186
+ ? clamp(
187
+ input.steadyCps * (input.inputActive ? 1 : SETTLE_DRAIN_MULTIPLIER),
188
+ config.minCps,
189
+ config.maxFlushCps,
190
+ )
191
+ : 0
192
+ } else if (input.inputActive) {
193
+ const overflow = Math.max(0, input.backlog - LIVE_LAG_CHAR_CEILING)
194
+ const catchup = overflow > 0 ? overflow / CATCHUP_SECONDS : 0
195
+ currentCps = clamp(baseCps * 1.08 + catchup, config.minCps, config.maxFlushCps)
196
+ } else if (input.settling) {
197
+ currentCps = computeSettleDrain(config, input)
198
+ } else {
199
+ const idleFlushCps = Math.max(config.flushCps, baseCps * 1.8, input.arrivalCpsEma * 0.8)
200
+ currentCps = clamp(idleFlushCps, config.flushCps, config.maxFlushCps)
201
+ }
202
+
203
+ const minRevealChars = input.inputActive ? 1 : 2
204
+ return { revealChars: Math.max(minRevealChars, Math.round(currentCps * dtSeconds)), targetLagChars }
205
+ }
206
+
207
+ export interface UseSmoothStreamContentOptions {
208
+ enabled?: boolean
209
+ preset?: StreamSmoothingPreset
210
+ /** Performance guard veto: while true, reveal commits are held back. */
211
+ shouldHoldBack?: (() => boolean) | undefined
212
+ /**
213
+ * Fixed reveal rate in chars/s. When set, the reveal runs at this steady
214
+ * pace while the input streams (instead of tracking the arrival rate) and
215
+ * drains the leftover backlog at {@link SETTLE_DRAIN_MULTIPLIER} once the
216
+ * input ends.
217
+ */
218
+ steadyCps?: number | undefined
219
+ /**
220
+ * Seed for the arrival-rate EMA. When omitted, the preset's `defaultCps`
221
+ * is used. The live rate then tracks observed arrival; this is not a cap.
222
+ */
223
+ defaultCps?: number | undefined
224
+ /** Written each commit with the live arrival-rate EMA for the follow lerp. */
225
+ speedCpsRef?: { current: number } | undefined
226
+ }
227
+
228
+ /**
229
+ * Smooth a chunked content stream into a reveal-paced display string.
230
+ *
231
+ * @param content - The full accumulated input so far.
232
+ * @param options - Preset, guard, and steady-rate wiring.
233
+ * @returns The displayed content, revealed at the smoothed cadence.
234
+ */
235
+ export function useSmoothStreamContent(
236
+ content: string,
237
+ { enabled = true, preset = 'balanced', shouldHoldBack, steadyCps, defaultCps, speedCpsRef }: UseSmoothStreamContentOptions = {},
238
+ ): string {
239
+ const config = PRESET_CONFIG[preset]
240
+ const seedCps = defaultCps ?? config.defaultCps
241
+ const [displayedContent, setDisplayedContent] = useState(content)
242
+
243
+ const displayedContentRef = useRef(content)
244
+ const displayedCountRef = useRef(countChars(content))
245
+ const targetContentRef = useRef(content)
246
+ const targetCharsRef = useRef([...content])
247
+ const targetCountRef = useRef(countChars(content))
248
+
249
+ const emaCpsRef = useRef(seedCps)
250
+ const lastInputTsRef = useRef(0)
251
+ const lastInputCountRef = useRef(countChars(content))
252
+ const chunkSizeEmaRef = useRef(1)
253
+ const arrivalCpsEmaRef = useRef(seedCps)
254
+
255
+ const rafRef = useRef<number | null>(null)
256
+ const lastFrameTsRef = useRef<number | null>(null)
257
+ const wakeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
258
+ const holdBackRef = useRef(shouldHoldBack)
259
+ const speedOutRef = useRef(speedCpsRef)
260
+ speedOutRef.current = speedCpsRef
261
+
262
+ useEffect(() => {
263
+ holdBackRef.current = shouldHoldBack
264
+ }, [shouldHoldBack])
265
+
266
+ const clearWakeTimer = useCallback(() => {
267
+ if (wakeTimerRef.current !== null) {
268
+ clearTimeout(wakeTimerRef.current)
269
+ wakeTimerRef.current = null
270
+ }
271
+ }, [])
272
+
273
+ const stopFrameLoop = useCallback(() => {
274
+ if (rafRef.current !== null) {
275
+ cancelAnimationFrame(rafRef.current)
276
+ rafRef.current = null
277
+ }
278
+ lastFrameTsRef.current = null
279
+ }, [])
280
+
281
+ const stopScheduling = useCallback(() => {
282
+ stopFrameLoop()
283
+ clearWakeTimer()
284
+ }, [clearWakeTimer, stopFrameLoop])
285
+
286
+ const startFrameLoopRef = useRef<() => void>(() => {})
287
+
288
+ const scheduleFrameWake = useCallback(
289
+ (delayMs: number) => {
290
+ clearWakeTimer()
291
+ wakeTimerRef.current = setTimeout(
292
+ () => {
293
+ wakeTimerRef.current = null
294
+ startFrameLoopRef.current()
295
+ },
296
+ Math.max(1, Math.ceil(delayMs)),
297
+ )
298
+ },
299
+ [clearWakeTimer],
300
+ )
301
+
302
+ const syncImmediate = useCallback(
303
+ (nextContent: string) => {
304
+ stopScheduling()
305
+ const chars = [...nextContent]
306
+ const now = performance.now()
307
+ targetContentRef.current = nextContent
308
+ targetCharsRef.current = chars
309
+ targetCountRef.current = chars.length
310
+ displayedContentRef.current = nextContent
311
+ displayedCountRef.current = chars.length
312
+ setDisplayedContent(nextContent)
313
+ emaCpsRef.current = seedCps
314
+ chunkSizeEmaRef.current = 1
315
+ arrivalCpsEmaRef.current = seedCps
316
+ lastInputTsRef.current = now
317
+ lastInputCountRef.current = chars.length
318
+ },
319
+ [seedCps, stopScheduling],
320
+ )
321
+
322
+ const startFrameLoop = useCallback(() => {
323
+ clearWakeTimer()
324
+ if (rafRef.current !== null) return
325
+
326
+ const tick = () => {
327
+ const targetCount = targetCountRef.current
328
+ const displayedCount = displayedCountRef.current
329
+ const backlog = targetCount - displayedCount
330
+
331
+ if (backlog <= 0) {
332
+ stopFrameLoop()
333
+ return
334
+ }
335
+
336
+ const now = performance.now()
337
+ if (lastFrameTsRef.current === null) {
338
+ lastFrameTsRef.current = now
339
+ rafRef.current = requestAnimationFrame(tick)
340
+ return
341
+ }
342
+
343
+ const commitIntervalMs = Math.min(MAX_COMMIT_INTERVAL_MS, config.minCommitIntervalMs)
344
+ const frameIntervalMs = Math.max(0, now - lastFrameTsRef.current)
345
+ if (frameIntervalMs < commitIntervalMs) {
346
+ rafRef.current = requestAnimationFrame(tick)
347
+ return
348
+ }
349
+
350
+ const dtSeconds = Math.max(0.001, Math.min(frameIntervalMs / 1000, 0.12))
351
+ lastFrameTsRef.current = now
352
+
353
+ const idleMs = now - lastInputTsRef.current
354
+ const inputActive = idleMs <= config.activeInputWindowMs
355
+ const settling = !inputActive && idleMs >= config.settleAfterMs
356
+
357
+ let revealChars: number
358
+ if (steadyCps !== undefined) {
359
+ const step = computeRevealStep(
360
+ config,
361
+ {
362
+ backlog,
363
+ chunkSizeEma: chunkSizeEmaRef.current,
364
+ arrivalCpsEma: arrivalCpsEmaRef.current,
365
+ emaCps: emaCpsRef.current,
366
+ inputActive,
367
+ settling,
368
+ steadyCps,
369
+ },
370
+ dtSeconds,
371
+ )
372
+ revealChars = Math.min(step.revealChars, backlog)
373
+ } else {
374
+ // Same rAF queue drain as the silky markdown demo.
375
+ revealChars = computeQueueReveal(backlog, frameIntervalMs)
376
+ }
377
+
378
+ // Performance guard: while degraded and the reply is offscreen, skip
379
+ // the DOM commit — the backlog keeps accumulating and flushes when the
380
+ // guard clears or the reply scrolls into view.
381
+ if (holdBackRef.current?.() === true) {
382
+ rafRef.current = requestAnimationFrame(tick)
383
+ return
384
+ }
385
+
386
+ const speedOut = speedOutRef.current
387
+ if (speedOut !== undefined && frameIntervalMs > 0) {
388
+ const instantCps = (revealChars * 1000) / frameIntervalMs
389
+ speedOut.current = speedOut.current * 0.92 + instantCps * 0.08
390
+ }
391
+
392
+ const nextCount = displayedCount + revealChars
393
+ const segment = targetCharsRef.current.slice(displayedCount, nextCount).join('')
394
+ if (segment) {
395
+ const nextDisplayed = displayedContentRef.current + segment
396
+ displayedContentRef.current = nextDisplayed
397
+ displayedCountRef.current = nextCount
398
+ setDisplayedContent(nextDisplayed)
399
+ } else {
400
+ displayedContentRef.current = targetContentRef.current
401
+ displayedCountRef.current = targetCount
402
+ setDisplayedContent(targetContentRef.current)
403
+ }
404
+
405
+ rafRef.current = requestAnimationFrame(tick)
406
+ }
407
+
408
+ rafRef.current = requestAnimationFrame(tick)
409
+ }, [config, scheduleFrameWake, stopFrameLoop, clearWakeTimer, steadyCps])
410
+
411
+ useEffect(() => {
412
+ startFrameLoopRef.current = startFrameLoop
413
+ }, [startFrameLoop])
414
+
415
+ useEffect(() => {
416
+ if (!enabled) {
417
+ syncImmediate(content)
418
+ return
419
+ }
420
+
421
+ const prevTargetContent = targetContentRef.current
422
+ if (content === prevTargetContent) return
423
+
424
+ const now = performance.now()
425
+ const appendOnly = content.startsWith(prevTargetContent)
426
+
427
+ if (!appendOnly) {
428
+ syncImmediate(content)
429
+ return
430
+ }
431
+
432
+ const appended = content.slice(prevTargetContent.length)
433
+ const appendedChars = [...appended]
434
+ const appendedCount = appendedChars.length
435
+
436
+ targetContentRef.current = content
437
+ targetCharsRef.current.push(...appendedChars)
438
+ targetCountRef.current += appendedCount
439
+
440
+ const hadSample = lastInputTsRef.current > 0
441
+ const deltaChars = targetCountRef.current - lastInputCountRef.current
442
+ const deltaMs = Math.max(1, now - lastInputTsRef.current)
443
+
444
+ // Skip the first sample: lastInputTs starts at 0, so the interval would
445
+ // be "time since page load" (or 1ms under fake timers) and poison the EMA.
446
+ if (hadSample && deltaChars > 0) {
447
+ const instantCps = (deltaChars * 1000) / deltaMs
448
+ const normalizedInstantCps = clamp(instantCps, config.minCps, config.maxFlushCps * 3)
449
+ const chunkEmaAlpha = 0.45
450
+ chunkSizeEmaRef.current = chunkSizeEmaRef.current * (1 - chunkEmaAlpha) + appendedCount * chunkEmaAlpha
451
+ arrivalCpsEmaRef.current = arrivalCpsEmaRef.current * (1 - chunkEmaAlpha) + normalizedInstantCps * chunkEmaAlpha
452
+ emaCpsRef.current = emaCpsRef.current * (1 - config.emaAlpha) + normalizedInstantCps * config.emaAlpha
453
+ }
454
+
455
+ lastInputTsRef.current = now
456
+ lastInputCountRef.current = targetCountRef.current
457
+
458
+ startFrameLoop()
459
+ }, [content, enabled, config, startFrameLoop, syncImmediate])
460
+
461
+ useEffect(() => {
462
+ return () => {
463
+ stopScheduling()
464
+ }
465
+ }, [stopScheduling])
466
+
467
+ return displayedContent
468
+ }
package/src/config.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Shared plugin configuration contract. The Host half defines the
3
+ * Schemastery schema over this shape (defaults live here so both halves stay
4
+ * symmetric); the client half falls back to the same defaults when the Host
5
+ * boot-config bridge is absent (client-only composition).
6
+ */
7
+
8
+ import type { StreamSmoothingPreset } from './client/useSmoothStreamContent.ts'
9
+
10
+ /** Streaming render direction of the assistant node view. */
11
+ export type StreamMode = 'typewriter' | 'teleprompter'
12
+
13
+ /** Plugin configuration validated by the Host schema and bridged to the browser half. */
14
+ export interface StreamConfig {
15
+ /** Render direction: horizontal per-character reveal or uniform upward glide. */
16
+ readonly mode: StreamMode
17
+ /** Smoothing preset for the reveal cadence. */
18
+ readonly preset: StreamSmoothingPreset
19
+ /**
20
+ * Unused at runtime; kept so existing overlays load. Live reveal tracks
21
+ * observed arrival and ignores this seed.
22
+ */
23
+ readonly revealCharsPerSec: number
24
+ /**
25
+ * Unused at runtime; kept so existing overlays load. Follow is a
26
+ * smooth-damp, not a cruise speed.
27
+ */
28
+ readonly scrollSpeedPxPerSec: number
29
+ /**
30
+ * Follow velocity ceiling in CSS pixels per second, so a huge first lag
31
+ * does not teleport. Ordinary wraps stay well below this.
32
+ */
33
+ readonly maxScrollSpeedPxPerSec: number
34
+ }
35
+
36
+ /** Defaults shared by the Host schema and the client-side fallback. */
37
+ export const DEFAULT_STREAM_CONFIG: StreamConfig = {
38
+ mode: 'typewriter',
39
+ preset: 'balanced',
40
+ revealCharsPerSec: 80,
41
+ scrollSpeedPxPerSec: 48,
42
+ maxScrollSpeedPxPerSec: 1000,
43
+ }
44
+
45
+ /**
46
+ * Window global the Host writes into the served index HTML. The browser boot
47
+ * graph carries no per-entry config, so this inline script is the only
48
+ * Host-to-client configuration channel for a composed web plugin.
49
+ */
50
+ export const STREAM_BOOT_GLOBAL = '__DSH_SMOOTH_STREAM_CONFIG__'
@@ -0,0 +1,9 @@
1
+ declare module '*.module.css' {
2
+ const classes: Record<string, string>
3
+ export default classes
4
+ }
5
+
6
+ declare module '*.css' {
7
+ const stylesheet: string
8
+ export default stylesheet
9
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Host entry. The browser implementation is exported from `./client`. */
2
+ export { apply, name, Config } from './plugin.ts'
package/src/plugin.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type {} from '@deepseek-ai/dsh-host-webserver'
3
+ import Schema from '@deepseek-ai/schemastery'
4
+ import { DEFAULT_STREAM_CONFIG, type StreamConfig } from './config.ts'
5
+ import { injectStreamConfig } from './boot-config.ts'
6
+
7
+ /** Display name shown by the Host loader while the plugin is mounted. */
8
+ export const name = 'dsh-smooth-stream'
9
+
10
+ /**
11
+ * Plugin configuration accepted from the overlay's `config` section. Cordis
12
+ * validates the value against this schema at load and fills omitted fields
13
+ * from the shared defaults, so an invalid value fails the load loudly.
14
+ */
15
+ export interface Config extends StreamConfig {}
16
+
17
+ export const Config: Schema<Config> = Schema.object({
18
+ mode: Schema.union(['typewriter', 'teleprompter'] as const).default(DEFAULT_STREAM_CONFIG.mode),
19
+ preset: Schema.union(['realtime', 'balanced', 'silky'] as const).default(DEFAULT_STREAM_CONFIG.preset),
20
+ revealCharsPerSec: Schema.number()
21
+ .min(5)
22
+ .max(200)
23
+ .default(DEFAULT_STREAM_CONFIG.revealCharsPerSec),
24
+ scrollSpeedPxPerSec: Schema.number()
25
+ .min(1)
26
+ .max(200)
27
+ .default(DEFAULT_STREAM_CONFIG.scrollSpeedPxPerSec),
28
+ maxScrollSpeedPxPerSec: Schema.number()
29
+ .min(1)
30
+ .max(2000)
31
+ .default(DEFAULT_STREAM_CONFIG.maxScrollSpeedPxPerSec),
32
+ })
33
+
34
+ /**
35
+ * Host half: log the resolved configuration and bridge it to the browser
36
+ * half. The web boot graph carries no per-entry config, so the validated
37
+ * value is injected into every served index response as a boot global the
38
+ * client entry reads at apply time.
39
+ * @param ctx - Host context carrying the web server service when composed.
40
+ * @param config - Schema-validated configuration with defaults filled.
41
+ */
42
+ export function apply(ctx: Context, config: Config): void {
43
+ console.log(
44
+ `[dsh-smooth-stream] plugin loaded! mode=${config.mode} preset=${config.preset} `
45
+ + `seed=${config.revealCharsPerSec}cps scroll=${config.scrollSpeedPxPerSec}px/s `
46
+ + `maxScroll=${config.maxScrollSpeedPxPerSec}px/s`,
47
+ )
48
+ ctx.inject(['webServer'], (httpCtx) => {
49
+ httpCtx.effect(
50
+ () => httpCtx.webServer.tapIndex(html => injectStreamConfig(html, config)),
51
+ 'dsh-smooth-stream: boot config bridge',
52
+ )
53
+ })
54
+ }