Automatically adjust ANSI color contrast (#34033)

Richard Feldman created

Closes #33253 in a way that doesn't regress #32175 - namely,
automatically adjusts the contrast between the foreground and background
text in the terminal such that it's above a certain threshold. The
threshold is configurable in settings, and can be set to 0 to turn off
this feature and use exactly the colors the theme specifies even if they
are illegible.

## One Light Theme Before
<img width="220" alt="Screenshot 2025-07-07 at 6 00 47 PM"
src="https://github.com/user-attachments/assets/096754a6-f79f-4fea-a86e-cb7b8ff45d60"
/>

(Last row is highlighted because otherwise the text is unreadable; the
foreground and background are the same color.)

## One Light Theme After

(This is with the new default contrast adjustment setting.)

<img width="215" alt="Screenshot 2025-07-07 at 6 22 02 PM"
src="https://github.com/user-attachments/assets/b082fefe-76f5-4231-b704-ff387983a3cb"
/>

This approach was inspired by @mitchellh's use of automatic contrast
adjustment in [Ghostty](https://ghostty.org/) - thanks, Mitchell! The
main difference is that we're using APCA's formula instead of WCAG for
[these
reasons](https://khan-tw.medium.com/wcag2-are-you-still-using-it-ui-contrast-visibility-standard-readability-contrast-f34eb73e89ee).

Release Notes:

- Added automatic dynamic contrast adjustment for terminal foreground
and background colors

Change summary

assets/settings/default.json                 |  17 
crates/repl/src/outputs/plain.rs             |  14 
crates/terminal/src/terminal_settings.rs     |  29 +
crates/terminal_view/src/color_contrast.rs   | 474 ++++++++++++++++++++++
crates/terminal_view/src/terminal_element.rs | 142 ++++++
crates/terminal_view/src/terminal_view.rs    |   1 
6 files changed, 669 insertions(+), 8 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -1352,7 +1352,7 @@
       // 5. Never show the scrollbar:
       //    "never"
       "show": null
-    }
+    },
     // Set the terminal's font size. If this option is not included,
     // the terminal will default to matching the buffer's font size.
     // "font_size": 15,
@@ -1369,6 +1369,21 @@
     // Default: 10_000, maximum: 100_000 (all bigger values set will be treated as 100_000), 0 disables the scrolling.
     // Existing terminals will not pick up this change until they are recreated.
     // "max_scroll_history_lines": 10000,
+    // The minimum APCA perceptual contrast between foreground and background colors.
+    // APCA (Accessible Perceptual Contrast Algorithm) is more accurate than WCAG 2.x,
+    // especially for dark mode. Values range from 0 to 106.
+    //
+    // Based on APCA Readability Criterion (ARC) Bronze Simple Mode:
+    // https://readtech.org/ARC/tests/bronze-simple-mode/
+    // - 0: No contrast adjustment
+    // - 45: Minimum for large fluent text (36px+)
+    // - 60: Minimum for other content text
+    // - 75: Minimum for body text
+    // - 90: Preferred for body text
+    //
+    // Most terminal themes have APCA values of 40-70.
+    // A value of 45 preserves colorful themes while ensuring legibility.
+    "minimum_contrast": 45
   },
   "code_actions_on_format": {},
   // Settings related to running tasks.

crates/repl/src/outputs/plain.rs 🔗

@@ -25,6 +25,7 @@ use alacritty_terminal::{
 use gpui::{Bounds, ClipboardItem, Entity, FontStyle, TextStyle, WhiteSpace, canvas, size};
 use language::Buffer;
 use settings::Settings as _;
+use terminal::terminal_settings::TerminalSettings;
 use terminal_view::terminal_element::TerminalElement;
 use theme::ThemeSettings;
 use ui::{IntoElement, prelude::*};
@@ -257,8 +258,17 @@ impl Render for TerminalOutput {
                 point: ic.point,
                 cell: ic.cell.clone(),
             });
-        let (cells, rects) =
-            TerminalElement::layout_grid(grid, 0, &text_style, text_system, None, window, cx);
+        let minimum_contrast = TerminalSettings::get_global(cx).minimum_contrast;
+        let (cells, rects) = TerminalElement::layout_grid(
+            grid,
+            0,
+            &text_style,
+            text_system,
+            None,
+            minimum_contrast,
+            window,
+            cx,
+        );
 
         // lines are 0-indexed, so we must add 1 to get the number of lines
         let text_line_height = text_style.line_height_in_pixels(window.rem_size());

crates/terminal/src/terminal_settings.rs 🔗

@@ -49,6 +49,7 @@ pub struct TerminalSettings {
     pub max_scroll_history_lines: Option<usize>,
     pub toolbar: Toolbar,
     pub scrollbar: ScrollbarSettings,
+    pub minimum_contrast: f32,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -229,6 +230,21 @@ pub struct TerminalSettingsContent {
     pub toolbar: Option<ToolbarContent>,
     /// Scrollbar-related settings
     pub scrollbar: Option<ScrollbarSettingsContent>,
+    /// The minimum APCA perceptual contrast between foreground and background colors.
+    ///
+    /// APCA (Accessible Perceptual Contrast Algorithm) is more accurate than WCAG 2.x,
+    /// especially for dark mode. Values range from 0 to 106.
+    ///
+    /// Based on APCA Readability Criterion (ARC) Bronze Simple Mode:
+    /// https://readtech.org/ARC/tests/bronze-simple-mode/
+    /// - 0: No contrast adjustment
+    /// - 45: Minimum for large fluent text (36px+)
+    /// - 60: Minimum for other content text
+    /// - 75: Minimum for body text
+    /// - 90: Preferred for body text
+    ///
+    /// Default: 0 (no adjustment)
+    pub minimum_contrast: Option<f32>,
 }
 
 impl settings::Settings for TerminalSettings {
@@ -237,7 +253,18 @@ impl settings::Settings for TerminalSettings {
     type FileContent = TerminalSettingsContent;
 
     fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> anyhow::Result<Self> {
-        sources.json_merge()
+        let settings: Self = sources.json_merge()?;
+
+        // Validate minimum_contrast for APCA
+        if settings.minimum_contrast < 0.0 || settings.minimum_contrast > 106.0 {
+            anyhow::bail!(
+                "terminal.minimum_contrast must be between 0 and 106, but got {}. \
+                APCA values: 0 = no adjustment, 75 = recommended for body text, 106 = maximum contrast.",
+                settings.minimum_contrast
+            );
+        }
+
+        Ok(settings)
     }
 
     fn import_from_vscode(vscode: &settings::VsCodeSettings, current: &mut Self::FileContent) {

crates/terminal_view/src/color_contrast.rs 🔗

@@ -0,0 +1,474 @@
+use gpui::Hsla;
+
+/// APCA (Accessible Perceptual Contrast Algorithm) constants
+/// Based on APCA 0.0.98G-4g W3 compatible constants
+/// https://github.com/Myndex/apca-w3
+struct APCAConstants {
+    // Main TRC exponent for monitor perception
+    main_trc: f32,
+
+    // sRGB coefficients
+    s_rco: f32,
+    s_gco: f32,
+    s_bco: f32,
+
+    // G-4g constants for use with 2.4 exponent
+    norm_bg: f32,
+    norm_txt: f32,
+    rev_txt: f32,
+    rev_bg: f32,
+
+    // G-4g Clamps and Scalers
+    blk_thrs: f32,
+    blk_clmp: f32,
+    scale_bow: f32,
+    scale_wob: f32,
+    lo_bow_offset: f32,
+    lo_wob_offset: f32,
+    delta_y_min: f32,
+    lo_clip: f32,
+}
+
+impl Default for APCAConstants {
+    fn default() -> Self {
+        Self {
+            main_trc: 2.4,
+            s_rco: 0.2126729,
+            s_gco: 0.7151522,
+            s_bco: 0.0721750,
+            norm_bg: 0.56,
+            norm_txt: 0.57,
+            rev_txt: 0.62,
+            rev_bg: 0.65,
+            blk_thrs: 0.022,
+            blk_clmp: 1.414,
+            scale_bow: 1.14,
+            scale_wob: 1.14,
+            lo_bow_offset: 0.027,
+            lo_wob_offset: 0.027,
+            delta_y_min: 0.0005,
+            lo_clip: 0.1,
+        }
+    }
+}
+
+/// Calculates the perceptual lightness contrast using APCA.
+/// Returns a value between approximately -108 and 106.
+/// Negative values indicate light text on dark background.
+/// Positive values indicate dark text on light background.
+///
+/// The APCA algorithm is more perceptually accurate than WCAG 2.x,
+/// especially for dark mode interfaces. Key improvements include:
+/// - Better accuracy for dark backgrounds
+/// - Polarity-aware (direction matters)
+/// - Perceptually uniform across the range
+///
+/// Common APCA Lc thresholds per ARC Bronze Simple Mode:
+/// https://readtech.org/ARC/tests/bronze-simple-mode/
+/// - Lc 45: Minimum for large fluent text (36px+)
+/// - Lc 60: Minimum for other content text
+/// - Lc 75: Minimum for body text
+/// - Lc 90: Preferred for body text
+///
+/// Most terminal themes use colors with APCA values of 40-70.
+///
+/// https://github.com/Myndex/apca-w3
+pub fn apca_contrast(text_color: Hsla, background_color: Hsla) -> f32 {
+    let constants = APCAConstants::default();
+
+    let text_y = srgb_to_y(text_color, &constants);
+    let bg_y = srgb_to_y(background_color, &constants);
+
+    // Apply soft clamp to near-black colors
+    let text_y_clamped = if text_y > constants.blk_thrs {
+        text_y
+    } else {
+        text_y + (constants.blk_thrs - text_y).powf(constants.blk_clmp)
+    };
+
+    let bg_y_clamped = if bg_y > constants.blk_thrs {
+        bg_y
+    } else {
+        bg_y + (constants.blk_thrs - bg_y).powf(constants.blk_clmp)
+    };
+
+    // Return 0 for extremely low delta Y
+    if (bg_y_clamped - text_y_clamped).abs() < constants.delta_y_min {
+        return 0.0;
+    }
+
+    let sapc;
+    let output_contrast;
+
+    if bg_y_clamped > text_y_clamped {
+        // Normal polarity: dark text on light background
+        sapc = (bg_y_clamped.powf(constants.norm_bg) - text_y_clamped.powf(constants.norm_txt))
+            * constants.scale_bow;
+
+        // Low contrast smooth rollout to prevent polarity reversal
+        output_contrast = if sapc < constants.lo_clip {
+            0.0
+        } else {
+            sapc - constants.lo_bow_offset
+        };
+    } else {
+        // Reverse polarity: light text on dark background
+        sapc = (bg_y_clamped.powf(constants.rev_bg) - text_y_clamped.powf(constants.rev_txt))
+            * constants.scale_wob;
+
+        output_contrast = if sapc > -constants.lo_clip {
+            0.0
+        } else {
+            sapc + constants.lo_wob_offset
+        };
+    }
+
+    // Return Lc (lightness contrast) scaled to percentage
+    output_contrast * 100.0
+}
+
+/// Converts sRGB color to Y (luminance) for APCA calculation
+fn srgb_to_y(color: Hsla, constants: &APCAConstants) -> f32 {
+    let rgba = color.to_rgb();
+
+    // Linearize and apply coefficients
+    let r_linear = (rgba.r).powf(constants.main_trc);
+    let g_linear = (rgba.g).powf(constants.main_trc);
+    let b_linear = (rgba.b).powf(constants.main_trc);
+
+    constants.s_rco * r_linear + constants.s_gco * g_linear + constants.s_bco * b_linear
+}
+
+/// Adjusts the foreground color to meet the minimum APCA contrast against the background.
+/// The minimum_apca_contrast should be an absolute value (e.g., 75 for Lc 75).
+///
+/// This implementation gradually adjusts the lightness while preserving the hue and
+/// saturation as much as possible, only falling back to black/white when necessary.
+pub fn ensure_minimum_contrast(
+    foreground: Hsla,
+    background: Hsla,
+    minimum_apca_contrast: f32,
+) -> Hsla {
+    if minimum_apca_contrast <= 0.0 {
+        return foreground;
+    }
+
+    let current_contrast = apca_contrast(foreground, background).abs();
+
+    if current_contrast >= minimum_apca_contrast {
+        return foreground;
+    }
+
+    // First, try to adjust lightness while preserving hue and saturation
+    let adjusted = adjust_lightness_for_contrast(foreground, background, minimum_apca_contrast);
+
+    let adjusted_contrast = apca_contrast(adjusted, background).abs();
+    if adjusted_contrast >= minimum_apca_contrast {
+        return adjusted;
+    }
+
+    // If that's not enough, gradually reduce saturation while adjusting lightness
+    let desaturated =
+        adjust_lightness_and_saturation_for_contrast(foreground, background, minimum_apca_contrast);
+
+    let desaturated_contrast = apca_contrast(desaturated, background).abs();
+    if desaturated_contrast >= minimum_apca_contrast {
+        return desaturated;
+    }
+
+    // Last resort: use black or white
+    let black = Hsla {
+        h: 0.0,
+        s: 0.0,
+        l: 0.0,
+        a: foreground.a,
+    };
+
+    let white = Hsla {
+        h: 0.0,
+        s: 0.0,
+        l: 1.0,
+        a: foreground.a,
+    };
+
+    let black_contrast = apca_contrast(black, background).abs();
+    let white_contrast = apca_contrast(white, background).abs();
+
+    if white_contrast > black_contrast {
+        white
+    } else {
+        black
+    }
+}
+
+/// Adjusts only the lightness to meet the minimum contrast, preserving hue and saturation
+fn adjust_lightness_for_contrast(
+    foreground: Hsla,
+    background: Hsla,
+    minimum_apca_contrast: f32,
+) -> Hsla {
+    // Determine if we need to go lighter or darker
+    let bg_luminance = srgb_to_y(background, &APCAConstants::default());
+    let should_go_darker = bg_luminance > 0.5;
+
+    // Binary search for the optimal lightness
+    let mut low = if should_go_darker { 0.0 } else { foreground.l };
+    let mut high = if should_go_darker { foreground.l } else { 1.0 };
+    let mut best_l = foreground.l;
+
+    for _ in 0..20 {
+        let mid = (low + high) / 2.0;
+        let test_color = Hsla {
+            h: foreground.h,
+            s: foreground.s,
+            l: mid,
+            a: foreground.a,
+        };
+
+        let contrast = apca_contrast(test_color, background).abs();
+
+        if contrast >= minimum_apca_contrast {
+            best_l = mid;
+            // Try to get closer to the minimum
+            if should_go_darker {
+                low = mid;
+            } else {
+                high = mid;
+            }
+        } else {
+            if should_go_darker {
+                high = mid;
+            } else {
+                low = mid;
+            }
+        }
+
+        // If we're close enough to the target, stop
+        if (contrast - minimum_apca_contrast).abs() < 1.0 {
+            best_l = mid;
+            break;
+        }
+    }
+
+    Hsla {
+        h: foreground.h,
+        s: foreground.s,
+        l: best_l,
+        a: foreground.a,
+    }
+}
+
+/// Adjusts both lightness and saturation to meet the minimum contrast
+fn adjust_lightness_and_saturation_for_contrast(
+    foreground: Hsla,
+    background: Hsla,
+    minimum_apca_contrast: f32,
+) -> Hsla {
+    // Try different saturation levels
+    let saturation_steps = [1.0, 0.8, 0.6, 0.4, 0.2, 0.0];
+
+    for &sat_multiplier in &saturation_steps {
+        let test_color = Hsla {
+            h: foreground.h,
+            s: foreground.s * sat_multiplier,
+            l: foreground.l,
+            a: foreground.a,
+        };
+
+        let adjusted = adjust_lightness_for_contrast(test_color, background, minimum_apca_contrast);
+        let contrast = apca_contrast(adjusted, background).abs();
+
+        if contrast >= minimum_apca_contrast {
+            return adjusted;
+        }
+    }
+
+    // If we get here, even grayscale didn't work, so return the grayscale attempt
+    Hsla {
+        h: foreground.h,
+        s: 0.0,
+        l: foreground.l,
+        a: foreground.a,
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla {
+        Hsla { h, s, l, a }
+    }
+
+    fn hsla_from_hex(hex: u32) -> Hsla {
+        let r = ((hex >> 16) & 0xFF) as f32 / 255.0;
+        let g = ((hex >> 8) & 0xFF) as f32 / 255.0;
+        let b = (hex & 0xFF) as f32 / 255.0;
+
+        let max = r.max(g).max(b);
+        let min = r.min(g).min(b);
+        let l = (max + min) / 2.0;
+
+        if max == min {
+            // Achromatic
+            Hsla {
+                h: 0.0,
+                s: 0.0,
+                l,
+                a: 1.0,
+            }
+        } else {
+            let d = max - min;
+            let s = if l > 0.5 {
+                d / (2.0 - max - min)
+            } else {
+                d / (max + min)
+            };
+
+            let h = if max == r {
+                (g - b) / d + if g < b { 6.0 } else { 0.0 }
+            } else if max == g {
+                (b - r) / d + 2.0
+            } else {
+                (r - g) / d + 4.0
+            } / 6.0;
+
+            Hsla { h, s, l, a: 1.0 }
+        }
+    }
+
+    #[test]
+    fn test_apca_contrast() {
+        // Test black text on white background (should be positive)
+        let black = hsla(0.0, 0.0, 0.0, 1.0);
+        let white = hsla(0.0, 0.0, 1.0, 1.0);
+        let contrast = apca_contrast(black, white);
+        assert!(
+            contrast > 100.0,
+            "Black on white should have high positive contrast, got {}",
+            contrast
+        );
+
+        // Test white text on black background (should be negative)
+        let contrast_reversed = apca_contrast(white, black);
+        assert!(
+            contrast_reversed < -100.0,
+            "White on black should have high negative contrast, got {}",
+            contrast_reversed
+        );
+
+        // Same color should have zero contrast
+        let gray = hsla(0.0, 0.0, 0.5, 1.0);
+        let contrast_same = apca_contrast(gray, gray);
+        assert!(
+            contrast_same.abs() < 1.0,
+            "Same color should have near-zero contrast, got {}",
+            contrast_same
+        );
+
+        // APCA is NOT commutative - polarity matters
+        assert!(
+            (contrast + contrast_reversed).abs() > 1.0,
+            "APCA should not be commutative"
+        );
+    }
+
+    #[test]
+    fn test_srgb_to_y() {
+        let constants = APCAConstants::default();
+
+        // Test known Y values
+        let black = hsla(0.0, 0.0, 0.0, 1.0);
+        let y_black = srgb_to_y(black, &constants);
+        assert!(
+            y_black.abs() < 0.001,
+            "Black should have Y near 0, got {}",
+            y_black
+        );
+
+        let white = hsla(0.0, 0.0, 1.0, 1.0);
+        let y_white = srgb_to_y(white, &constants);
+        assert!(
+            (y_white - 1.0).abs() < 0.001,
+            "White should have Y near 1, got {}",
+            y_white
+        );
+    }
+
+    #[test]
+    fn test_ensure_minimum_contrast() {
+        let white_bg = hsla(0.0, 0.0, 1.0, 1.0);
+        let light_gray = hsla(0.0, 0.0, 0.9, 1.0);
+
+        // Light gray on white has poor contrast
+        let initial_contrast = apca_contrast(light_gray, white_bg).abs();
+        assert!(
+            initial_contrast < 15.0,
+            "Initial contrast should be low, got {}",
+            initial_contrast
+        );
+
+        // Should be adjusted to black for better contrast (using APCA Lc 45 as minimum)
+        let adjusted = ensure_minimum_contrast(light_gray, white_bg, 45.0);
+        assert_eq!(adjusted.l, 0.0); // Should be black
+        assert_eq!(adjusted.a, light_gray.a); // Alpha preserved
+
+        // Test with dark background
+        let black_bg = hsla(0.0, 0.0, 0.0, 1.0);
+        let dark_gray = hsla(0.0, 0.0, 0.1, 1.0);
+
+        // Dark gray on black has poor contrast
+        let initial_contrast = apca_contrast(dark_gray, black_bg).abs();
+        assert!(
+            initial_contrast < 15.0,
+            "Initial contrast should be low, got {}",
+            initial_contrast
+        );
+
+        // Should be adjusted to white for better contrast
+        let adjusted = ensure_minimum_contrast(dark_gray, black_bg, 45.0);
+        assert_eq!(adjusted.l, 1.0); // Should be white
+
+        // Test when contrast is already sufficient
+        let black = hsla(0.0, 0.0, 0.0, 1.0);
+        let adjusted = ensure_minimum_contrast(black, white_bg, 45.0);
+        assert_eq!(adjusted, black); // Should remain unchanged
+    }
+
+    #[test]
+    fn test_one_light_theme_exact_colors() {
+        // Test with exact colors from One Light theme
+        // terminal.background and terminal.ansi.white are both #fafafaff
+        let fafafa = hsla_from_hex(0xfafafa);
+
+        // They should be identical
+        let bg = fafafa;
+        let fg = fafafa;
+
+        // Contrast should be 0 (no contrast)
+        let contrast = apca_contrast(fg, bg);
+        assert!(
+            contrast.abs() < 1.0,
+            "Same color should have near-zero APCA contrast, got {}",
+            contrast
+        );
+
+        // With minimum APCA contrast of 15 (very low, but detectable), it should adjust
+        let adjusted = ensure_minimum_contrast(fg, bg, 15.0);
+        // The new algorithm preserves colors, so we just need to check contrast
+        let new_contrast = apca_contrast(adjusted, bg).abs();
+        assert!(
+            new_contrast >= 15.0,
+            "Adjusted contrast {} should be >= 15.0",
+            new_contrast
+        );
+
+        // The adjusted color should have sufficient contrast
+        let new_contrast = apca_contrast(adjusted, bg).abs();
+        assert!(
+            new_contrast >= 15.0,
+            "Adjusted APCA contrast {} should be >= 15.0",
+            new_contrast
+        );
+    }
+}

crates/terminal_view/src/terminal_element.rs 🔗

@@ -1,3 +1,4 @@
+use crate::color_contrast;
 use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine};
 use gpui::{
     AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, Element,
@@ -204,9 +205,9 @@ impl TerminalElement {
         grid: impl Iterator<Item = IndexedCell>,
         start_line_offset: i32,
         text_style: &TextStyle,
-        // terminal_theme: &TerminalStyle,
         text_system: &WindowTextSystem,
         hyperlink: Option<(HighlightStyle, &RangeInclusive<AlacPoint>)>,
+        minimum_contrast: f32,
         window: &Window,
         cx: &App,
     ) -> (Vec<LayoutCell>, Vec<LayoutRect>) {
@@ -285,8 +286,15 @@ impl TerminalElement {
                 {
                     if !is_blank(&cell) {
                         let cell_text = cell.c.to_string();
-                        let cell_style =
-                            TerminalElement::cell_style(&cell, fg, theme, text_style, hyperlink);
+                        let cell_style = TerminalElement::cell_style(
+                            &cell,
+                            fg,
+                            bg,
+                            theme,
+                            text_style,
+                            hyperlink,
+                            minimum_contrast,
+                        );
 
                         let layout_cell = text_system.shape_line(
                             cell_text.into(),
@@ -341,13 +349,17 @@ impl TerminalElement {
     fn cell_style(
         indexed: &IndexedCell,
         fg: terminal::alacritty_terminal::vte::ansi::Color,
-        // bg: terminal::alacritty_terminal::ansi::Color,
+        bg: terminal::alacritty_terminal::vte::ansi::Color,
         colors: &Theme,
         text_style: &TextStyle,
         hyperlink: Option<(HighlightStyle, &RangeInclusive<AlacPoint>)>,
+        minimum_contrast: f32,
     ) -> TextRun {
         let flags = indexed.cell.flags;
         let mut fg = convert_color(&fg, colors);
+        let bg = convert_color(&bg, colors);
+
+        fg = color_contrast::ensure_minimum_contrast(fg, bg, minimum_contrast);
 
         // Ghostty uses (175/255) as the multiplier (~0.69), Alacritty uses 0.66, Kitty
         // uses 0.75. We're using 0.7 because it's pretty well in the middle of that.
@@ -680,6 +692,7 @@ impl Element for TerminalElement {
                 let buffer_font_size = settings.buffer_font_size(cx);
 
                 let terminal_settings = TerminalSettings::get_global(cx);
+                let minimum_contrast = terminal_settings.minimum_contrast;
 
                 let font_family = terminal_settings.font_family.as_ref().map_or_else(
                     || settings.buffer_font.family.clone(),
@@ -853,6 +866,7 @@ impl Element for TerminalElement {
                         last_hovered_word
                             .as_ref()
                             .map(|last_hovered_word| (link_style, &last_hovered_word.word_match)),
+                        minimum_contrast,
                         window,
                         cx,
                     ),
@@ -874,6 +888,7 @@ impl Element for TerminalElement {
                             last_hovered_word.as_ref().map(|last_hovered_word| {
                                 (link_style, &last_hovered_word.word_match)
                             }),
+                            minimum_contrast,
                             window,
                             cx,
                         )
@@ -1390,3 +1405,122 @@ pub fn convert_color(fg: &terminal::alacritty_terminal::vte::ansi::Color, theme:
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_contrast_adjustment_logic() {
+        // Test the core contrast adjustment logic without needing full app context
+
+        // Test case 1: Light colors (poor contrast)
+        let white_fg = gpui::Hsla {
+            h: 0.0,
+            s: 0.0,
+            l: 1.0,
+            a: 1.0,
+        };
+        let light_gray_bg = gpui::Hsla {
+            h: 0.0,
+            s: 0.0,
+            l: 0.95,
+            a: 1.0,
+        };
+
+        // Should have poor contrast
+        let actual_contrast = color_contrast::apca_contrast(white_fg, light_gray_bg).abs();
+        assert!(
+            actual_contrast < 30.0,
+            "White on light gray should have poor APCA contrast: {}",
+            actual_contrast
+        );
+
+        // After adjustment with minimum APCA contrast of 45, should be darker
+        let adjusted = color_contrast::ensure_minimum_contrast(white_fg, light_gray_bg, 45.0);
+        assert!(
+            adjusted.l < white_fg.l,
+            "Adjusted color should be darker than original"
+        );
+        let adjusted_contrast = color_contrast::apca_contrast(adjusted, light_gray_bg).abs();
+        assert!(adjusted_contrast >= 45.0, "Should meet minimum contrast");
+
+        // Test case 2: Dark colors (poor contrast)
+        let black_fg = gpui::Hsla {
+            h: 0.0,
+            s: 0.0,
+            l: 0.0,
+            a: 1.0,
+        };
+        let dark_gray_bg = gpui::Hsla {
+            h: 0.0,
+            s: 0.0,
+            l: 0.05,
+            a: 1.0,
+        };
+
+        // Should have poor contrast
+        let actual_contrast = color_contrast::apca_contrast(black_fg, dark_gray_bg).abs();
+        assert!(
+            actual_contrast < 30.0,
+            "Black on dark gray should have poor APCA contrast: {}",
+            actual_contrast
+        );
+
+        // After adjustment with minimum APCA contrast of 45, should be lighter
+        let adjusted = color_contrast::ensure_minimum_contrast(black_fg, dark_gray_bg, 45.0);
+        assert!(
+            adjusted.l > black_fg.l,
+            "Adjusted color should be lighter than original"
+        );
+        let adjusted_contrast = color_contrast::apca_contrast(adjusted, dark_gray_bg).abs();
+        assert!(adjusted_contrast >= 45.0, "Should meet minimum contrast");
+
+        // Test case 3: Already good contrast
+        let good_contrast = color_contrast::ensure_minimum_contrast(black_fg, white_fg, 45.0);
+        assert_eq!(
+            good_contrast, black_fg,
+            "Good contrast should not be adjusted"
+        );
+    }
+
+    #[test]
+    fn test_white_on_white_contrast_issue() {
+        // This test reproduces the exact issue from the bug report
+        // where white ANSI text on white background should be adjusted
+
+        // Simulate One Light theme colors
+        let white_fg = gpui::Hsla {
+            h: 0.0,
+            s: 0.0,
+            l: 0.98, // #fafafaff is approximately 98% lightness
+            a: 1.0,
+        };
+        let white_bg = gpui::Hsla {
+            h: 0.0,
+            s: 0.0,
+            l: 0.98, // Same as foreground - this is the problem!
+            a: 1.0,
+        };
+
+        // With minimum contrast of 0.0, no adjustment should happen
+        let no_adjust = color_contrast::ensure_minimum_contrast(white_fg, white_bg, 0.0);
+        assert_eq!(no_adjust, white_fg, "No adjustment with min_contrast 0.0");
+
+        // With minimum APCA contrast of 15, it should adjust to a darker color
+        let adjusted = color_contrast::ensure_minimum_contrast(white_fg, white_bg, 15.0);
+        assert!(
+            adjusted.l < white_fg.l,
+            "White on white should become darker, got l={}",
+            adjusted.l
+        );
+
+        // Verify the contrast is now acceptable
+        let new_contrast = color_contrast::apca_contrast(adjusted, white_bg).abs();
+        assert!(
+            new_contrast >= 15.0,
+            "Adjusted APCA contrast {} should be >= 15.0",
+            new_contrast
+        );
+    }
+}