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'); + }); +}