Adjust ANSI colors in logs and console for theme legibility (#10060)

* Adjust ANSI colors in logs and console for theme legibility

ANSI colors are fixed terminal colors, so some of them (e.g. ansi-black,
ansi-white, ansi-bright-white) are effectively invisible against the
corresponding light or dark themed background (see #6345).

textSpansFromAnsi now takes an optional background color, which all
console/logging call sites pass as the theme surface. Foreground colors
whose contrast ratio against that background is below 3:1 are blended
toward black (on a light background) or white (on a dark background) to
restore legibility; sufficiently contrasting colors are left untouched.
Background ANSI colors are not adjusted since blending into the surface
does not harm legibility.

Adds unit tests for the contrast adjustment behavior.

Fixes #6345

* Extract magic numbers in _adjustForContrast into named constants

Per code review feedback and the DevTools style guide.

* Add release note for ANSI color legibility improvements

* Remove unnecessary import from utils.dart

---------

Co-authored-by: awaisdev5765 <awaisdev5765@users.noreply.github.com>
Co-authored-by: Sam Rawlins <sam.rawlins@gmail.com>
diff --git a/packages/devtools_app/lib/src/screens/logging/_message_column.dart b/packages/devtools_app/lib/src/screens/logging/_message_column.dart
index ead7f9f..23b63b2 100644
--- a/packages/devtools_app/lib/src/screens/logging/_message_column.dart
+++ b/packages/devtools_app/lib/src/screens/logging/_message_column.dart
@@ -74,6 +74,7 @@
                         //  The current summary is generally the first 200 chars of details.
                         data.summary!,
                         theme.regularTextStyle,
+                        backgroundColor: theme.colorScheme.surface,
                       ),
                     if (hasSummary && hasDetails())
                       TextSpan(text: '  •  ', style: theme.subtleTextStyle),
@@ -81,6 +82,7 @@
                       ...textSpansFromAnsi(
                         detailsComputed ? data.details! : '<fetching>',
                         theme.subtleTextStyle,
+                        backgroundColor: theme.colorScheme.surface,
                       ),
                   ],
                 ),
diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_instance_display.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_instance_display.dart
index 7c2f36b..f9fc8ed 100644
--- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_instance_display.dart
+++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_instance_display.dart
@@ -171,6 +171,7 @@
               children: textSpansFromAnsi(
                 variable.text ?? '',
                 theme.subtleFixedFontStyle,
+                backgroundColor: theme.colorScheme.surface,
               ),
             ),
           ),
diff --git a/packages/devtools_app/lib/src/shared/console/console.dart b/packages/devtools_app/lib/src/shared/console/console.dart
index 53a8adc..0a04528 100644
--- a/packages/devtools_app/lib/src/shared/console/console.dart
+++ b/packages/devtools_app/lib/src/shared/console/console.dart
@@ -196,6 +196,7 @@
                           children: textSpansFromAnsi(
                             line.text,
                             theme.regularTextStyle,
+                            backgroundColor: theme.colorScheme.surface,
                           ),
                         ),
                       );
diff --git a/packages/devtools_app/lib/src/shared/console/widgets/display_provider.dart b/packages/devtools_app/lib/src/shared/console/widgets/display_provider.dart
index b8d398b..ec3932d 100644
--- a/packages/devtools_app/lib/src/shared/console/widgets/display_provider.dart
+++ b/packages/devtools_app/lib/src/shared/console/widgets/display_provider.dart
@@ -75,6 +75,7 @@
             children: textSpansFromAnsi(
               widget.variable.text!,
               theme.subtleFixedFontStyle,
+              backgroundColor: theme.colorScheme.surface,
             ),
           ),
         ),
diff --git a/packages/devtools_app/lib/src/shared/primitives/utils.dart b/packages/devtools_app/lib/src/shared/primitives/utils.dart
index e40cf69..10b90e3 100644
--- a/packages/devtools_app/lib/src/shared/primitives/utils.dart
+++ b/packages/devtools_app/lib/src/shared/primitives/utils.dart
@@ -12,7 +12,6 @@
 import 'package:collection/collection.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/services.dart';
-import 'package:flutter/widgets.dart';
 import 'package:intl/intl.dart';
 import 'package:logging/logging.dart';
 import 'package:material_ui/material_ui.dart';
@@ -545,7 +544,11 @@
   }
 }
 
-List<TextSpan> textSpansFromAnsi(String input, TextStyle defaultStyle) {
+List<TextSpan> textSpansFromAnsi(
+  String input,
+  TextStyle defaultStyle, {
+  Color? backgroundColor,
+}) {
   final parser = AnsiParser(input);
   return parser.parse().map((entry) {
     final styled = entry.bold || entry.fgColor != null || entry.bgColor != null;
@@ -553,7 +556,7 @@
       text: entry.text,
       style: styled
           ? TextStyle(
-              color: ansiToColor(entry.fgColor),
+              color: ansiToColor(entry.fgColor, background: backgroundColor),
               backgroundColor: ansiToColor(entry.bgColor),
               fontWeight: entry.bold ? FontWeight.bold : FontWeight.normal,
             )
@@ -562,13 +565,45 @@
   }).toList();
 }
 
-Color? ansiToColor(List<int>? ansiInput) {
+Color? ansiToColor(List<int>? ansiInput, {Color? background}) {
   if (ansiInput == null) {
     return null;
   }
 
   assert(ansiInput.length == 3, 'Ansi color list should contain 3 elements');
-  return Color.fromRGBO(ansiInput[0], ansiInput[1], ansiInput[2], 1);
+  final color = Color.fromRGBO(ansiInput[0], ansiInput[1], ansiInput[2], 1);
+  if (background == null) {
+    return color;
+  }
+  return _adjustForContrast(color, background);
+}
+
+/// Returns [color] adjusted so that it remains legible against [background].
+///
+/// ANSI colors are fixed terminal colors, and some of them (e.g. ansi-black,
+/// ansi-white, ansi-bright-white) are effectively invisible against the
+/// corresponding light or dark themed background. When the contrast between
+/// [color] and [background] is too low, blend the color toward black (on a
+/// light background) or white (on a dark background) to restore legibility.
+Color _adjustForContrast(Color color, Color background) {
+  // A 3:1 contrast ratio is sufficient for terminal-style output (WCAG AA
+  // large-text threshold).
+  const minContrastRatio = 3.0;
+  const contrastBlendFactor = 0.6;
+
+  final fgLuminance = color.computeLuminance();
+  final bgLuminance = background.computeLuminance();
+  final contrastRatio =
+      (max(fgLuminance, bgLuminance) + 0.05) /
+      (min(fgLuminance, bgLuminance) + 0.05);
+  if (contrastRatio >= minContrastRatio) {
+    return color;
+  }
+  return Color.lerp(
+    color,
+    bgLuminance > 0.5 ? Colors.black : Colors.white,
+    contrastBlendFactor,
+  )!;
 }
 
 /// An extension on [LogicalKeySet] to provide user-facing names for key
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 979b0ff..86b2a49 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -17,6 +17,11 @@
 
 TODO: Remove this section if there are not any updates.
 
+* Improved the readability of ANSI-colored text in the console and logging
+screens: colors that are hard to see against the current light or dark theme,
+such as black or white, are now adjusted to remain legible.
+[#10060](https://github.com/flutter/devtools/pull/10060)
+
 ## Inspector updates
 
 TODO: Remove this section if there are not any updates.
diff --git a/packages/devtools_app/test/shared/primitives/utils_test.dart b/packages/devtools_app/test/shared/primitives/utils_test.dart
index 98581e5..a1ad897 100644
--- a/packages/devtools_app/test/shared/primitives/utils_test.dart
+++ b/packages/devtools_app/test/shared/primitives/utils_test.dart
@@ -11,6 +11,59 @@
 
 void main() {
   group('utils', () {
+    group('textSpansFromAnsi', () {
+      const defaultStyle = TextStyle();
+      // Theme surface colors used by DevTools (see devtools_app_shared).
+      const lightSurface = Color(0xFFFFFFFF);
+      const darkSurface = Color(0xFF1B1B1F);
+
+      test('adjusts low-contrast foreground for a light background', () {
+        // ansi-white is invisible on a white background.
+        final spans = textSpansFromAnsi(
+          '\x1B[37mhello\x1B[0m',
+          defaultStyle,
+          backgroundColor: lightSurface,
+        );
+        final color = spans.single.style!.color!;
+        expect(color.computeLuminance(), lessThan(1.0));
+        expect(color, isNot(equals(lightSurface)));
+      });
+
+      test('adjusts low-contrast foreground for a dark background', () {
+        // ansi-black is invisible on a near-black background.
+        final spans = textSpansFromAnsi(
+          '\x1B[30mhello\x1B[0m',
+          defaultStyle,
+          backgroundColor: darkSurface,
+        );
+        final color = spans.single.style!.color!;
+        expect(
+          color.computeLuminance(),
+          greaterThan(darkSurface.computeLuminance()),
+        );
+      });
+
+      test('keeps colors that already have sufficient contrast', () {
+        // ansi-red is legible on a white background and must be unchanged.
+        final spans = textSpansFromAnsi(
+          '\x1B[31mhello\x1B[0m',
+          defaultStyle,
+          backgroundColor: lightSurface,
+        );
+        expect(
+          spans.single.style!.color,
+          equals(const Color.fromRGBO(187, 0, 0, 1)),
+        );
+      });
+
+      test('does not adjust colors when no background is provided', () {
+        final spans = textSpansFromAnsi('\x1B[37mhello\x1B[0m', defaultStyle);
+        expect(
+          spans.single.style!.color,
+          equals(const Color.fromRGBO(255, 255, 255, 1)),
+        );
+      });
+    });
     group('durationText', () {
       test('infers unit based on duration', () {
         expect(durationText(Duration.zero), equals('0 μs'));