From 234fcd4383c0bcf9632db385141fd7ca9d1f2e9d 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 02:09:52 +0700 Subject: [PATCH] fix(core): dark-surface colors for links, h6, code and mark (#23) On a dark Theme only the default text color followed the theme; the built-in link (#1976D2), h6 (#6B7280) and pre>code (#0550AE) colors measured 4.1, 3.9 and 2.5:1 against #121212, and the inline code / mark chips were bright light blocks on a dark page. StyleResolver.darkSurface swaps those UA colors for variants that keep >= 4.5:1 (link 8.5, h6 7.4, pre code 9.6 on #121212; code 7.2 and mark 6.9 on their own dark chips). HyperViewer enables it when the effective default text color is light, i.e. judged by the text color rather than the Theme: a dark Theme with a dark textColor (a white pane) keeps the light-surface palette, a light Theme with a light textColor gets the dark one. Off by default, so light output is unchanged. Author CSS and inline styles are applied after and still win; an without href is untouched. Tests (test/dark_ua_colors_test.dart): contrast of the resolved style per element on page and card surfaces, the exact light palette, the white-pane and dark-pane cases, toggles, author CSS precedence, the compute() isolate path, markdown, and a pixel test. Mutation-checked. --- CHANGELOG.md | 4 + README.md | 2 +- lib/src/widgets/hyper_viewer.dart | 7 + packages/hyper_render_core/CHANGELOG.md | 4 + .../lib/src/style/resolver.dart | 41 +++ test/dark_ua_colors_test.dart | 279 ++++++++++++++++++ 6 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 test/dark_ua_colors_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 8720e95..116a81c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- **Dark surfaces: links, `
`, `` / `
` and `` now use dark-surface colors** ([#23](https://github.com/brewkits/hyper_render/issues/23)). In 1.11.0 only the default text color followed a dark theme; these built-in colors were still tuned for light surfaces and measured 4.1:1 (links), 3.9:1 (`
`) and **2.5:1** (`
`) on `#121212`. They now keep at least 4.5:1, and the inline `` / `` chips become dark chips instead of bright blocks. The palette is chosen from the *effective text color*: light text means a dark surface, so a dark `Theme` with `textColor: Colors.black87` (a white pane) keeps the light-surface palette, and a light `Theme` with a light `textColor` gets the dark one. Light-surface output is unchanged. Author CSS and inline styles still win. `
`, `` and table headers keep their light backgrounds (their text is kept readable by the 1.11.0 contrast guard). Requires the matching `hyper_render_core` (`StyleResolver.darkSurface`). + ## 1.11.0 - **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. diff --git a/README.md b/README.md index ad8ca47..b761425 100644 --- a/README.md +++ b/README.md @@ -262,7 +262,7 @@ Under a dark `Theme`, unstyled text uses `colorScheme.onSurface` and re-resolves HyperViewer(html: html, textColor: Colors.black87) ``` -Known limitation: link blue, inline `` / `
` colors and the `` highlight are still tuned for light surfaces.
+Links, `
`, `` / `
` and `` switch to dark-surface variants (at least 4.5:1 against `#121212`) whenever the effective text color is light — a dark `Theme`, or a light `textColor`. A dark `Theme` with a dark `textColor` (a white email pane) keeps the light-surface palette. Your own CSS (`a { color }`, inline `style`) still wins. `
`, `` and table headers keep their light backgrounds; their text stays readable but they remain light blocks on a dark page. `textColor` 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`. `body { color }` and `html { color }` are honoured; other `body` properties are not. diff --git a/lib/src/widgets/hyper_viewer.dart b/lib/src/widgets/hyper_viewer.dart index 0e4fe4e..541b910 100644 --- a/lib/src/widgets/hyper_viewer.dart +++ b/lib/src/widgets/hyper_viewer.dart @@ -1121,6 +1121,13 @@ class _HyperViewerState extends State // (blockquote, kbd, th, author backgrounds) into white-on-near-white. resolver.ensureReadableOnOwnBackground = themeArgb != null || overrideArgb != null; + // Light text means a dark surface: switch the built-in link / code / mark / + // h6 colors to their dark-surface variants. Judged by the effective text + // color, not the theme, so a dark Theme with `textColor: black87` (a white + // email pane) correctly keeps the light-surface palette. + final effective = overrideArgb ?? themeArgb; + resolver.darkSurface = + effective != null && Color(effective).computeLuminance() > 0.5; resolver.resolveStyles( doc, baseStyle: diff --git a/packages/hyper_render_core/CHANGELOG.md b/packages/hyper_render_core/CHANGELOG.md index 739736a..23b2e87 100644 --- a/packages/hyper_render_core/CHANGELOG.md +++ b/packages/hyper_render_core/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — hyper_render_core +## Unreleased + +- **`StyleResolver.darkSurface`** (default `false`) — switches the built-in link, `
`, ``, `
` and `` colors to dark-surface variants with at least 4.5:1 contrast on `#121212` ([#23](https://github.com/brewkits/hyper_render/issues/23)). `HyperViewer` sets it whenever its effective default text color is light. Author CSS still wins; an `` without `href` is untouched.
+
 ## 1.11.0
 
 - **`StyleResolver.rootColorOverride`** — a host-supplied root color, applied to the document root after the content's own `html` / `:root` / `body` color so an app can override a publisher stylesheet. Element-level colors still win. `HyperViewer(textColor:)` sets it.
diff --git a/packages/hyper_render_core/lib/src/style/resolver.dart b/packages/hyper_render_core/lib/src/style/resolver.dart
index 703f6c9..f0a148d 100644
--- a/packages/hyper_render_core/lib/src/style/resolver.dart
+++ b/packages/hyper_render_core/lib/src/style/resolver.dart
@@ -662,6 +662,46 @@ class StyleResolver {
   /// host-supplied color is unchanged. An element's own `color` always wins.
   bool ensureReadableOnOwnBackground = false;
 
+  /// When true, the built-in element colors that were picked for a light
+  /// surface — links, ``, ``, `
` — use dark-surface variants + /// that keep at least 4.5:1 contrast on a typical dark page (`#121212`). + /// + /// Set it when the text sits on a dark surface. `HyperViewer` does so + /// whenever its effective default text color is light (a dark [Theme], or a + /// light `textColor`). Off by default, so output is unchanged for everyone + /// who does not opt in. Author CSS (`a { color }`, inline `style`) is applied + /// after these and still wins. + bool darkSurface = false; + + // Dark-surface variants of the UA colors. WCAG contrast, measured: on #121212 + // (#1E1E1E cards in parentheses) link 8.5 (7.5), h6 7.4 (6.6), pre code + // 9.6 (8.6); inline code 7.2 and mark 6.9 against their own chip. + static const Color _darkLink = Color(0xFF64B5F6); + static const Color _darkH6 = Color(0xFF9CA3AF); + static const Color _darkCode = Color(0xFF79C0FF); + static const Color _darkCodeBg = Color(0xFF262C36); + static const Color _darkMark = Color(0xFFFDE68A); + static const Color _darkMarkBg = Color(0xFF5C4A00); + + /// Replaces a UA style's light-surface colors with their dark variants. + void _applyDarkSurfaceColors(ComputedStyle style, String tagName) { + switch (tagName) { + case 'a': + style.color = _darkLink; + case 'h6': + style.color = _darkH6; + case 'code': + style.color = _darkCode; + style.backgroundColor = _darkCodeBg; + case 'mark': + style.color = _darkMark; + style.backgroundColor = _darkMarkBg; + default: + return; + } + style.markExplicitlySet('color'); + } + static const Color _guardDark = Color(0xFF1F2937); static const Color _guardLight = Color(0xFFFFFFFF); @@ -736,6 +776,7 @@ class StyleResolver { (node.attributes['href'] == null || node.attributes['href']!.isEmpty); if (!isHreflessAnchor) { style = _mergeStyles(style, _userAgentStyles[tagName]!); + if (darkSurface) _applyDarkSurfaceColors(style, tagName); } } diff --git a/test/dark_ua_colors_test.dart b/test/dark_ua_colors_test.dart new file mode 100644 index 0000000..867ae82 --- /dev/null +++ b/test/dark_ua_colors_test.dart @@ -0,0 +1,279 @@ +// Issue #23: link /
 / 
(and the code / mark chips) were fixed +// light-surface colors. They now switch to dark-surface variants whenever the +// effective default text color is light. +// +// Contrast is asserted on the RESOLVED style against the surface color, plus one +// pixel test for the case the numbers cannot prove (the text is really painted +// visibly). The light-surface palette is asserted EXACTLY so a regression that +// shifts the unchanged light output is caught here, not only by goldens. +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 _html = '
six

link inline ' + 'marked

block code
' + '
not a link'; + +double _contrast(Color a, Color b) { + final x = a.computeLuminance(), y = b.computeLuminance(); + final hi = x > y ? x : y, lo = x > y ? y : x; + return (hi + 0.05) / (lo + 0.05); +} + +UDTNode? _find(UDTNode n, bool Function(UDTNode) test) { + if (test(n)) return n; + for (final c in n.children) { + final f = _find(c, test); + if (f != null) return f; + } + return null; +} + +Future _doc( + WidgetTester t, { + required Brightness brightness, + Color? textColor, + String? css, + String html = _html, + HyperRenderMode mode = HyperRenderMode.sync, + HyperRenderConfig config = const HyperRenderConfig(useMicrotaskParsing: true), +}) async { + await t.pumpWidget(MaterialApp( + theme: ThemeData(brightness: brightness), + home: Scaffold( + body: HyperViewer( + html: html, + textColor: textColor, + customCss: css, + mode: mode, + renderConfig: config, + ), + ), + )); + await t.pumpAndSettle(); + return t + .widget(find.byType(HyperRenderWidget).first) + .document; +} + +ComputedStyle _style(DocumentNode d, String tag, {String? inside}) { + final n = _find(d, (n) { + if (n.tagName != tag) return false; + return inside == null || n.parent?.tagName == inside; + }); + expect(n, isNotNull, reason: '<$tag> not found'); + return n!.style; +} + +const _surface = Color(0xFF121212); +const _card = Color(0xFF1E1E1E); + +void main() { + group('dark Theme: readable built-in colors (>= 4.5:1)', () { + testWidgets('link, h6 and
 against the page', (t) async {
+      final d = await _doc(t, brightness: Brightness.dark);
+      for (final (tag, inside) in [
+        ('a', null),
+        ('h6', null),
+        ('code', 'pre')
+      ]) {
+        final c = _style(d, tag, inside: inside).color;
+        expect(_contrast(c, _surface), greaterThanOrEqualTo(4.5),
+            reason: '<$tag> $c on page');
+        expect(_contrast(c, _card), greaterThanOrEqualTo(4.5),
+            reason: '<$tag> $c on a card');
+      }
+    });
+
+    testWidgets(
+        'inline  and  are readable on their own chip and '
+        'no longer a glaring light block', (t) async {
+      final d = await _doc(t, brightness: Brightness.dark);
+      for (final tag in ['mark']) {
+        final s = _style(d, tag);
+        expect(
+            _contrast(s.color, s.backgroundColor!), greaterThanOrEqualTo(4.5));
+        expect(s.backgroundColor!.computeLuminance(), lessThan(0.2),
+            reason: '<$tag> chip must be dark on a dark page');
+      }
+      final code = _style(d, 'code', inside: 'p');
+      expect(_contrast(code.color, code.backgroundColor!),
+          greaterThanOrEqualTo(4.5));
+      expect(code.backgroundColor!.computeLuminance(), lessThan(0.2));
+    });
+
+    testWidgets('an  without href stays plain text (no link color)',
+        (t) async {
+      final d = await _doc(t, brightness: Brightness.dark);
+      final plain =
+          _find(d, (n) => n.tagName == 'a' && n.attributes['href'] == null)!;
+      expect(plain.style.color, ThemeData.dark().colorScheme.onSurface);
+    });
+  });
+
+  group('light surfaces keep the exact original palette', () {
+    testWidgets('light Theme, no textColor', (t) async {
+      final d = await _doc(t, brightness: Brightness.light);
+      expect(_style(d, 'a').color, const Color(0xFF1976D2));
+      expect(_style(d, 'h6').color, const Color(0xFF6B7280));
+      final code = _style(d, 'code', inside: 'p');
+      expect(code.color, const Color(0xFF0550AE));
+      expect(code.backgroundColor, const Color(0xFFF6F8FA));
+      final mark = _style(d, 'mark');
+      expect(mark.color, const Color(0xFF713F12));
+      expect(mark.backgroundColor, const Color(0xFFFEF08A));
+    });
+
+    testWidgets(
+        'dark Theme + dark textColor (a white email pane) keeps the '
+        'light-surface palette — switching would put light blue on white',
+        (t) async {
+      final d =
+          await _doc(t, brightness: Brightness.dark, textColor: Colors.black87);
+      expect(_style(d, 'a').color, const Color(0xFF1976D2));
+      expect(_style(d, 'h6').color, const Color(0xFF6B7280));
+      expect(_style(d, 'code', inside: 'p').backgroundColor,
+          const Color(0xFFF6F8FA));
+    });
+  });
+
+  group('the surface is judged by the effective text color', () {
+    testWidgets(
+        'light Theme + light textColor (a dark pane) uses the dark '
+        'variants', (t) async {
+      final d =
+          await _doc(t, brightness: Brightness.light, textColor: Colors.white);
+      expect(
+          _contrast(_style(d, 'a').color, _surface), greaterThanOrEqualTo(4.5));
+      expect(_style(d, 'a').color, isNot(const Color(0xFF1976D2)));
+    });
+
+    testWidgets('a theme toggle switches the palette (silent re-parse)',
+        (t) async {
+      Widget app(Brightness b) => MaterialApp(
+            theme: ThemeData(brightness: b),
+            home: const Scaffold(
+              body: HyperViewer(html: _html, mode: HyperRenderMode.sync),
+            ),
+          );
+      DocumentNode doc() => t
+          .widget(find.byType(HyperRenderWidget).first)
+          .document;
+      await t.pumpWidget(app(Brightness.light));
+      await t.pumpAndSettle();
+      expect(_style(doc(), 'a').color, const Color(0xFF1976D2));
+      await t.pumpWidget(app(Brightness.dark));
+      await t.pumpAndSettle();
+      expect(_style(doc(), 'a').color, isNot(const Color(0xFF1976D2)));
+      await t.pumpWidget(app(Brightness.light));
+      await t.pumpAndSettle();
+      expect(_style(doc(), 'a').color, const Color(0xFF1976D2));
+    });
+  });
+
+  group('author CSS still wins over the dark variants', () {
+    testWidgets('customCss and inline style', (t) async {
+      final d = await _doc(t,
+          brightness: Brightness.dark,
+          css: 'a { color: #ff0000; }',
+          html:
+              '

link

' + 'inline

six
'); + final links = []; + void walk(UDTNode n) { + if (n.tagName == 'a') links.add(n); + n.children.forEach(walk); + } + + walk(d); + expect(links[0].style.color, const Color(0xFFFF0000)); + expect(links[1].style.color, const Color(0xFF00FF00)); + expect(_style(d, 'h6').color, const Color(0xFF0000FF)); + }); + }); + + group('every parse path', () { + testWidgets('virtualized compute() isolate carries the dark surface', + (t) async { + await t.pumpWidget(MaterialApp( + theme: ThemeData.dark(), + home: const Scaffold( + body: HyperViewer( + html: _html, + mode: HyperRenderMode.virtualized, + renderConfig: HyperRenderConfig(useMicrotaskParsing: false), + ), + ), + )); + for (var i = 0; + i < 50 && find.byType(HyperRenderWidget).evaluate().isEmpty; + i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await t.pump(); + } + await t.pump(const Duration(milliseconds: 400)); + final d = t + .widget(find.byType(HyperRenderWidget).first) + .document; + expect( + _contrast(_style(d, 'a').color, _surface), greaterThanOrEqualTo(4.5)); + }); + + testWidgets('markdown links on a dark Theme', (t) async { + await t.pumpWidget(MaterialApp( + theme: ThemeData.dark(), + home: const Scaffold( + body: HyperViewer.markdown(markdown: '[a link](https://example.com)'), + ), + )); + await t.pumpAndSettle(); + final d = t + .widget(find.byType(HyperRenderWidget).first) + .document; + expect( + _contrast(_style(d, 'a').color, _surface), greaterThanOrEqualTo(4.5)); + }); + }); + + testWidgets( + 'pixels: a link on a dark surface is painted with the new color ' + 'and is clearly visible', (t) async { + final key = GlobalKey(); + await t.pumpWidget(MaterialApp( + theme: ThemeData.dark(), + home: Scaffold( + backgroundColor: _surface, + body: RepaintBoundary( + key: key, + child: const SizedBox( + width: 300, + height: 80, + child: HyperViewer( + html: '

LINKLINKLINK

', + mode: HyperRenderMode.sync, + ), + ), + ), + ), + )); + await t.pumpAndSettle(); + final (bright, oldBlue) = (await t.runAsync(() async { + final b = key.currentContext!.findRenderObject() as RenderRepaintBoundary; + final d = (await (await b.toImage()).toByteData())!; + var bright = 0, old = 0; + for (var i = 0; i < d.lengthInBytes; i += 4) { + final r = d.getUint8(i), g = d.getUint8(i + 1), bl = d.getUint8(i + 2); + if (r + g + bl > 450) bright++; + // The old #1976D2, anti-aliased: blue-dominant and darker. + if (bl > 150 && r < 60 && g > 90 && g < 140) old++; + } + return (bright, old); + }))!; + expect(bright, greaterThan(100), + reason: 'link text must be clearly bright'); + expect(oldBlue, lessThan(bright ~/ 4), + reason: 'the dark-on-dark #1976D2 must be gone'); + }); +}