From 76809749bd73726caa88d41b7249201041e3e042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Sat, 3 Oct 2026 21:49:19 +0700 Subject: [PATCH 1/6] fix: readable text on dark surfaces; body/html color; :root matches root only (#20) HyperViewer's unstyled text was always the fixed dark gray #1F2937 with no way to change it, so it was unreadable on dark surfaces. - Dark Theme: default text color is colorScheme.onSurface. Light theme output is unchanged. A theme toggle re-resolves in place (no loading state, scroll position kept). - New HyperViewer(textColor:), a host override that wins over the content's html/:root/body color but not over an element's own color. - body { color } and html { color } now apply (no UDT node is tagged body or html, so they never matched). Layered html < :root < body. - :root matched every top-level block because it tested parent == null, which top-level blocks also satisfy; it now tests node is DocumentNode. - StyleResolver.ensureReadableOnOwnBackground: an element with its own opaque background and no color of its own no longer inherits text under 3:1 contrast (blockquote, kbd, th, author backgrounds). Enabled only when the host supplies a default color. - customCss doc example used body { font-size }, which is still ignored. --- CHANGELOG.md | 10 + lib/src/widgets/hyper_viewer.dart | 137 ++++- packages/hyper_render_core/CHANGELOG.md | 7 + .../lib/src/style/resolver.dart | 134 ++++- test/dark_mode_text_color_test.dart | 539 ++++++++++++++++++ 5 files changed, 812 insertions(+), 15 deletions(-) create mode 100644 test/dark_mode_text_color_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index d6dd067..6af156c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- **Dark mode: `HyperViewer` text is readable on dark surfaces** ([#20](https://github.com/brewkits/hyper_render/issues/20)). Unstyled text was always the fixed dark gray `#1F2937`, with no way to change it from `HyperViewer`. Under a `Brightness.dark` `Theme` the default is now `colorScheme.onSurface`; under a light theme nothing changes. A theme toggle re-resolves styles in place (no loading state, scroll position kept). `EpubReader` goes through `HyperViewer`, so it follows the theme the same way. +- **Built-in light surfaces stay readable.** `
`, ``, `` and any author `background` without a `color` assume dark text; once a light default is in play they would inherit white on near-white. An element with its own opaque background and no color of its own now falls back to dark gray / white when the inherited text would be under 3:1 contrast. Only active when a host-supplied color exists (dark theme or `textColor`), so light-theme output without `textColor` is unchanged. An element's own `color` always wins. +- **New `HyperViewer(textColor:)`** — a host override of the document's text color. It wins over the content's own `html` / `:root` / `body` color (so an app can force a reader theme over a publisher stylesheet) but not over an element's own color (`p { color }`, inline `style`). Not available on `HyperViewer.fromNode`. +- **`body { color }` and `html { color }` now work.** The adapters only keep ``'s children, so those selectors could never match and were silently ignored. They now set the document root color, layered as in a browser (`html` < `:root` < `body`). **Behavior change:** content that declares `body { color: … }` (EPUB stylesheets often do) now renders in that color. Only `color` is honoured; other `body` properties (`display`, `margin`, `background`) are still ignored. Consequence: a publisher `body { color: #000 }` stays black on a dark theme. `HyperViewer(textColor:)` overrides it; `EpubReader` has no `textColor` parameter yet, so until it gets one pass `customCss: 'body { color: #fff !important }'` (chapter `$_html', + css: 'body { color: #ffffff !important; }'); + expect(px.light, greaterThan(200), reason: '$px'); + expect(px.red, lessThan(50), reason: '$px'); + }); + }); + + group('theme toggle in virtualized mode', () { + testWidgets('keeps the scroll offset and shows no placeholder', (t) async { + final long = List.generate(400, (i) => '

Paragraph $i text

').join(); + var placeholders = 0; + Widget app(Brightness b) => MaterialApp( + theme: ThemeData(brightness: b), + home: Scaffold( + body: SizedBox( + width: 400, + height: 300, + child: HyperViewer( + html: long, + mode: HyperRenderMode.virtualized, + placeholderBuilder: (_) { + placeholders++; + return const SizedBox(); + }, + renderConfig: const HyperRenderConfig( + useMicrotaskParsing: true, + virtualizationChunkSize: 1000, + ), + ), + ), + ), + ); + ScrollPosition position() => + t.state(find.byType(Scrollable).first).position; + double offset() => position().pixels; + await t.pumpWidget(app(Brightness.light)); + await t.pumpAndSettle(); + expect(find.byType(ListView), findsOneWidget); + position().jumpTo(1500); + await t.pumpAndSettle(); + final before = offset(); + expect(before, greaterThan(1000)); + final placeholdersBefore = placeholders; + + await t.pumpWidget(app(Brightness.dark)); + await t.pumpAndSettle(); + expect(offset(), closeTo(before, 1.0), + reason: 'a theme toggle must not reset the reader to the top'); + expect(placeholders, placeholdersBefore, + reason: 'no loading placeholder frame on a theme toggle'); + }); + }); +} From 7a20b134bbee193d49be9b01919339a0cef17e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Sat, 3 Oct 2026 22:58:30 +0700 Subject: [PATCH 2/6] fix(core): block plugins on custom tags were laid out at 0x0 and never painted A custom tag (, ) has no UA display style, so the HTML adapters build it as an InlineNode. _handleInlineNode only consulted the inline plugin tags, so a registered BLOCK plugin on such a tag never reached _tokenizeBlockPlugin: its widget was built, linked to no fragment, sized 0x0 and not painted ('More child widgets than fragments' in debug). A registered block tag is now handled as a block whatever its node type. The existing plugin tests hand-built BlockNodes or asserted findsOneWidget, which a 0x0 widget satisfies. The new tests assert size, position between the neighbouring paragraphs, painted pixels, stacking, virtualized mode, that the plugin owns its children, and that unregistered and inline plugins are unaffected. --- packages/hyper_render_core/CHANGELOG.md | 4 + .../lib/src/core/render_hyper_box_layout.dart | 10 + test/block_plugin_custom_tag_test.dart | 207 ++++++++++++++++++ 3 files changed, 221 insertions(+) create mode 100644 test/block_plugin_custom_tag_test.dart diff --git a/packages/hyper_render_core/CHANGELOG.md b/packages/hyper_render_core/CHANGELOG.md index 55f9345..65b6f44 100644 --- a/packages/hyper_render_core/CHANGELOG.md +++ b/packages/hyper_render_core/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — hyper_render_core +## Unreleased + +- **Block-tier plugins on custom tags now render.** ``, `` and any other tag without a UA display style are built by the HTML adapters as inline nodes, so a registered **block** plugin on them never took the block path: its widget was built, linked to no fragment, laid out at 0×0 and never painted, with "Layout Warning: More child widgets than fragments" in debug. Only tags that are already block (`figure`, `div`) worked. A registered block tag is now treated as a block whatever its node type. Existing tests passed because they hand-built `BlockNode`s or only asserted `findsOneWidget`, which a 0×0 widget satisfies. + ## 1.10.0 ### 🆕 New diff --git a/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart b/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart index d01f115..6f2e25f 100644 --- a/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart +++ b/packages/hyper_render_core/lib/src/core/render_hyper_box_layout.dart @@ -106,6 +106,16 @@ extension _RenderHyperBoxLayout on RenderHyperBox { } void _handleInlineNode(UDTNode node) { + // A custom tag (``, ``) has no UA display style, so the + // adapters build it as an InlineNode even when its plugin is block-tier. + // Routing it through the inline path never emits a fragment for the plugin + // widget: the widget was built but never linked, laid out at 0x0 and never + // painted. A registered block tag is a block whatever its node type. + if (_blockPluginTags.isNotEmpty && + _blockPluginTags.contains(node.tagName?.toLowerCase())) { + _tokenizeBlockPlugin(node); + return; + } if (_inlinePluginTags.isNotEmpty && _inlinePluginTags.contains(node.tagName?.toLowerCase())) { _fragments.add(Fragment.atomic( diff --git a/test/block_plugin_custom_tag_test.dart b/test/block_plugin_custom_tag_test.dart new file mode 100644 index 0000000..0db3173 --- /dev/null +++ b/test/block_plugin_custom_tag_test.dart @@ -0,0 +1,207 @@ +// A block-tier plugin on a CUSTOM tag (``, ``), reached the +// documented way — HyperViewer(html:, pluginRegistry:). The adapters build a +// custom tag as an InlineNode (no UA display style), so the block-plugin path +// was never taken: the widget was built, linked to no fragment, laid out at +// 0x0 and never painted ("Layout Warning: More child widgets than fragments"). +// +// The older plugin tests constructed `BlockNode(tagName: 'figure')` by hand or +// used `findsOneWidget`, which a 0x0 widget satisfies — so none could see it. +// These assert size, position and pixels. +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render/hyper_render.dart'; + +const _amber = Color(0xFFFFC107); + +class _Block implements HyperNodePlugin { + const _Block(this.tag, {this.height = 40}); + final String tag; + final double height; + @override + List get tagNames => [tag]; + @override + bool get isInline => false; + @override + Widget? buildWidget(UDTNode node, HyperPluginBuildContext ctx) => SizedBox( + key: ValueKey(tag), + height: height, + child: const ColoredBox(color: _amber), + ); +} + +Future<({List warnings, GlobalKey capture})> _pump( + WidgetTester t, + String html, + List plugins, { + HyperRenderMode mode = HyperRenderMode.sync, +}) async { + final warnings = []; + final reg = HyperPluginRegistry(); + for (final p in plugins) { + reg.register(p); + } + final key = GlobalKey(); + // flutter_test fails a test that leaves `debugPrint` replaced, and tearDown + // callbacks run after that check — so restore it here, not in addTearDown. + final old = debugPrint; + debugPrint = (m, {wrapWidth}) { + if (m != null && m.contains('More child widgets than fragments')) { + warnings.add(m); + } + }; + try { + await t.pumpWidget(MaterialApp( + home: Scaffold( + backgroundColor: Colors.white, + body: RepaintBoundary( + key: key, + child: SizedBox( + width: 400, + height: 400, + child: HyperViewer( + html: html, + pluginRegistry: reg, + mode: mode, + renderConfig: const HyperRenderConfig(useMicrotaskParsing: true), + ), + ), + ), + ), + )); + await t.pumpAndSettle(); + } finally { + debugPrint = old; + } + return (warnings: warnings, capture: key); +} + +/// Top offset of each text fragment, keyed by its text. +Map _textTops(WidgetTester t) { + RenderHyperBox? box; + void walk(RenderObject o) { + if (o is RenderHyperBox) box ??= o; + o.visitChildren(walk); + } + + walk(t.renderObject(find.byType(HyperRenderWidget).first)); + return { + for (final f in box!.debugFragments()) + if (f['text'] != null && f['offsetY'] != null) + (f['text'] as String).trim(): (f['offsetY'] as num).toDouble(), + }; +} + +Future _amberPixels(WidgetTester t, GlobalKey key) async => + (await t.runAsync(() async { + final b = key.currentContext!.findRenderObject() as RenderRepaintBoundary; + final d = (await (await b.toImage()).toByteData())!; + var n = 0; + for (var i = 0; i < d.lengthInBytes; i += 4) { + if (d.getUint8(i) == 255 && + d.getUint8(i + 1) == 193 && + d.getUint8(i + 2) == 7) { + n++; + } + } + return n; + }))!; + +void main() { + for (final html in [ + '

Before

After

', + '

Before

content the plugin owns

After

', + ]) { + testWidgets( + 'block plugin on a custom tag is sized, placed and painted: ' + '$html', (t) async { + final r = await _pump(t, html, [const _Block('x-a')]); + final f = find.byKey(const ValueKey('x-a')); + expect(f, findsOneWidget); + + // Full available width, its own height. + expect(t.getSize(f), const Size(400, 40)); + + // Between the two paragraphs, not stacked on top of either. Text is + // painted on the canvas, so read its position from the render object. + final y = _textTops(t); + final top = t.getTopLeft(f).dy; + expect(top, greaterThan(y['Before']!)); + expect(y['After']!, greaterThanOrEqualTo(top + 40 - 0.5)); + + expect(await _amberPixels(t, r.capture), 400 * 40, + reason: 'the plugin widget must actually paint'); + expect(r.warnings, isEmpty); + }); + } + + testWidgets('the plugin owns its children: their text is not also rendered', + (t) async { + await _pump( + t, '

Before

SECRET-CHILD', [const _Block('x-a')]); + expect( + find.textContaining('SECRET-CHILD', findRichText: true), findsNothing); + }); + + testWidgets('three adjacent block plugins stack, none overlapping', + (t) async { + await _pump(t, '', [ + const _Block('x-a'), + const _Block('x-b', height: 30), + const _Block('x-c', height: 20) + ]); + final ys = [ + for (final k in ['x-a', 'x-b', 'x-c']) + t.getTopLeft(find.byKey(ValueKey(k))).dy, + ]; + expect(ys[1], greaterThanOrEqualTo(ys[0] + 40 - 0.5)); + expect(ys[2], greaterThanOrEqualTo(ys[1] + 30 - 0.5)); + }); + + testWidgets('works in virtualized mode too', (t) async { + final r = await _pump( + t, '

Before

After

', [const _Block('x-a')], + mode: HyperRenderMode.virtualized); + expect(t.getSize(find.byKey(const ValueKey('x-a'))), const Size(400, 40)); + expect(r.warnings, isEmpty); + }); + + testWidgets( + 'an unregistered custom tag is unaffected (no widget, no warning)', + (t) async { + final r = await _pump(t, '

Before

inline text', []); + expect(find.byKey(const ValueKey('x-zzz')), findsNothing); + expect(r.warnings, isEmpty); + }); + + testWidgets('an INLINE plugin on a custom tag still flows with text', + (t) async { + final reg = HyperPluginRegistry()..register(const _InlineChip()); + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + width: 400, + child: HyperViewer( + html: '

Status: done

', + pluginRegistry: reg, + mode: HyperRenderMode.sync, + ), + ), + ), + )); + await t.pumpAndSettle(); + expect(find.byKey(const ValueKey('chip')), findsOneWidget); + expect(t.getSize(find.byKey(const ValueKey('chip'))).width, greaterThan(0)); + }); +} + +class _InlineChip implements HyperNodePlugin { + const _InlineChip(); + @override + List get tagNames => const ['x-chip']; + @override + bool get isInline => true; + @override + Widget? buildWidget(UDTNode node, HyperPluginBuildContext ctx) => + const SizedBox(key: ValueKey('chip'), width: 40, height: 16); +} From 029302efcb0ced66018058541e88779992684282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Sat, 3 Oct 2026 22:59:03 +0700 Subject: [PATCH 3/6] release prep 1.11.0: EpubReader(textColor:), docs, hardening tests, demo fixes - hyper_render_epub 0.1.3: EpubReader(textColor:) forwarded to HyperViewer; requires hyper_render/core ^1.11.0. - Versions: hyper_render 1.11.0, hyper_render_core 1.11.0. CHANGELOGs, README (Dark Mode section, API table), MIGRATION_GUIDE, CSS matrix, ROADMAP. - test/integration/dark_mode_hardening_test.dart: security (hostile body colors, var() bomb, 5000-rule sheet), stress (40 theme toggles in sync / virtualized / paged, in-flight parse, streaming) and performance. - example: DEMO_THEME=dark|light define for the all-demos drive; fix a RenderFlex overflow in the accessibility demo (controls now scroll and are capped) and ListTile-inside-ColoredBox assertions in the security and accessibility demos (Material instead of Container(color:)). - customCss dartdoc now says the document's own $_html', + css: 'body { color: #ffffff; }'); + expect(plain.red, greaterThan(200), + reason: 'the document wins at equal priority: $plain'); + + final forced = await _shoot(t, brightness: Brightness.dark, surface: Colors.black, html: '$_html', css: 'body { color: #ffffff !important; }'); - expect(px.light, greaterThan(200), reason: '$px'); - expect(px.red, lessThan(50), reason: '$px'); + expect(forced.light, greaterThan(200), reason: '$forced'); + expect(forced.red, lessThan(50), reason: '$forced'); }); }); diff --git a/test/integration/dark_mode_hardening_test.dart b/test/integration/dark_mode_hardening_test.dart index eafe576..5db2336 100644 --- a/test/integration/dark_mode_hardening_test.dart +++ b/test/integration/dark_mode_hardening_test.dart @@ -83,9 +83,10 @@ void main() { expect(doc.children.length, 2); }); - test('a body color cannot reach another document through the resolver', () { - // Resolvers are per-parse, but root-color state lives on the instance: - // re-using one for a second document must not leak the first one's rule. + test('a reused resolver keeps its body rule, a fresh one starts clean', () { + // Root-color state lives on the resolver instance: re-using it applies + // the same rules to the next document (documented behaviour), while a + // new resolver must not inherit anything. final r = StyleResolver()..parseCss('body { color: #ff0000; }'); final a = HtmlAdapter().parse('

a

'); r.resolveStyles(a); From 41b12d9760de71d3dff1207a3ce0c292d83ff91e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguye=CC=82=CC=83n=20Tua=CC=82=CC=81n=20Vie=CC=A3=CC=82t?= Date: Sun, 4 Oct 2026 01:33:44 +0700 Subject: [PATCH 6/6] test: sample the timing assertion more robustly (best of 9, called once per round) The 3x bound was measured at ~1.1x in isolation (body/html rules 1.02x, override 1.09x, guard 1.12x) but read 2.07x once under build load with only 5 samples taken by an awkward double call. Take the minimum of 9 single runs per variant so a busy runner does not flake it. --- .../integration/dark_mode_hardening_test.dart | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/test/integration/dark_mode_hardening_test.dart b/test/integration/dark_mode_hardening_test.dart index 5db2336..5528bf8 100644 --- a/test/integration/dark_mode_hardening_test.dart +++ b/test/integration/dark_mode_hardening_test.dart @@ -232,17 +232,21 @@ void main() { return sw.elapsedMicroseconds; } - // Warm up, then take the best of several runs to damp scheduler noise. - run('p { margin: 4px; }', null); - var base = 1 << 30, rooted = 1 << 30; - for (var i = 0; i < 5; i++) { - base = base < run('p { margin: 4px; }', null) - ? base - : run('p { margin: 4px; }', null); - rooted = rooted < run(css, const Color(0xFFABCDEF)) - ? rooted - : run(css, const Color(0xFFABCDEF)); + // Warm up, then take the best of 9 runs each: the minimum is the sample + // least disturbed by a busy CI runner. Measured cost is ~1.1x; the 3x + // bound only has to catch an accidental O(rules) or O(nodes^2) walk. + int best(String stylesheet, Color? override) { + run(stylesheet, override); + var m = 1 << 30; + for (var i = 0; i < 9; i++) { + final v = run(stylesheet, override); + if (v < m) m = v; + } + return m; } + + final base = best('p { margin: 4px; }', null); + final rooted = best(css, const Color(0xFFABCDEF)); expect(rooted, lessThan(base * 3 + 5000), reason: 'base=${base}us rooted=${rooted}us'); });