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
11 changes: 11 additions & 0 deletions packages/devtools_app/lib/src/shared/ui/side_panel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,17 @@ class SidePanel extends AnimatedWidget {
: 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,
),
),
Comment on lines +185 to +195

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

[MUST-FIX] Instantiating MarkdownStyleSheet directly via its default constructor will result in a stylesheet where all other text styles (such as p, h1, code, etc.) are null. This causes the Markdown widget to fall back to default unstyled text, losing all theme-derived styles (fonts, sizes, colors) for the rest of the release notes.

To preserve the theme's styles while overriding only the blockquote decoration, use MarkdownStyleSheet.fromTheme(theme).copyWith(...) instead.

Suggested change
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,
),
),
styleSheet: MarkdownStyleSheet.fromTheme(theme).copyWith(
// 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,
),
)
References
  1. The repository style guide requires prefixing comments with a severity category, such as [MUST-FIX] for logical bugs. (link)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The widget merges this sheet over the theme one. flutter_markdown resolves kFallbackStyle(context, widget.styleSheetTheme).merge(widget.styleSheet) before parsing, widget.dart:391 in 0.7.7, and merge keeps every field the override leaves null. I probed this exact sheet on a dark theme, merged.p stays equal to MarkdownStyleSheet.fromTheme(theme).p while the blockquote fill changes. I'm keeping the minimal override.

onTapLink: (text, url, title) =>
unawaited(launchUrlWithErrorHandling(url!)),
),
Expand Down
4 changes: 3 additions & 1 deletion packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ To learn more about DevTools, check out the

## 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

Expand Down
102 changes: 102 additions & 0 deletions packages/devtools_app/test/shared/ui/side_panel_test.dart
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice docs

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);
}
Loading