Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## Unreleased

- **Dark surfaces: links, `<h6>`, `<code>` / `<pre>` and `<mark>` 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 (`<h6>`) and **2.5:1** (`<pre><code>`) on `#121212`. They now keep at least 4.5:1, and the inline `<code>` / `<mark>` 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. `<blockquote>`, `<kbd>` 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<code>` / `<pre>` colors and the `<mark>` highlight are still tuned for light surfaces.
Links, `<h6>`, `<code>` / `<pre>` and `<mark>` 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. `<blockquote>`, `<kbd>` 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.

Expand Down
7 changes: 7 additions & 0 deletions lib/src/widgets/hyper_viewer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1121,6 +1121,13 @@ class _HyperViewerState extends State<HyperViewer>
// (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:
Expand Down
4 changes: 4 additions & 0 deletions packages/hyper_render_core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog — hyper_render_core

## Unreleased

- **`StyleResolver.darkSurface`** (default `false`) — switches the built-in link, `<h6>`, `<code>`, `<pre><code>` and `<mark>` 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 `<a>` 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.
Expand Down
41 changes: 41 additions & 0 deletions packages/hyper_render_core/lib/src/style/resolver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<code>`, `<mark>`, `<h6>` — 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);

Expand Down Expand Up @@ -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);
}
}

Expand Down
279 changes: 279 additions & 0 deletions test/dark_ua_colors_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
// Issue #23: link / <pre><code> / <h6> (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 = '<h6>six</h6><p><a href="#">link</a> <code>inline</code> '
'<mark>marked</mark></p><pre><code>block code</code></pre>'
'<a name="anchor">not a link</a>';

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<DocumentNode> _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<HyperRenderWidget>(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 <pre><code> 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 <code> and <mark> 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 <a> 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<HyperRenderWidget>(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:
'<p><a href="#">link</a></p><p><a href="#" style="color:#00ff00">'
'inline</a></p><h6 style="color:#0000ff">six</h6>');
final links = <UDTNode>[];
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<void>.delayed(const Duration(milliseconds: 20)));
await t.pump();
}
await t.pump(const Duration(milliseconds: 400));
final d = t
.widget<HyperRenderWidget>(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<HyperRenderWidget>(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: '<p><a href="#">LINKLINKLINK</a></p>',
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');
});
}
Loading