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
diff --git a/packages/devtools_app/lib/src/shared/ui/side_panel.dart b/packages/devtools_app/lib/src/shared/ui/side_panel.dart
index 16d1943..9174f92 100644
--- a/packages/devtools_app/lib/src/shared/ui/side_panel.dart
+++ b/packages/devtools_app/lib/src/shared/ui/side_panel.dart
@@ -182,6 +182,17 @@
: Expanded(
child: Markdown(
data: markdownData!,
+ styleSheet: MarkdownStyleSheet(
+ // [MarkdownStyleSheet.fromTheme], which supplies the
+ // rest of the style sheet, hard codes
+ // `Colors.blue.shade100` as the blockquote fill while
+ // taking the text color from the theme. In the dark
+ // theme that draws light gray text on light blue.
+ blockquoteDecoration: BoxDecoration(
+ color: theme.colorScheme.secondaryContainer,
+ borderRadius: defaultBorderRadius,
+ ),
+ ),
onTapLink: (text, url, title) =>
unawaited(launchUrlWithErrorHandling(url!)),
),
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 9bbf8ec..dedd867 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -15,7 +15,9 @@
## General updates
-TODO: Remove this section if there are not any updates.
+* Fixed unreadable text in the release notes panel, where blockquotes were
+ drawn on a hard coded light blue background in the dark theme.
+ [#9957](https://github.com/flutter/devtools/pull/9957)
## Inspector updates
diff --git a/packages/devtools_app/test/shared/ui/side_panel_test.dart b/packages/devtools_app/test/shared/ui/side_panel_test.dart
new file mode 100644
index 0000000..8d003a0
--- /dev/null
+++ b/packages/devtools_app/test/shared/ui/side_panel_test.dart
@@ -0,0 +1,102 @@
+// Copyright 2026 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
+
+import 'package:devtools_app/devtools_app.dart';
+import 'package:devtools_app_shared/ui.dart';
+import 'package:devtools_app_shared/utils.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+/// The smallest contrast ratio WCAG 2.1 accepts for body text at level AA.
+///
+/// See https://www.w3.org/TR/WCAG21/#contrast-minimum.
+const _minimumContrastRatio = 4.5;
+
+void main() {
+ setUp(() {
+ setGlobal(IdeTheme, IdeTheme());
+ });
+
+ group('$SidePanelViewer', () {
+ // `MarkdownStyleSheet.fromTheme` fills blockquotes with
+ // `Colors.blue.shade100` but takes the text color from the theme, so a
+ // release note that opens with a blockquote drew `onSurface` text on light
+ // blue in the dark theme.
+ // Regression test for https://github.com/flutter/devtools/issues/9945.
+ for (final useDarkTheme in [true, false]) {
+ final themeName = useDarkTheme ? 'dark' : 'light';
+ testWidgets('blockquote text is legible in the $themeName theme', (
+ tester,
+ ) async {
+ const summary = 'Release notes for Dart and Flutter DevTools.';
+ final controller = SidePanelController();
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: themeFor(
+ isDarkTheme: useDarkTheme,
+ ideTheme: IdeTheme(),
+ theme: ThemeData(
+ useMaterial3: true,
+ colorScheme: useDarkTheme ? darkColorScheme : lightColorScheme,
+ ),
+ ),
+ home: SidePanelViewer(controller: controller),
+ ),
+ );
+ controller.markdown.value = '# Release notes\n\n> $summary';
+ controller.toggleVisibility(true);
+ await tester.pumpAndSettle();
+
+ final summaryFinder = find.byWidgetPredicate(
+ (widget) =>
+ widget is RichText && widget.text.toPlainText() == summary,
+ );
+ expect(summaryFinder, findsOneWidget);
+
+ final textColor = _colorOfSpan(
+ tester.widget<RichText>(summaryFinder).text,
+ summary,
+ );
+ final fillColor = tester
+ .widgetList<DecoratedBox>(
+ find.ancestor(
+ of: summaryFinder,
+ matching: find.byType(DecoratedBox),
+ ),
+ )
+ .map((box) => box.decoration)
+ .whereType<BoxDecoration>()
+ .map((decoration) => decoration.color)
+ .nonNulls
+ .first;
+
+ expect(
+ _contrastRatio(textColor!, fillColor),
+ greaterThanOrEqualTo(_minimumContrastRatio),
+ );
+ });
+ }
+ });
+}
+
+/// The color the span holding [text] is painted with, or null if [root] holds
+/// no such span.
+Color? _colorOfSpan(InlineSpan root, String text) {
+ Color? color;
+ root.visitChildren((span) {
+ if (span is TextSpan && span.text == text) {
+ color = span.style?.color;
+ return false;
+ }
+ return true;
+ });
+ return color;
+}
+
+/// The WCAG contrast ratio between [a] and [b], from 1 (identical) to 21
+/// (black on white).
+double _contrastRatio(Color a, Color b) {
+ final luminances = [a.computeLuminance(), b.computeLuminance()]..sort();
+ return (luminances.last + 0.05) / (luminances.first + 0.05);
+}