git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

commit4f56ed0e2a8966022d1bcdf97670a5b8fba858a4
parenta3efaeb0e4
authorLucas Galante <[email protected]>
date2026-08-28 08:23
fix: draw the halo in whatever color the text reads against

The outline was hardcoded white, which was fine while it only ever
defended dark text on a dark backdrop — the case the fixed text_halo knob
was written for. Adaptive contrast defends whatever color is configured,
and on light text a white outline is not a weaker treatment, it is an
eraser: white glyphs with a white halo over a light backdrop washed out
into an unreadable smear, worse than no treatment at all.

Found by trying it — switching module text to white over a near-white
desktop in a shadow session. The measurement was right the whole time
(`85 4`, light and uniform); the response was not.

Black or white now, whichever the text itself reads against, compared by
contrast ratio rather than a luminance midpoint — the WCAG curve puts the
crossover near 0.18, so mid-gray text wants a black halo and a midpoint
test would hand half the gray range the wrong one.

text_relief stays white-only: it is the manual knob for a session with no
backdrop feed, and it was written for dark text.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md   |  9 +++++++++
 src/main.rs | 44 +++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 52 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index bb43508..9aa39f8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -204,6 +204,15 @@ closes that loop with the compositor, which CAN see:
 3. `tick` eases `halo_now` toward that demand over ~120ms. Stepping straight
    to it makes the outline strobe as the desktop pans under the segment.
 
+The halo is drawn in whichever of black/white the TEXT reads against
+(`halo_rgb`, chosen by contrast ratio — the WCAG crossover is near 0.18, not
+0.5), not the fixed white the two legacy knobs use. An outline separates the
+glyphs from what is behind them, so it has to contrast with the glyphs: white
+around white text is not a weaker treatment, it is an eraser, which is exactly
+what light text over a light backdrop got before this followed the text color.
+(`text_relief` is still white-only; it is the manual knob, and it was written
+for dark text.)
+
 A window covering part of a segment is measured too — the compositor reads
 that window's own content over the overlapping strip and blends it with the
 desktop reading for the rest. `unknown` is left for content it genuinely
diff --git a/src/main.rs b/src/main.rs
index 0430c44..961b7dd 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -332,6 +332,11 @@ struct StatusApp {
     /// at config-reload time because the contrast decision needs it every
     /// frame and the color changes about never.
     text_luma: f32,
+    /// The color the halo is drawn in — black or white, whichever the TEXT
+    /// reads against. A halo exists to separate the glyphs from what is
+    /// behind them, so it has to contrast with the glyphs; a white outline
+    /// around white text is not a weaker treatment, it is an eraser.
+    halo_rgb: [u8; 3],
     /// The halo strength actually being painted, eased toward the backdrop's
     /// demand in `tick`. Stepping straight to the target makes the outline
     /// snap on and off as the desktop pans under a segment, which reads as a
@@ -445,6 +450,14 @@ impl StatusApp {
         let spacing = read_status_module_spacing_from_config();
         let normal_color = read_normal_color_from_config().unwrap_or(color::TEXT_FG);
         self.text_luma = relative_luminance(normal_color);
+        // Whichever of black/white the text itself reads against — compared
+        // by contrast ratio rather than a luminance midpoint, because the
+        // WCAG curve does not put the crossover at 0.5.
+        self.halo_rgb = if contrast_ratio(self.text_luma, 0.0) >= contrast_ratio(self.text_luma, 1.0) {
+            [0, 0, 0]
+        } else {
+            [255, 255, 255]
+        };
         let sw_logical = if is_vertical { self.height as f32 } else { self.width as f32 };
         let bar_h = if is_vertical { self.width as f32 } else { read_status_height_from_config() };
 
@@ -1236,6 +1249,7 @@ impl cce_ui::engine::Application for StatusApp {
             text_contrast: 0.0,
             backdrop: (50, 100),
             text_luma: 0.0,
+            halo_rgb: [255, 255, 255],
             halo_now: 0.0,
             text_prims: Vec::new(),
             scale_factor: 1.0,
@@ -1570,7 +1584,7 @@ impl cce_ui::engine::Application for StatusApp {
                         // Full halo: four diagonal white copies — a true
                         // outline, readable over any backdrop.
                         for (dx, dy) in [(-0.75, -0.75), (0.75, -0.75), (-0.75, 0.75), (0.75, 0.75)] {
-                            pc.text_faded(text.clone(), *x + dx, *y + dy, *tsize, [255, 255, 255], halo, font.clone(), *bounds);
+                            pc.text_faded(text.clone(), *x + dx, *y + dy, *tsize, self.halo_rgb, halo, font.clone(), *bounds);
                         }
                     } else if self.text_contrast <= 0.0 && self.text_relief > 0.0 {
                         // Letterpress underlay: a translucent white copy offset
@@ -2109,6 +2123,34 @@ mod tests {
         assert_eq!(halo_demand(BLACK_TEXT, (50, 100)), 1.0);
     }
 
+    /// The same choice `rebuild_layout` makes, factored for the test.
+    fn halo_rgb_for(text_luma: f32) -> [u8; 3] {
+        if contrast_ratio(text_luma, 0.0) >= contrast_ratio(text_luma, 1.0) {
+            [0, 0, 0]
+        } else {
+            [255, 255, 255]
+        }
+    }
+
+    #[test]
+    fn the_halo_contrasts_with_the_text_not_with_a_fixed_assumption() {
+        // A white outline around white text is not a weaker treatment, it is
+        // an eraser — which is exactly what a light backdrop got before the
+        // halo color followed the text color.
+        assert_eq!(halo_rgb_for(WHITE_TEXT), [0, 0, 0]);
+        assert_eq!(halo_rgb_for(BLACK_TEXT), [255, 255, 255]);
+    }
+
+    #[test]
+    fn the_halo_crossover_follows_the_wcag_curve_not_the_midpoint() {
+        // Mid-gray text (luminance 0.5) reads far better against black than
+        // against white, so the crossover sits well below 0.5 — picking it by
+        // luminance midpoint would give half the gray range the wrong halo.
+        assert_eq!(halo_rgb_for(0.5), [0, 0, 0]);
+        assert_eq!(halo_rgb_for(0.25), [0, 0, 0]);
+        assert_eq!(halo_rgb_for(0.1), [255, 255, 255]);
+    }
+
     #[test]
     fn contrast_ratio_matches_the_wcag_endpoints() {
         assert!((contrast_ratio(0.0, 1.0) - 21.0).abs() < 0.01);