Skip to content

Commit 644500c

Browse files
Fix unreadable release notes blockquote in dark theme (#9957)
* Fix unreadable release notes blockquote in dark theme MarkdownStyleSheet.fromTheme takes the blockquote text style from the theme but hard codes the fill as Colors.blue.shade100, so the side panel drew onSurface text on light blue. Release notes that open with a blockquote, like 2.60.0, were unreadable in the dark theme. The panel now passes its own blockquote decoration, and widget tests assert the text clears WCAG AA contrast in both themes. * Point the release note entry at the pull request
1 parent f8209c7 commit 644500c

3 files changed

Lines changed: 116 additions & 1 deletion

File tree

packages/devtools_app/lib/src/shared/ui/side_panel.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,17 @@ class SidePanel extends AnimatedWidget {
182182
: Expanded(
183183
child: Markdown(
184184
data: markdownData!,
185+
styleSheet: MarkdownStyleSheet(
186+
// [MarkdownStyleSheet.fromTheme], which supplies the
187+
// rest of the style sheet, hard codes
188+
// `Colors.blue.shade100` as the blockquote fill while
189+
// taking the text color from the theme. In the dark
190+
// theme that draws light gray text on light blue.
191+
blockquoteDecoration: BoxDecoration(
192+
color: theme.colorScheme.secondaryContainer,
193+
borderRadius: defaultBorderRadius,
194+
),
195+
),
185196
onTapLink: (text, url, title) =>
186197
unawaited(launchUrlWithErrorHandling(url!)),
187198
),

packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ To learn more about DevTools, check out the
1515

1616
## General updates
1717

18-
TODO: Remove this section if there are not any updates.
18+
* Fixed unreadable text in the release notes panel, where blockquotes were
19+
drawn on a hard coded light blue background in the dark theme.
20+
[#9957](https://github.com/flutter/devtools/pull/9957)
1921

2022
## Inspector updates
2123

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright 2026 The Flutter Authors
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
4+
5+
import 'package:devtools_app/devtools_app.dart';
6+
import 'package:devtools_app_shared/ui.dart';
7+
import 'package:devtools_app_shared/utils.dart';
8+
import 'package:flutter/material.dart';
9+
import 'package:flutter_test/flutter_test.dart';
10+
11+
/// The smallest contrast ratio WCAG 2.1 accepts for body text at level AA.
12+
///
13+
/// See https://www.w3.org/TR/WCAG21/#contrast-minimum.
14+
const _minimumContrastRatio = 4.5;
15+
16+
void main() {
17+
setUp(() {
18+
setGlobal(IdeTheme, IdeTheme());
19+
});
20+
21+
group('$SidePanelViewer', () {
22+
// `MarkdownStyleSheet.fromTheme` fills blockquotes with
23+
// `Colors.blue.shade100` but takes the text color from the theme, so a
24+
// release note that opens with a blockquote drew `onSurface` text on light
25+
// blue in the dark theme.
26+
// Regression test for https://github.com/flutter/devtools/issues/9945.
27+
for (final useDarkTheme in [true, false]) {
28+
final themeName = useDarkTheme ? 'dark' : 'light';
29+
testWidgets('blockquote text is legible in the $themeName theme', (
30+
tester,
31+
) async {
32+
const summary = 'Release notes for Dart and Flutter DevTools.';
33+
final controller = SidePanelController();
34+
await tester.pumpWidget(
35+
MaterialApp(
36+
theme: themeFor(
37+
isDarkTheme: useDarkTheme,
38+
ideTheme: IdeTheme(),
39+
theme: ThemeData(
40+
useMaterial3: true,
41+
colorScheme: useDarkTheme ? darkColorScheme : lightColorScheme,
42+
),
43+
),
44+
home: SidePanelViewer(controller: controller),
45+
),
46+
);
47+
controller.markdown.value = '# Release notes\n\n> $summary';
48+
controller.toggleVisibility(true);
49+
await tester.pumpAndSettle();
50+
51+
final summaryFinder = find.byWidgetPredicate(
52+
(widget) =>
53+
widget is RichText && widget.text.toPlainText() == summary,
54+
);
55+
expect(summaryFinder, findsOneWidget);
56+
57+
final textColor = _colorOfSpan(
58+
tester.widget<RichText>(summaryFinder).text,
59+
summary,
60+
);
61+
final fillColor = tester
62+
.widgetList<DecoratedBox>(
63+
find.ancestor(
64+
of: summaryFinder,
65+
matching: find.byType(DecoratedBox),
66+
),
67+
)
68+
.map((box) => box.decoration)
69+
.whereType<BoxDecoration>()
70+
.map((decoration) => decoration.color)
71+
.nonNulls
72+
.first;
73+
74+
expect(
75+
_contrastRatio(textColor!, fillColor),
76+
greaterThanOrEqualTo(_minimumContrastRatio),
77+
);
78+
});
79+
}
80+
});
81+
}
82+
83+
/// The color the span holding [text] is painted with, or null if [root] holds
84+
/// no such span.
85+
Color? _colorOfSpan(InlineSpan root, String text) {
86+
Color? color;
87+
root.visitChildren((span) {
88+
if (span is TextSpan && span.text == text) {
89+
color = span.style?.color;
90+
return false;
91+
}
92+
return true;
93+
});
94+
return color;
95+
}
96+
97+
/// The WCAG contrast ratio between [a] and [b], from 1 (identical) to 21
98+
/// (black on white).
99+
double _contrastRatio(Color a, Color b) {
100+
final luminances = [a.computeLuminance(), b.computeLuminance()]..sort();
101+
return (luminances.last + 0.05) / (luminances.first + 0.05);
102+
}

0 commit comments

Comments
 (0)