diff --git a/CHANGELOG.md b/CHANGELOG.md index 136786a..bfcda89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 1.8.0 + +- **AI & LLM Real-Time Token Streaming Engine**: + - `HyperStreamingController`: High-performance streaming controller for batching rapid SSE / WebSocket token bursts with 16ms frame-aligned throttling by default, backing off adaptively (up to `maxThrottleDuration`, default 200ms) once the accumulated buffer passes 10,000 / 50,000 characters โ€” bounding total reparse cost over a long stream's lifetime. + - `StreamSyntaxNormalizer`: Transient auto-repair utility for incomplete in-flight tokens (auto-closes unclosed code block fences, inline code, bold/italic asterisks, incomplete table rows, incomplete links, and truncated HTML tags). + - `HyperTypingCaret`: Animated typing caret widget with customizable blinking styles (`bar`, `block`, `underscore`, `dot`, `custom`). + - `HyperViewer.streaming(...)`: Dedicated streaming constructor with stick-to-bottom auto-scroller, syntax auto-repair, and typing caret integration. + - Interactive AI streaming demo added to showcase app (`example/lib/ai_streaming_demo.dart`), including a virtualized-mode toggle and an error-simulation control. + +### ๐Ÿ› Fixes + +- **`didUpdateWidget` missed `streamingController` swaps and `autoRepairSyntax` toggles**: replacing the streaming controller with a fresh instance (e.g. a new AI turn) or flipping `autoRepairSyntax` at runtime had no visible effect until the next stream notification happened to arrive. Both are now in the reparse-trigger list. +- **Content fade restarted on every streaming tick**: the 300ms fade-in was reset on every throttled notification (as often as every 16ms), so it perpetually restarted instead of settling โ€” content flickered/stayed dim for the whole stream. The fade now plays once per streaming session and correctly replays after `reset()`. +- **`fallbackBuilder`'s complexity gate never fired during streaming**: it checked `widget.content`, which `.streaming()` hardcodes to `''`, so `HtmlHeuristics.isComplex(...)` was always evaluated against an empty string regardless of how complex the streamed HTML became. It now reads the live streaming buffer. +- **`autoScrollToBottom` silently did nothing in virtualized/`auto` mode** without an externally supplied `HyperViewerController`: the virtualized `ListView.builder` was bound to `widget.controller?.scrollController` instead of the existing `_effectiveScrollController` fallback, so the internally-created scroll controller was never attached to anything. +- **`append()` after `error()` silently resumed streaming**, erasing the error state without requiring `reset()` first (the guard only checked for `completed`, not `error`). It now rejects appends in both terminal states. +- **A synchronous exception from `append()` inside `bindStream`/`bindCustomStream`'s `onData` callback bypassed `onError` entirely** and surfaced as an unhandled zone exception, contradicting the documented "catches errors" behavior. Both now wrap `onData` (and, for `bindCustomStream`, the `mapper` call) and route genuine failures into `error()` โ€” without overwriting an already-legitimate `completed`/`error` state if a late/duplicate event races it. +- **`StreamSyntaxNormalizer.normalizeMarkdown` scanned the entire accumulated buffer** for unmatched backticks/asterisks/`$`/brackets, so an odd character count *inside an already-closed, fully-rendered code fence* earlier in the document was miscounted as unclosed โ€” corrupting unrelated trailing prose with a spurious appended character on every subsequent tick. Parity and link-repair scans are now scoped to the text after the last closed fence. +- **`StreamSyntaxNormalizer.normalizeHtml`'s truncated-tag detection could be fooled by a literal `>` inside a still-open tag's quoted attribute value** (e.g. `
Hello

Text wraps seamlessly around floats!

', onLinkTap: (url, attributes, element) => launchUrl(Uri.parse(url!)), @@ -67,7 +68,7 @@ Html( ```yaml dependencies: - hyper_render: ^1.7.0 + hyper_render: ^1.8.0 ``` ```dart @@ -83,6 +84,29 @@ Zero configuration. XSS sanitization is **on by default**. No Gradle setup requi --- +## ๐Ÿค– AI / LLM Real-Time Streaming + +Render live streaming token feeds from **Google Gemini, OpenAI ChatGPT, Anthropic Claude**, or WebSocket backends with frame-aligned, adaptively-throttled updates and automatic transient syntax repair. + +```dart +final controller = HyperStreamingController(); + +// Bind directly to any Dart Stream (e.g. OpenAI / Gemini SDK): +controller.bindStream(aiTokenStream); + +// Render with automatic stick-to-bottom auto-scroller and pulsing caret: +HyperViewer.streaming( + streamingController: controller, + contentType: HyperContentType.markdown, + showTypingCaret: true, + caretStyle: HyperTypingCaretStyle.bar, + autoRepairSyntax: true, // Auto-closes incomplete ```, **, $$, | on the fly + autoScrollToBottom: true, // Smoothly tracks stream tail +) +``` + +--- + ## ๐Ÿ—๏ธ Why Switch? The Architecture Argument Most Flutter HTML libraries map each HTML tag to a Flutter widget. A 3 000-word article becomes **500+ nested widgets** โ€” and some layout primitives simply cannot be expressed that way: @@ -97,6 +121,7 @@ HyperRender renders the whole document inside **one custom `RenderObject`**. CSS | Feature | `flutter_html` | `flutter_widget_from_html` | **HyperRender** | |---|:---:|:---:|:---:| | `float: left / right` | โŒ | โŒ | โœ… | +| AI / LLM Streaming | โŒ | โŒ | โœ… Frame-aligned, adaptive throttle | | Text selection โ€” large docs | โŒ Crashes | โŒ Crashes | โœ… Crash-free | | Ruby / Furigana + Kinsoku | โŒ Raw text | โŒ Raw text | โœ… | | RTL / BiDi (Arabic, Hebrew) | โš ๏ธ | โš ๏ธ | โœ… | @@ -450,7 +475,7 @@ These packages bring specialized dependencies and are **not bundled** by default ```yaml dependencies: - hyper_render_epub: ^0.1.0 + hyper_render_epub: ^0.1.2 ``` ```dart diff --git a/ROADMAP.md b/ROADMAP.md index 115810e..a405b87 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,35 +9,26 @@ This document outlines the architectural roadmap for **HyperRender** to become t | Version | Target Date | Strategic Focus | Key Differentiators | | :--- | :--- | :--- | :--- | | **v1.7.0** | Current | **Production Hardening & Drop-in Migration** | Single RenderObject, 100% WASM support, 160/160 pub score, 30s `flutter_html` drop-in layer. | -| **v1.8.0** | Q3 2026 | **AI & LLM Token-Streaming Engine** | Incremental Delta-Streaming, Zero-Jank token updates, auto-scroll locking, tail-only layout invalidation. | +| **v1.8.0** | Q3 2026 | **AI & LLM Token-Streaming Engine** | Frame-throttled token updates with adaptive backoff, transient syntax auto-repair, auto-scroll locking. Tail-only layout invalidation remains a separate, unscheduled epic โ€” see below. | | **v1.9.0** | Q4 2026 | **Native Vector Diagramming & Headless Export** | Pure Canvas/Vector Mermaid.js & GraphViz (Zero-WebView), Headless Image & PDF byte stream generator. | | **v2.0.0** | Q1 2027 | **Interactive Editorial & Magazine Typography** | Medium-style Text Annotation/Highlighting layer, Multi-column layout (`column-count`), Z-Index Stacking Context, Vertical Text (`writing-mode: vertical-rl`). | --- -## ๐Ÿš€ v1.8.0: AI / LLM Streaming Engine (Token-by-Token Zero-Jank) +## ๐Ÿš€ v1.8.0: AI / LLM Streaming Engine -### 1. Incremental Delta-Append Engine -- **Problem**: Modern LLM chat apps (ChatGPT, Claude, Notion AI) stream Markdown/HTML token-by-token. Re-parsing the full document string every 50ms causes 100% CPU spikes, severe frame drops (jank), memory thrashing, and scroll jump. -- **Solution**: - - Token-level append directly to the active UDT leaf node. - - Partial layout invalidation: only measure and lay out the trailing line fragment (`tailLineLayout`), preserving 100% of cached layout geometry for preceding paragraphs, tables, and code blocks. - - Smooth Auto-Scroll Anchor: lock viewport to stream tail without jittering scroll physics. -- **API Surface**: - ```dart - final streamController = HyperDocumentStreamController(); - - HyperViewer.stream( - controller: streamController, - mode: HyperRenderMode.sync, - ); - - // As chunks arrive from LLM: - streamController.appendToken(" **instant** rendering"); - ``` +**Shipped** (`HyperStreamingController`, `HyperViewer.streaming(...)`, `StreamSyntaxNormalizer`, `HyperTypingCaret` โ€” see CHANGELOG 1.8.0): frame-throttled token append with **adaptive backoff** (the notification interval widens as the accumulated buffer grows past 10,000 / 50,000 chars, up to `maxThrottleDuration`), transient syntax auto-repair for Markdown/HTML, stick-to-bottom auto-scroll, typing caret. This bounds the total cost of re-parsing over the life of a long stream and is what "Zero-Jank" in this doc's earlier drafts actually refers to. + +**Not shipped โ€” the paragraph below was aspirational and did not match what got built; corrected after a production-readiness review found the mismatch:** + +### 1. Incremental Delta-Append Engine (tail-only layout) โ€” still unimplemented, own epic +- **Problem**: re-parsing and re-laying-out the *entire* accumulated document on every streaming tick, rather than only the appended tail, means total work over a stream's lifetime scales with the square of its final length. The adaptive-backoff mitigation above bounds *how often* this happens as the buffer grows, but each tick still does a full document reparse + full `RenderHyperBox` layout pass โ€” it does not make any single tick cheaper. +- **Why it's not a small patch**: a feasibility review of `packages/hyper_render_core/lib/src/core/render_hyper_box*.dart` found this needs four largely independent subsystems, most of them outside the renderer: (a) a parser able to resume from a character offset instead of re-tokenizing from scratch, (b) a UDT model change โ€” `TextNode.text` is currently immutable and nodes have no identity that survives across two parses, so there is no way to "find and extend the last text node" today, (c) a fragment list that supports appending instead of the current full-rebuild-every-layout design, (d) a persisted line-layout checkpoint (cursor position, in-progress float lists) that `_performLineLayout` can resume from instead of always resetting to empty. `RenderHyperBox`'s 7-file `part` architecture (shared private state across files, no interface boundary โ€” see the Architecture section above) makes this riskier than in a normally-composed class, since nothing stops a part file from silently assuming layout is always complete and freshly computed. Some CSS behavior (`text-align: justify`, float carryover, `text-overflow: ellipsis`) is also not strictly tail-local, so "only touch the appended tail" needs a correctness argument per feature, not just an engine change. +- **Status**: deliberately deferred as a separate, scoped effort (own design + plan, own risk review) rather than folded into a bug-fix/hardening pass on a renderer every consumer of this library depends on โ€” not just streaming users. -### 2. Live KaTeX & Syntax Highlighting in Streaming Mode -- Incremental tokenizer state tracking: maintain code-fence (` ``` `) and math-delimiter (`$$`) states across partial chunks to prevent flashing unstyled syntax during streaming. +### 2. Live KaTeX & Syntax Highlighting in Streaming Mode โ€” verified non-issue, not scheduled +- **Original concern**: incomplete code fences / math delimiters mid-stream could flash unstyled or broken content, or crash the highlighter/KaTeX renderer. +- **Investigated and closed**: `HyperViewer.streaming()` already exposes both `codeHighlighter` and `pluginRegistry`, so both are reachable during live streaming. `flutter_highlight`'s lexer is best-effort (not a strict parser) and doesn't throw on incomplete/malformed code โ€” covered by `code_highlighter_edge_cases_test.dart`. `flutter_math_fork`'s `Math.tex(..., onErrorFallback: ...)` wraps both its parse and build stages in a catch-all, so a delimiter-balanced-but-internally-malformed LaTeX fragment (which `StreamSyntaxNormalizer` intentionally does not try to brace-balance) safely falls through to the red-text fallback instead of crashing. No incremental tokenizer state is needed; regression tests were added to lock this behavior in (see CHANGELOG). --- diff --git a/doc/CSS_PROPERTIES_MATRIX.md b/doc/CSS_PROPERTIES_MATRIX.md index 71236d5..0951956 100644 --- a/doc/CSS_PROPERTIES_MATRIX.md +++ b/doc/CSS_PROPERTIES_MATRIX.md @@ -1,7 +1,7 @@ # CSS Properties Support Matrix -Last Updated: June 24, 2026 -Version: 1.4.0 +Last Updated: September 6, 2026 +Version: 1.8.0 This document lists CSS property support in HyperRender. @@ -250,4 +250,4 @@ This document lists CSS property support in HyperRender. --- -*Last updated: July 5, 2026 โ€” HyperRender v1.5.0* +*Last updated: September 6, 2026 โ€” HyperRender v1.8.0* diff --git a/doc/LIMITATIONS.md b/doc/LIMITATIONS.md index d36ce5c..6242660 100644 --- a/doc/LIMITATIONS.md +++ b/doc/LIMITATIONS.md @@ -161,6 +161,27 @@ HyperViewer(html: html, mode: HyperRenderMode.sync) Prefer explicit `pump(duration)` calls over `pumpAndSettle()` for any test that exercises virtualised or paged mode. +### AI/LLM streaming (`HyperViewer.streaming`) is full-reparse, not tail-only + +`HyperStreamingController`/`HyperViewer.streaming(...)` (v1.8.0) re-runs syntax +normalization, sanitization, parsing, and layout over the **entire** +accumulated buffer on every throttled notification โ€” it does not append to an +existing document tree or only re-lay-out the trailing line. This is +mitigated, not eliminated, by adaptive throttle backoff: the notification +interval automatically widens once the buffer passes 10,000 / 50,000 +characters (up to `maxThrottleDuration`, default 200ms), bounding total work +over a long stream's lifetime, but each individual tick's cost still scales +with the full document length, not just the newly-appended text. + +A genuine tail-only incremental engine (append to the last text node in +place, resume line-layout from the last committed line) was evaluated and +deliberately deferred โ€” it requires a parser that can resume from a character +offset, a UDT model change (`TextNode.text` is currently immutable and nodes +have no identity across two parses), and a persisted line-layout checkpoint +inside `RenderHyperBox`'s `part`-file architecture, which has no +encapsulation boundary between layout/paint/selection/accessibility. See +`ROADMAP.md`'s v1.8.0 section for the full feasibility writeup. + --- ## Interactive Elements @@ -217,4 +238,4 @@ the full suite plus goldens as the safety net. --- -*Last updated: July 22, 2026 โ€” HyperRender v1.5.x (unreleased branch)* +*Last updated: September 6, 2026 โ€” HyperRender v1.8.0* diff --git a/example/lib/ai_streaming_demo.dart b/example/lib/ai_streaming_demo.dart new file mode 100644 index 0000000..3503acd --- /dev/null +++ b/example/lib/ai_streaming_demo.dart @@ -0,0 +1,328 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:hyper_render/hyper_render.dart'; + +/// Interactive AI & LLM Streaming Demo for HyperRender v1.8.0. +class AiStreamingDemo extends StatefulWidget { + final bool autoStart; + const AiStreamingDemo({super.key, this.autoStart = false}); + + @override + State createState() => _AiStreamingDemoState(); +} + +class _AiStreamingDemoState extends State { + final HyperStreamingController _controller = HyperStreamingController( + throttleDuration: const Duration(milliseconds: 16), + ); + + HyperTypingCaretStyle _caretStyle = HyperTypingCaretStyle.bar; + bool _autoRepair = true; + bool _autoScroll = true; + HyperRenderMode _mode = HyperRenderMode.sync; + Timer? _streamTimer; + int _chunkIndex = 0; + + @override + void initState() { + super.initState(); + if (widget.autoStart) { + WidgetsBinding.instance.addPostFrameCallback((_) => _startSimulation()); + } + } + + static const List _sampleTokens = [ + '# ๐Ÿง  HyperRender AI Assistant\n\n', + 'Hello! I am your **HyperRender AI** assistant streaming responses directly into Flutter.\n\n', + '### Key Capabilities of v1.8.0 Streaming Engine:\n\n', + '- **Frame-Aligned Adaptive Throttling**: Batches high-speed SSE bursts at a 16ms cadence, backing off automatically for very long responses.\n', + '- **Transient Syntax Normalization**: Auto-repairs unclosed markdown fences and formatting.\n', + '- **Stick-to-Bottom Auto Scroll**: Smoothly follows output tail in real-time.\n', + '- **Native Blinking Carets**: Customizable bar, block, underscore, and dot styles.\n\n', + 'Here is an example code snippet generated on-the-fly:\n\n', + '```dart\n', + '// Initialize AI Streaming Controller\n', + 'final controller = HyperStreamingController();\n', + 'controller.bindStream(geminiStream);\n\n', + '// Render with HyperViewer.streaming\n', + 'HyperViewer.streaming(\n', + ' streamingController: controller,\n', + ' contentType: HyperContentType.markdown,\n', + ' showTypingCaret: true,\n', + ')\n', + '```\n\n', + '### How the throttling works:\n\n', + 'Each token is buffered and the view is notified at a 16ms cadence by ' + 'default; once the accumulated response grows past 10,000 or ' + '50,000 characters, the notification interval widens automatically ' + '(up to `maxThrottleDuration`) so re-parsing a very long response ' + "doesn't get proportionally more expensive on every tick.\n\n", + 'โœจ *Streaming generation finished.*', + ]; + + @override + void dispose() { + _streamTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + void _startSimulation() { + _streamTimer?.cancel(); + _controller.reset(); + _chunkIndex = 0; + + _streamTimer = Timer.periodic(const Duration(milliseconds: 120), (timer) { + if (_chunkIndex < _sampleTokens.length) { + _controller.append(_sampleTokens[_chunkIndex]); + _chunkIndex++; + } else { + _controller.complete(); + timer.cancel(); + } + }); + } + + void _stopSimulation() { + _streamTimer?.cancel(); + _controller.complete(); + } + + void _resetSimulation() { + _streamTimer?.cancel(); + _controller.reset(); + _chunkIndex = 0; + } + + /// Demonstrates the error โ†’ reset() lifecycle: once a controller reaches + /// [HyperStreamingStatus.error], it must be reset() before it can stream + /// again โ€” appending directly would throw. + void _simulateError() { + _streamTimer?.cancel(); + _controller.error('Simulated network timeout'); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('AI / LLM Streaming Engine (v1.8.0)'), + backgroundColor: const Color(0xFF1E293B), + foregroundColor: Colors.white, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _resetSimulation, + tooltip: 'Reset Stream', + ), + ], + ), + body: Column( + children: [ + // Control Panel + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + border: Border(bottom: BorderSide(color: Colors.grey.shade300)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + alignment: WrapAlignment.spaceBetween, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + ElevatedButton.icon( + onPressed: _startSimulation, + icon: const Icon(Icons.play_arrow, size: 18), + label: const Text('Start Stream'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _stopSimulation, + icon: const Icon(Icons.stop, size: 18), + label: const Text('Stop'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _simulateError, + icon: const Icon(Icons.warning_amber, size: 18), + label: const Text('Simulate Error'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + ), + ), + ], + ), + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, _) { + Color badgeColor = Colors.grey; + String statusLabel = 'IDLE'; + + switch (state.status) { + case HyperStreamingStatus.streaming: + badgeColor = Colors.green; + statusLabel = + 'STREAMING (${state.tokenCount} tokens ยท ${state.tokensPerSecond.toStringAsFixed(1)} tps)'; + break; + case HyperStreamingStatus.completed: + badgeColor = Colors.blue; + statusLabel = + 'DONE (${state.tokenCount} tokens ยท ${state.tokensPerSecond.toStringAsFixed(1)} tps avg)'; + break; + case HyperStreamingStatus.error: + badgeColor = Colors.red; + statusLabel = 'ERROR'; + break; + case HyperStreamingStatus.idle: + break; + } + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: badgeColor.withAlpha(25), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: badgeColor), + ), + child: Text( + statusLabel, + style: TextStyle( + color: badgeColor, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 6), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + const Text('Caret: ', + style: TextStyle( + fontWeight: FontWeight.w600, fontSize: 13)), + DropdownButton( + value: _caretStyle, + underline: const SizedBox.shrink(), + isDense: true, + items: const [ + DropdownMenuItem( + value: HyperTypingCaretStyle.bar, + child: + Text('Bar (โ–)', style: TextStyle(fontSize: 13)), + ), + DropdownMenuItem( + value: HyperTypingCaretStyle.block, + child: Text('Block (โ–ˆ)', + style: TextStyle(fontSize: 13)), + ), + DropdownMenuItem( + value: HyperTypingCaretStyle.underscore, + child: Text('Underscore (_)', + style: TextStyle(fontSize: 13)), + ), + DropdownMenuItem( + value: HyperTypingCaretStyle.dot, + child: + Text('Dot (โ—)', style: TextStyle(fontSize: 13)), + ), + ], + onChanged: (val) { + if (val != null) setState(() => _caretStyle = val); + }, + ), + const SizedBox(width: 16), + const Text('Mode: ', + style: TextStyle( + fontWeight: FontWeight.w600, fontSize: 13)), + DropdownButton( + value: _mode, + underline: const SizedBox.shrink(), + isDense: true, + items: const [ + DropdownMenuItem( + value: HyperRenderMode.sync, + child: Text('Sync', style: TextStyle(fontSize: 13)), + ), + DropdownMenuItem( + value: HyperRenderMode.virtualized, + child: Text('Virtualized', + style: TextStyle(fontSize: 13)), + ), + ], + onChanged: (val) { + if (val != null) setState(() => _mode = val); + }, + ), + const SizedBox(width: 16), + const Text('Auto Scroll: ', + style: TextStyle(fontSize: 13)), + Switch( + value: _autoScroll, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (v) => setState(() => _autoScroll = v), + ), + const SizedBox(width: 16), + const Text('Auto Repair: ', + style: TextStyle(fontSize: 13)), + Switch( + value: _autoRepair, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + onChanged: (v) => setState(() => _autoRepair = v), + ), + ], + ), + ), + ], + ), + ), + + // Main Streaming Viewer + Expanded( + child: Container( + color: Colors.white, + padding: const EdgeInsets.all(16), + child: HyperViewer.streaming( + streamingController: _controller, + contentType: HyperContentType.markdown, + caretStyle: _caretStyle, + autoRepairSyntax: _autoRepair, + autoScrollToBottom: _autoScroll, + mode: _mode, + showTypingCaret: true, + caretColor: theme.colorScheme.primary, + ), + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/demo_auto_player.dart b/example/lib/demo_auto_player.dart index e1e234a..b346056 100644 --- a/example/lib/demo_auto_player.dart +++ b/example/lib/demo_auto_player.dart @@ -2,12 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:hyper_render/hyper_render.dart'; -import 'main.dart'; -import 'smart_table_demo.dart'; -import 'manga_demo.dart'; -import 'enhanced_selection_demo.dart'; -import 'ultra_showcase_2026.dart'; -import 'stress_test_demo.dart'; +import 'ai_streaming_demo.dart'; String getActiveDemo() { try { @@ -91,6 +86,10 @@ class _AutoPlayerHostState extends State { String title; String htmlContent; + if (widget.demoName == 'ai_streaming') { + return const AiStreamingDemo(autoStart: true); + } + switch (widget.demoName) { case 'ruby': title = 'Ruby / Furigana CJK Typography'; @@ -238,11 +237,14 @@ class _AutoPlayerHostState extends State { title = '60 FPS Virtualized Rendering'; final buf = StringBuffer(); buf.write('
'); - buf.write('

100,000+ Chars Virtualized Mode

'); - buf.write('
๐Ÿš€ FPS: 60.0 | Memory: 2.4 MB | Active Nodes: 12
'); + buf.write( + '

100,000+ Chars Virtualized Mode

'); + buf.write( + '
๐Ÿš€ FPS: 60.0 | Memory: 2.4 MB | Active Nodes: 12
'); for (int i = 1; i <= 60; i++) { final bg = i % 2 == 0 ? '#F8FAFC' : '#FFFFFF'; - buf.write('
Section $i: HyperRender utilizes intelligent chunking. Only blocks visible on screen are painted, achieving smooth 60 FPS scrolling.
'); + buf.write( + '
Section $i: HyperRender utilizes intelligent chunking. Only blocks visible on screen are painted, achieving smooth 60 FPS scrolling.
'); } buf.write('
'); htmlContent = buf.toString(); @@ -280,7 +282,8 @@ class _AutoPlayerHostState extends State { return Scaffold( appBar: AppBar( - title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + title: Text(title, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), backgroundColor: const Color(0xFF1A56DB), foregroundColor: Colors.white, elevation: 2, diff --git a/example/lib/enhanced_selection_demo.dart b/example/lib/enhanced_selection_demo.dart index 0eed93e..fa1298a 100644 --- a/example/lib/enhanced_selection_demo.dart +++ b/example/lib/enhanced_selection_demo.dart @@ -452,12 +452,12 @@ class _EnhancedSelectionDemoState extends State { _showSnackBar('โœ… Copied to clipboard', Colors.green); } - // MED-01: Declared as Future so exceptions propagate to the caller and + // Declared as Future so exceptions propagate to the caller and // are not silently swallowed (void async discards unhandled Future errors). Future _handleShare(HyperSelectionState state) async { final text = state.selectedText; if (text != null && text.isNotEmpty) { - // CRIT-02: Check mounted BEFORE setState โ€” user may have popped the screen + // Check mounted BEFORE setState โ€” user may have popped the screen // between the gesture callback and this synchronous execution path. if (!mounted) return; setState(() { @@ -492,7 +492,7 @@ class _EnhancedSelectionDemoState extends State { Future _handleSearch(HyperSelectionState state) async { final text = state.selectedText; if (text != null && text.isNotEmpty) { - if (!mounted) return; // CRIT-02 + if (!mounted) return; setState(() { _lastAction = 'Search on Google'; _selectedText = text; @@ -532,7 +532,7 @@ class _EnhancedSelectionDemoState extends State { Future _handleTranslate(HyperSelectionState state) async { final text = state.selectedText; if (text != null && text.isNotEmpty) { - if (!mounted) return; // CRIT-02 + if (!mounted) return; setState(() { _lastAction = 'Translate'; _selectedText = text; @@ -561,7 +561,7 @@ class _EnhancedSelectionDemoState extends State { Future _handleDefine(HyperSelectionState state) async { final text = state.selectedText; if (text != null && text.isNotEmpty) { - if (!mounted) return; // CRIT-02 + if (!mounted) return; setState(() { _lastAction = 'Dictionary Lookup'; _selectedText = text; diff --git a/example/lib/html_preview_helper.dart b/example/lib/html_preview_helper.dart index 82068f1..1170271 100644 --- a/example/lib/html_preview_helper.dart +++ b/example/lib/html_preview_helper.dart @@ -89,7 +89,7 @@ class HtmlPreviewHelper { final uri = Uri.file(file.path); if (await canLaunchUrl(uri)) { await launchUrl(uri); - // MED-02: Clean up temp file after a short delay so the browser has + // Clean up temp file after a short delay so the browser has // time to read it. The OS temp dir is eventually purged regardless, // but explicit deletion avoids accumulation during demo sessions. Future.delayed(const Duration(seconds: 5), () { diff --git a/example/lib/main.dart b/example/lib/main.dart index f3833f2..7ed11f9 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -37,6 +37,7 @@ import 'reader_app/library_screen.dart'; import 'float_hell_demo.dart'; import 'zero_padding_image_demo.dart'; import 'base_url_demo.dart'; +import 'ai_streaming_demo.dart'; /// Optimized base TextStyle for better readability. /// @@ -125,6 +126,16 @@ class DemoHomePage extends StatelessWidget { const SizedBox(height: 8), // โ”€โ”€ Signature Features โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ _buildSectionHeader(context, 'Signature Features'), + _buildDemoCard( + context, + icon: Icons.psychology, + title: 'AI / LLM Streaming Engine (v1.8.0)', + subtitle: + 'Real-time token streaming with 60 FPS throttling, syntax auto-repair, auto-scroll & animated carets', + color: const Color(0xFF10B981), + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const AiStreamingDemo())), + ), _buildDemoCard( context, icon: Icons.explore, @@ -893,7 +904,7 @@ class FloatLayoutDemo extends StatelessWidget { // RUBY DEMO // ============================================================================= -// MED-04: RubyDemo removed โ€” it was dead code (never navigated to from any +// RubyDemo removed โ€” it was dead code (never navigated to from any // screen). Ruby annotation content is covered by MangaDemo and CjkLanguagesDemo. // ============================================================================= diff --git a/example/lib/paged_mode_demo.dart b/example/lib/paged_mode_demo.dart index c211791..63f89db 100644 --- a/example/lib/paged_mode_demo.dart +++ b/example/lib/paged_mode_demo.dart @@ -121,7 +121,7 @@ class _PageBarState extends State<_PageBar> { super.dispose(); } - // CRIT-01: Guard against setState after dispose โ€” the ValueNotifier listener + // Guard against setState after dispose โ€” the ValueNotifier listener // can fire during Flutter's teardown sequence (e.g. page transition animation) // after dispose() has already run, causing "setState called after dispose". void _onPageChanged() { diff --git a/example/lib/performance_deep_dive_demo.dart b/example/lib/performance_deep_dive_demo.dart index 2a59db2..bf20633 100644 --- a/example/lib/performance_deep_dive_demo.dart +++ b/example/lib/performance_deep_dive_demo.dart @@ -482,7 +482,7 @@ class _IsolateParsingTabState extends State<_IsolateParsingTab> { void _measureSync() { final sw = Stopwatch()..start(); - // MED-06: addPostFrameCallback measures time to the NEXT FRAME COMPLETION, + // addPostFrameCallback measures time to the NEXT FRAME COMPLETION, // not true parse time. It includes frame scheduling latency (~4โ€“16 ms) on // top of the actual parse duration. This is intentional for this demo โ€” // it shows total "time to first pixel" which is what users perceive โ€” @@ -496,7 +496,7 @@ class _IsolateParsingTabState extends State<_IsolateParsingTab> { void _measureAsync() { final sw = Stopwatch()..start(); - // MED-06: Same note โ€” measures time to next frame, not isolate parse time. + // Same note โ€” measures time to next frame, not isolate parse time. // For async mode the callback fires after the UI thread resumes, which can // be significantly later than when the isolate finished. WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/example/lib/reader_app/reader_screen.dart b/example/lib/reader_app/reader_screen.dart index cd146f2..9c63295 100644 --- a/example/lib/reader_app/reader_screen.dart +++ b/example/lib/reader_app/reader_screen.dart @@ -75,12 +75,12 @@ class _ReaderScreenState extends State { super.initState(); _currentBook = widget.book; _pageController = HyperPageController(initialPage: _currentBook.lastPage); - // CRIT-01: listener removed in dispose(); mounted guard inside callback. + // listener removed in dispose(); mounted guard inside callback. _pageController.currentPage.addListener(_onPageChanged); } void _onPageChanged() { - // CRIT-01: ValueNotifier callbacks can fire during Flutter teardown. + // ValueNotifier callbacks can fire during Flutter teardown. if (!mounted) return; final newPage = _pageController.currentPage.value; if (_currentBook.lastPage != newPage) { diff --git a/example/lib/stress_test_demo.dart b/example/lib/stress_test_demo.dart index 4ef1181..5bb1953 100644 --- a/example/lib/stress_test_demo.dart +++ b/example/lib/stress_test_demo.dart @@ -985,7 +985,7 @@ class _StressTestDemoState extends State { html: content, mode: HyperRenderMode.auto, selectable: true, - // CRIT-03: Explicit sanitize:true even though it is the default. + // Explicit sanitize:true even though it is the default. // This demo renders arbitrary user-supplied URLs โ€” keeping sanitize // explicit makes the security posture clear to anyone reading the code // and prevents accidental removal when copy-pasting to production. diff --git a/example/pubspec.lock b/example/pubspec.lock index e5aa182..5eb7999 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -350,21 +350,21 @@ packages: path: ".." relative: true source: path - version: "1.7.1" + version: "1.8.0" hyper_render_core: dependency: "direct main" description: path: "../packages/hyper_render_core" relative: true source: path - version: "1.7.0" + version: "1.8.0" hyper_render_epub: dependency: "direct main" description: path: "../packages/hyper_render_epub" relative: true source: path - version: "0.1.1" + version: "0.1.2" hyper_render_highlight: dependency: "direct overridden" description: diff --git a/example/test/all_demos_smoke_test.dart b/example/test/all_demos_smoke_test.dart index 89596c9..d44ec5c 100644 --- a/example/test/all_demos_smoke_test.dart +++ b/example/test/all_demos_smoke_test.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:example/accessibility_demo.dart'; +import 'package:example/ai_streaming_demo.dart'; import 'package:example/animation_demo.dart'; import 'package:example/base_url_demo.dart'; import 'package:example/cjk_languages_demo.dart'; @@ -43,6 +44,7 @@ import 'package:example/v2_1_showcase.dart'; /// Each entry: a label and a builder for the demo's screen widget. final _demos = { 'AccessibilityDemo': () => const AccessibilityDemo(), + 'AiStreamingDemo': () => const AiStreamingDemo(), 'AnimationDemo': () => const AnimationDemo(), 'BaseUrlDemo': () => const BaseUrlDemo(), 'CjkLanguagesDemo': () => const CjkLanguagesDemo(), diff --git a/lib/hyper_render.dart b/lib/hyper_render.dart index daf9760..a468a98 100644 --- a/lib/hyper_render.dart +++ b/lib/hyper_render.dart @@ -148,6 +148,13 @@ export 'package:hyper_render_core/hyper_render_core.dart' HyperNodePlugin, HyperPluginRegistry, HyperPluginBuildContext, + // Streaming (v1.8.0) + HyperStreamingController, + HyperStreamingState, + HyperStreamingStatus, + StreamSyntaxNormalizer, + HyperTypingCaret, + HyperTypingCaretStyle, // Loading / error UI LoadingSkeleton, HyperErrorWidget, diff --git a/lib/src/widgets/hyper_viewer.dart b/lib/src/widgets/hyper_viewer.dart index 0e89f2b..a81d6ea 100644 --- a/lib/src/widgets/hyper_viewer.dart +++ b/lib/src/widgets/hyper_viewer.dart @@ -469,6 +469,36 @@ class HyperViewer extends StatefulWidget { /// ``` final HyperImageLoader? imageLoader; + /// Optional streaming controller for real-time AI and LLM token feeds. + /// + /// When non-null, [HyperViewer] listens to tokens from [streamingController], + /// batch-renders them with frame throttling, auto-repairs in-flight markdown/HTML, + /// displays the typing caret, and maintains stick-to-bottom scroll. + final HyperStreamingController? streamingController; + + /// Whether to auto-repair transient incomplete syntax tokens (e.g. unclosed + /// code blocks or bold asterisks) during in-flight streaming. + /// + /// Default: true. + final bool autoRepairSyntax; + + /// Whether to automatically follow the stream tail and scroll down as new tokens + /// arrive. + /// + /// Default: true. + final bool autoScrollToBottom; + + /// Whether to display an animated typing cursor at the tail while streaming is active. + /// + /// Default: true. + final bool showTypingCaret; + + /// Visual style of the typing caret. + final HyperTypingCaretStyle caretStyle; + + /// Color of the typing caret (defaults to theme primary color). + final Color? caretColor; + /// Creates a HyperViewer for HTML content (default) /// /// ```dart @@ -523,6 +553,12 @@ class HyperViewer extends StatefulWidget { this.imageLoader, }) : content = html, contentType = HyperContentType.html, + streamingController = null, + autoRepairSyntax = false, + autoScrollToBottom = false, + showTypingCaret = false, + caretStyle = HyperTypingCaretStyle.bar, + caretColor = null, _prebuiltDocument = null; /// Creates a HyperViewer for Quill Delta JSON content @@ -575,6 +611,12 @@ class HyperViewer extends StatefulWidget { this.imageLoader, }) : content = delta, contentType = HyperContentType.delta, + streamingController = null, + autoRepairSyntax = false, + autoScrollToBottom = false, + showTypingCaret = false, + caretStyle = HyperTypingCaretStyle.bar, + caretColor = null, _prebuiltDocument = null; /// Creates a HyperViewer for Markdown content @@ -627,6 +669,76 @@ class HyperViewer extends StatefulWidget { this.imageLoader, }) : content = markdown, contentType = HyperContentType.markdown, + streamingController = null, + autoRepairSyntax = false, + autoScrollToBottom = false, + showTypingCaret = false, + caretStyle = HyperTypingCaretStyle.bar, + caretColor = null, + _prebuiltDocument = null; + + /// Creates a [HyperViewer] for real-time AI and LLM streaming token feeds. + /// + /// Listens to [streamingController], automatically repairs incomplete syntactic tokens + /// on-the-fly, displays an animated [HyperTypingCaret], and smoothly auto-scrolls down. + /// + /// ```dart + /// final controller = HyperStreamingController(); + /// controller.bindStream(geminiResponseStream); + /// + /// HyperViewer.streaming( + /// streamingController: controller, + /// contentType: HyperContentType.markdown, + /// ) + /// ``` + const HyperViewer.streaming({ + super.key, + required this.streamingController, + this.contentType = HyperContentType.markdown, + this.autoRepairSyntax = true, + this.autoScrollToBottom = true, + this.showTypingCaret = true, + this.caretStyle = HyperTypingCaretStyle.bar, + this.caretColor, + this.mode = HyperRenderMode.sync, + this.selectable = true, + this.onLinkTap, + this.allowedCustomSchemes, + this.widgetBuilder, + this.placeholderBuilder, + this.fallbackBuilder, + this.enableZoom = false, + this.minScale = 0.5, + this.maxScale = 4.0, + this.contentParser, + this.codeHighlighter, + this.showSelectionMenu = true, + this.selectionHandleColor, + this.selectionColor, + this.selectionMenuActionsBuilder, + this.selectionContextMenuBuilder, + this.sanitize = true, + this.textDirection, + this.textScaler, + this.allowedTags, + this.allowDataAttributes = false, + this.semanticLabel, + this.excludeSemantics = false, + this.baseUrl, + this.customCss, + this.debugShowHyperRenderBounds = false, + this.enableComplexFilters = true, + this.captureKey, + this.shrinkWrap = false, + this.physics, + this.onError, + this.controller, + this.pageController, + this.pluginRegistry, + this.onMemoryPressure, + this.renderConfig = HyperRenderConfig.defaults, + this.imageLoader, + }) : content = '', _prebuiltDocument = null; /// Creates a [HyperViewer] from a pre-parsed [DocumentNode], skipping @@ -671,6 +783,12 @@ class HyperViewer extends StatefulWidget { this.imageLoader, }) : content = '', contentType = HyperContentType.html, + streamingController = null, + autoRepairSyntax = false, + autoScrollToBottom = false, + showTypingCaret = false, + caretStyle = HyperTypingCaretStyle.bar, + caretColor = null, mode = HyperRenderMode.sync, placeholderBuilder = null, fallbackBuilder = null, @@ -719,7 +837,14 @@ class _HyperViewerState extends State late final AnimationController _contentFadeController; late final Animation _contentFadeAnimation; - // โ”€โ”€ CRIT-03: Global text-cache-size ref-counting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + // Whether the content fade has already played once for the current + // streaming session, so a throttled mid-stream tick (as often as every + // ~16ms) doesn't perpetually restart a 300ms fade and make content + // flicker/never settle to full opacity. Only consulted when + // `widget.streamingController != null`. + bool _streamingFadeStarted = false; + + // โ”€โ”€ Global text-cache-size ref-counting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // // RenderHyperBox._globalTextPainters is a process-wide static LRU cache. // Each HyperViewer registers its desired textPainterCacheSize here; when it @@ -867,14 +992,27 @@ class _HyperViewerState extends State ); } + /// Internal [ScrollController] used when [widget.autoScrollToBottom] is true + /// and no external [widget.controller] is provided. + ScrollController? _internalScrollController; + + /// Effective scroll controller for sync / streaming view. + ScrollController? get _effectiveScrollController => + widget.controller?.scrollController ?? _internalScrollController; + + /// The raw, unparsed content to render: the live streaming buffer when a + /// [HyperStreamingController] is attached (`.streaming()` hardcodes + /// `widget.content` to `''`), otherwise the static [widget.content]. + String get _rawContent => widget.streamingController?.text ?? widget.content; + @override void initState() { super.initState(); - // CRIT-03: Use ref-counted helper so multiple HyperViewers with different + // Use ref-counted helper so multiple HyperViewers with different // cache sizes don't clobber each other. _ownedTextCacheSize = widget.renderConfig.textPainterCacheSize; _acquireTextCacheSize(_ownedTextCacheSize); - // CRIT-01: Wire imageConcurrency config to the singleton image queue. + // Wire imageConcurrency config to the singleton image queue. LazyImageQueue.instance.maxConcurrent = widget.renderConfig.imageConcurrency; WidgetsBinding.instance.addObserver(this); @@ -895,14 +1033,67 @@ class _HyperViewerState extends State if (widget.mode == HyperRenderMode.paged && widget.pageController == null) { _ownedPageController = PageController(); } + if (widget.controller == null && widget.autoScrollToBottom) { + _internalScrollController = ScrollController(); + } + widget.streamingController?.addListener(_onStreamingStateChanged); + _parseContent(); + } + + void _onStreamingStateChanged() { + if (!mounted) return; + // A reset() controller goes back to `idle` before streaming again โ€” that + // is semantically a new message, so the fade should replay for it. + if (widget.streamingController?.status == HyperStreamingStatus.idle) { + _streamingFadeStarted = false; + } _parseContent(); + _scrollToBottomIfApplicable(); + } + + void _scrollToBottomIfApplicable() { + if (!widget.autoScrollToBottom) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final scrollCtrl = _effectiveScrollController; + if (scrollCtrl != null && scrollCtrl.hasClients) { + final position = scrollCtrl.position; + if (position.maxScrollExtent > 0) { + final distanceToBottom = position.maxScrollExtent - position.pixels; + if (distanceToBottom < 350) { + scrollCtrl.animateTo( + position.maxScrollExtent, + duration: const Duration(milliseconds: 100), + curve: Curves.easeOut, + ); + } + } + } + }); } @override void didUpdateWidget(covariant HyperViewer oldWidget) { super.didUpdateWidget(oldWidget); - // BUG-02: Handle selectable toggle โ€” create/dispose controller as needed. + if (widget.controller == null && + widget.autoScrollToBottom && + _internalScrollController == null) { + _internalScrollController = ScrollController(); + } else if (widget.controller != null && _internalScrollController != null) { + _internalScrollController?.dispose(); + _internalScrollController = null; + } + + if (oldWidget.streamingController != widget.streamingController) { + oldWidget.streamingController?.removeListener(_onStreamingStateChanged); + widget.streamingController?.addListener(_onStreamingStateChanged); + // A new controller instance starts a new streaming session for fade + // purposes, even if it already has accumulated text. + _streamingFadeStarted = false; + } + + // Handle selectable toggle โ€” create/dispose controller as needed. if (oldWidget.selectable != widget.selectable) { if (widget.selectable) { _virtualizedSelectionController = VirtualizedSelectionController( @@ -915,7 +1106,7 @@ class _HyperViewerState extends State } } - // BUG-05: When customCss changes the section hashes are stale (they only + // When customCss changes the section hashes are stale (they only // cover text, not styles). Reset them so every section re-layouts. if (oldWidget.customCss != widget.customCss) { _sectionHashes = const []; @@ -923,13 +1114,13 @@ class _HyperViewerState extends State if (oldWidget.renderConfig.textPainterCacheSize != widget.renderConfig.textPainterCacheSize) { - // CRIT-03: Use ref-counted helper to avoid clobbering peer HyperViewers. + // Use ref-counted helper to avoid clobbering peer HyperViewers. _releaseTextCacheSize(_ownedTextCacheSize); _ownedTextCacheSize = widget.renderConfig.textPainterCacheSize; _acquireTextCacheSize(_ownedTextCacheSize); } - // CRIT-01: Keep imageConcurrency in sync with config. + // Keep imageConcurrency in sync with config. if (oldWidget.renderConfig.imageConcurrency != widget.renderConfig.imageConcurrency) { LazyImageQueue.instance.maxConcurrent = @@ -951,16 +1142,19 @@ class _HyperViewerState extends State !listEquals(oldWidget.allowedTags, widget.allowedTags) || oldWidget.allowDataAttributes != widget.allowDataAttributes || oldWidget.fallbackBuilder != widget.fallbackBuilder || - // BUG-08: Compare full renderConfig (value equality now available). + // Compare full renderConfig (value equality now available). oldWidget.renderConfig != widget.renderConfig || - oldWidget.pluginRegistry != widget.pluginRegistry) { + oldWidget.pluginRegistry != widget.pluginRegistry || + oldWidget.streamingController != widget.streamingController || + oldWidget.autoRepairSyntax != widget.autoRepairSyntax) { _parseContent(); } } @override void dispose() { - // CRIT-03: Release our size from the global ref-count so peer HyperViewers + widget.streamingController?.removeListener(_onStreamingStateChanged); + // Release our size from the global ref-count so peer HyperViewers // see the correct maximum cache size after we're gone. _releaseTextCacheSize(_ownedTextCacheSize); WidgetsBinding.instance.removeObserver(this); @@ -968,6 +1162,7 @@ class _HyperViewerState extends State _contentFadeController.dispose(); _virtualizedSelectionController?.dispose(); _ownedPageController?.dispose(); + _internalScrollController?.dispose(); super.dispose(); } @@ -1293,7 +1488,7 @@ class _HyperViewerState extends State // Relative URLs (scheme == '') are always forwarded โ€” the app's handler // is responsible for resolving them against a base URL. final isRelative = scheme.isEmpty; - // BUG-03/09: Check BOTH allowedCustomSchemes (legacy widget param) AND + // Check BOTH allowedCustomSchemes (legacy widget param) AND // renderConfig.extraLinkSchemes so neither registration path silently // drops deep-link taps. final customSchemes = widget.allowedCustomSchemes; @@ -1349,6 +1544,22 @@ class _HyperViewerState extends State return {...base, ...registry.registeredTags}.toList(); } + // Gate the fade so a throttled mid-stream tick (as often as every ~16ms) + // doesn't restart the 300ms fade before it can settle to full opacity โ€” + // it should only play once per streaming session. Non-streaming content + // swaps are unaffected and keep fading on every parse as before. + void _beginContentFade() { + if (widget.streamingController != null && _streamingFadeStarted) return; + _contentFadeController.reset(); + } + + void _completeContentFade() { + _contentFadeController.forward(); + if (widget.streamingController != null) { + _streamingFadeStarted = true; + } + } + void _parseContent() { // Fast path: pre-parsed AST โ€” skip all parsing. if (widget._prebuiltDocument != null) { @@ -1377,7 +1588,17 @@ class _HyperViewerState extends State // RenderObject belonging to the previous document. _sectionBoxes.clear(); - String contentToRender = widget.content; + String contentToRender = _rawContent; + + if (widget.streamingController != null && widget.autoRepairSyntax) { + if (widget.contentType == HyperContentType.markdown) { + contentToRender = + StreamSyntaxNormalizer.normalizeMarkdown(contentToRender); + } else if (widget.contentType == HyperContentType.html) { + contentToRender = StreamSyntaxNormalizer.normalizeHtml(contentToRender); + } + } + // CSS collected from