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