Skip to content

feat: optional input-smoothing utility (./smoothing)#89

Merged
jonathanKingston merged 2 commits into
mainfrom
claude/reveal-smoothing
Jul 8, 2026
Merged

feat: optional input-smoothing utility (./smoothing)#89
jonathanKingston merged 2 commits into
mainfrom
claude/reveal-smoothing

Conversation

@jonathanKingston

Copy link
Copy Markdown
Collaborator

Closes #84.

An opt-in reveal-smoothing utility behind a new ./smoothing subpath — off by default, zero bytes in the main bundle unless imported, and the incremental DOM emitter's default behavior is byte-for-byte unchanged.

Approach: input-smoothing, not output-animation

The smoother throttles the input string fed to renderer.update() — releasing a growing prefix a few characters per frame via requestAnimationFrame — rather than animating newly-added DOM nodes. Every released value is a prefix of the accumulated text, i.e. exactly the streaming contract the existing pending-state machinery and DOM morph already converge on, so it composes with them instead of introducing a second animation system that could fight the morph. Lower-risk and framework-/theme-agnostic.

CSS entrance animations are deliberately left to the host (documented): style the emitter's existing .stream-pending / .stream-complete hooks with your own transitions.

API

import { createInputSmoother } from '@copse/streaming-markdown/smoothing'

const smoother = createInputSmoother({ update: (t) => renderer.update(t), charsPerSecond: 600 })
for await (const chunk of stream) { accumulated += chunk; smoother.push(accumulated) }
smoother.flush() // release everything immediately on stream end

createInputSmoother({ update, charsPerSecond=600, disabled, respectReducedMotion=true, + injectable now/requestFrame/cancelFrame/matchMedia seams }){ push, flush, dispose, enabled }.

Guarantees

  • Off by default; zero main-bundle bytes (asserted via an esbuild bundle-of-index test).
  • prefers-reduced-motion → smoothing disabled, immediate pass-through.
  • Post-flush() DOM equals a single un-smoothed update(fullText) (asserted against a direct full-string render) — convergence preserved.
  • All environment globals read through defaulted injectable seams, so it runs and unit-tests under node/jsdom.
  • UTF-16-surrogate-safe: intermediate frames never emit a lone surrogate half.

Verification

  • npm run typecheck, npm run build (dist/smoothing.* emitted) — pass
  • npm test — 786 pass / 0 fail / 1 pre-existing skip (incl. 19 new smoothing tests)
  • npm run check:conformance / npm run check:gfm-conformance — pass
  • npm run coverage:ci — gate passes; smoothing.ts 100% lines/funcs; baseline ratcheted 99.75 → 99.76

Caveats

  • prefers-reduced-motion is evaluated once at creation (documented).
  • The c8-instrumented full run took a late SIGKILL in the sandbox after flushing a complete coverage summary (which the gate passed against, +0.01%); the uninstrumented suite completed cleanly green. A clean CI run can only match or exceed this. Worth a glance at the CI coverage job.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ehmok4AzyTQDNkRzW3g8z8


Generated by Claude Code

claude added 2 commits July 7, 2026 08:54
Ship an opt-in input smoother that steadies chunky token arrival into a
smooth character-cadence reveal by throttling the string fed to
`renderer.update()` (via requestAnimationFrame) rather than animating output.
It lives behind `@copse/streaming-markdown/smoothing`, is never re-exported
from the main entry, and adds zero bytes to the main bundle unless imported —
the default emitter path stays byte-for-byte unchanged.

- `createInputSmoother({ update, charsPerSecond, disabled, respectReducedMotion })`
  with `push`/`flush`/`dispose` and an `enabled` flag.
- Honours `prefers-reduced-motion` (disable -> immediate pass-through); flushes
  the full target on stream end with no completion lag.
- Convergence preserved: after flush the DOM equals a single un-smoothed
  `update(fullText)` (asserted against a direct full-string render).
- Environment access (raf/caf/now/matchMedia) is guarded behind defaulted,
  injectable seams so it runs and unit-tests under node/jsdom.
- Tests: incremental reveal on a fake clock, flush, dispose, reduced-motion,
  UTF-16 surrogate safety, default-env fallbacks, and an esbuild bundle check
  proving no leakage into the main entry. Docs in LAZY-LOADING.md + README,
  covering the input-smoothing-vs-output-animation decision. Coverage ratcheted
  to 99.76%.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ehmok4AzyTQDNkRzW3g8z8
# Conflicts:
#	README.md
#	coverage-baseline.json
#	docs/LAZY-LOADING.md
@jonathanKingston jonathanKingston merged commit c274477 into main Jul 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optional reveal smoothing / streaming animation (opt-in, never default)

2 participants