collective

Browse / github.com/storytold/gridcraft / patch

Fix dark mode worksheet cell colors

Fix dark mode worksheet cell colors Dark mode currently changes the window chrome while leaving the worksheet white with black text. This change gives default worksheet cells black backgrounds and white text, including merged cells, overflowing text, and the in-cell editor. Gridlines, formula reference highlights, automatic borders, sheet controls, and sheet icons follow the active theme. Explicit workbook fills and font colors remain intact; automatic cell text chooses whichever of black or white has the higher WCAG contrast against the resolved fill. The in-cell editor shares the grid's resolved style and active conditional-f

Status
open
Score
0.00
Adopters
0 other operators
Author
JonathanLee2020 (imported from pull request #2614, open)
Base
42bbf6baa26a57f094411f17d1ab039cc91905a0
Size
+424 −74 in 8 files
Review
pending
Shared
2026-10-28
Id
d82784bdef142628ff4393d1

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/formula_bar.rs crates/ui-egui/src/grid.rs crates/ui-egui/src/icons.rs crates/ui-egui/src/theme.rs crates/ui-egui/tests/it/cell_editor_caret.rs crates/ui-egui/tests/it/text_overflow_scroll.rs docs/architecture.md docs/ui-parity.md

diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs--- a/crates/ui-egui/src/formula_bar.rs+++ b/crates/ui-egui/src/formula_bar.rs@@ -421,7 +421,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:     let refs = formula_refs(&ed.text);     let t = Tokens::get(ui.ctx());     let on_sheet = id == egui::Id::unique("gridcraft.cell_editor");-    let cell_text_color = if on_sheet { t.cell_text } else { t.text };+    let cell_text_color = if on_sheet { ui.visuals().override_text_color.unwrap_or(t.cell_text) } else { t.text };     let font2 = font.clone();     let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {         let s = buf.as_str();diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs--- a/crates/ui-egui/src/grid.rs+++ b/crates/ui-egui/src/grid.rs@@ -490,7 +490,7 @@ fn numfmt_color(c: gridcraft_engine::numfmt::FormatColor) -> Color32 { }  /// Last column of a Center Across Selection run starting at `start`: it extends over following columns (up to `limit`) while `continues` holds.-fn center_across_end(start: u32, limit: u32, continues: impl Fn(u32) -> bool) -> u32 {+fn center_across_end(start: u32, limit: u32, mut continues: impl FnMut(u32) -> bool) -> u32 {     let mut end = start;     while end < limit && end + 1 < MAX_COLS && continues(end + 1) {         end += 1;@@ -498,7 +498,13 @@ fn center_across_end(start: u32, limit: u32, continues: impl Fn(u32) -> bool) ->     end } -fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (Look, Option<CfLook>) {+/// Only unmerged, empty cells without a resolved fill can receive text overflow.+fn empty_for_overflow(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, merges: &[RangeRef], cf: &mut CfCache, t: &Tokens) -> bool {+    sh.value_ref(c).is_none_or(Value::is_empty) && !merges.iter().any(|m| m.contains(c)) && look(wb, si, sh, c, cf, t).0.fill.is_none()+}++/// Resolve table and conditional styles before choosing theme defaults and automatic cell ink.+fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache, t: &Tokens) -> (Look, Option<CfLook>) {     let mut style = wb.styles.get(sh.style_id(c)).clone();     if let Some(t) = sh.table_at(c) {         style = gridcraft_engine::tables::table_cell_style(wb, si, t, c);@@ -559,7 +565,8 @@ fn look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut CfCache) -> (     {         fill = Some(theme::color32(rgb));     }-    let text_color = col32(style.font.color, wb, Color32::BLACK);+    let automatic = fill.map_or(t.cell_text, ink_on);+    let text_color = col32(style.font.color, wb, automatic);     (Look { style, fill, text_color }, cfl) } @@ -599,6 +606,7 @@ fn display_text(     (f.text, f.color.map(numfmt_color), f.numeric, f.fill) } +/// Paint the visible sheet and its editor with a shared per-frame conditional-format cache. pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {     let t = Tokens::get(ui.ctx());     let available = ui.available_rect_before_wrap();@@ -855,7 +863,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {     update_cursor(app, ui, &resp, &geo, sh);     crate::text_box::show(app, ui, &geo, sh);     if app.editor.as_ref().is_some_and(|e| !e.from_formula_bar) {-        in_cell_editor(app, ui, &geo, sh, &wb);+        in_cell_editor(app, ui, &geo, sh, &wb, &mut cf);     }     filter_menu(app, ui, &geo, previous_popups.0 == app.grid.filter_menu);     list_picker(app, ui, &geo, previous_popups.1 == app.grid.list_picker);@@ -1129,6 +1137,7 @@ fn mirror_shape(shape: &mut egui::Shape, axis: f32) {     } } +/// Paint one visible pane using resolved cell styles, merged regions and theme-aware defaults. #[allow(clippy::too_many_arguments)] pub(crate) fn paint_quadrant(     p: &Painter,@@ -1184,7 +1193,6 @@ pub(crate) fn paint_quadrant(             cells.push(CellRef::new(r, c));         }     }-    let empty_ok = |c: CellRef| -> bool { sh.value_ref(c).is_none_or(Value::is_empty) && in_merge(c).is_none() };     // Backgrounds.     let mut looks: Vec<(CellRef, Rect, Look, Option<CfLook>)> = Vec::with_capacity(cells.len().min(20_000));     for &c in &cells {@@ -1193,11 +1201,11 @@ pub(crate) fn paint_quadrant(             Some(m) => (geo.range_rect(sh, m), m.start),             None => (geo.cell_rect(sh, c), c),         };-        let (lk, cfl) = look(wb, si, sh, anchor, cf);+        let (lk, cfl) = look(wb, si, sh, anchor, cf, t);         if let Some(f) = lk.fill {             p.rect_filled(rect.expand(0.0), 0.0, f);         } else if in_merge(c).is_some() {-            p.rect_filled(rect.shrink(0.5), 0.0, Color32::WHITE);+            p.rect_filled(rect.shrink(0.5), 0.0, t.grid_bg);         }         looks.push((anchor, rect, lk, cfl));     }@@ -1209,11 +1217,13 @@ pub(crate) fn paint_quadrant(         if sh.row_height(r) == 0.0 {             continue;         }-        let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n));-        let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1)).map(|col| CellRef::new(r, col)).find(|&n| !empty_ok(n));+        let left = (c0.saturating_sub(20)..c0).rev().map(|col| CellRef::new(r, col)).find(|&n| !empty_for_overflow(wb, si, sh, n, &merges, cf, t));+        let right = (c1.saturating_add(1)..=c1.saturating_add(20).min(MAX_COLS - 1))+            .map(|col| CellRef::new(r, col))+            .find(|&n| !empty_for_overflow(wb, si, sh, n, &merges, cf, t));         for n in [left, right].into_iter().flatten() {             if same_pane(n.col) && sh.col_width(n.col) > 0.0 && in_merge(n).is_none() {-                let (lk, cfl) = look(wb, si, sh, n, cf);+                let (lk, cfl) = look(wb, si, sh, n, cf, t);                 outside.push((n, geo.cell_rect(sh, n), lk, cfl));             }         }@@ -1369,7 +1379,7 @@ pub(crate) fn paint_quadrant(         let across_end = if halign == HAlign::CenterAcross && !rotated && in_merge(c).is_none() {             center_across_end(c.col, c1 + 20, |col| {                 let n = CellRef::new(c.row, col);-                empty_ok(n) && wb.styles.get(sh.style_id(n)).align.h == HAlign::CenterAcross+                empty_for_overflow(wb, si, sh, n, &merges, cf, t) && wb.styles.get(sh.style_id(n)).align.h == HAlign::CenterAcross             })         } else {             c.col@@ -1385,14 +1395,22 @@ pub(crate) fn paint_quadrant(             }             if matches!(halign, HAlign::Left | HAlign::Center | HAlign::Justify) {                 let mut cc = c.col + 1;-                while right - rect.left() < w + 2.0 * pad && cc < MAX_COLS && empty_ok(CellRef::new(c.row, cc)) && cc <= c1 + 20 {+                while right - rect.left() < w + 2.0 * pad+                    && cc < MAX_COLS+                    && empty_for_overflow(wb, si, sh, CellRef::new(c.row, cc), &merges, cf, t)+                    && cc <= c1 + 20+                {                     right = geo.x(sh, cc + 1);                     cc += 1;                 }             }             if matches!(halign, HAlign::Right | HAlign::Center) {                 let mut cc = c.col;-                while rect.right() - left < w + 2.0 * pad && cc > 0 && empty_ok(CellRef::new(c.row, cc - 1)) && cc + 20 > c0 {+                while rect.right() - left < w + 2.0 * pad+                    && cc > 0+                    && empty_for_overflow(wb, si, sh, CellRef::new(c.row, cc - 1), &merges, cf, t)+                    && cc + 20 > c0+                {                     cc -= 1;                     left = geo.x(sh, cc);                 }@@ -1471,14 +1489,14 @@ pub(crate) fn paint_quadrant(             if line.is_none() {                 continue;             }-            let col = col32(line.color, wb, Color32::BLACK);+            let col = col32(line.color, wb, t.cell_text);             paint_border(p, pts, line.style, col);         }         if !b.diag_down.is_none() {-            paint_border(p, [rect.left_top(), rect.right_bottom()], b.diag_down.style, col32(b.diag_down.color, wb, Color32::BLACK));+            paint_border(p, [rect.left_top(), rect.right_bottom()], b.diag_down.style, col32(b.diag_down.color, wb, t.cell_text));         }         if !b.diag_up.is_none() {-            paint_border(p, [rect.left_bottom(), rect.right_top()], b.diag_up.style, col32(b.diag_up.color, wb, Color32::BLACK));+            paint_border(p, [rect.left_bottom(), rect.right_top()], b.diag_up.style, col32(b.diag_up.color, wb, t.cell_text));         }         // Comment / note indicator.         if let Some(cm) = sh.comments.get(c) {@@ -1634,8 +1652,8 @@ fn merged_edges(             continue;         }         let (x0, x1) = (geo.x(sh, col), geo.x(sh, col.saturating_add(1)));-        let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf).0.style.borders.top;-        let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf).0.style.borders.bottom;+        let top = look(wb, si, sh, CellRef::new(m.start.row, col), cf, &Tokens::light()).0.style.borders.top;+        let bottom = look(wb, si, sh, CellRef::new(m.end.row, col), cf, &Tokens::light()).0.style.borders.bottom;         out.push((top, [pos2(x0, rect.top()), pos2(x1, rect.top())]));         out.push((bottom, [pos2(x0, rect.bottom()), pos2(x1, rect.bottom())]));     }@@ -1645,8 +1663,8 @@ fn merged_edges(             continue;         }         let (y0, y1) = (geo.y(sh, row), geo.y(sh, row.saturating_add(1)));-        let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf).0.style.borders.left;-        let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf).0.style.borders.right;+        let left = look(wb, si, sh, CellRef::new(row, m.start.col), cf, &Tokens::light()).0.style.borders.left;+        let right = look(wb, si, sh, CellRef::new(row, m.end.col), cf, &Tokens::light()).0.style.borders.right;         out.push((left, [pos2(rect.left(), y0), pos2(rect.left(), y1)]));         out.push((right, [pos2(rect.right(), y0), pos2(rect.right(), y1)]));     }@@ -1857,7 +1875,7 @@ pub(crate) fn paint_selection(     // Fill handle (bottom-right of the current area).     if app.editor.is_none() && range_in_pane(RangeRef::cell(sel.current().end), rows, cols) {         let h = Rect::from_center_size(outline.right_bottom(), vec2(6.0, 6.0));-        p.rect_filled(h.expand(1.0), 0.0, Color32::WHITE);+        p.rect_filled(h.expand(1.0), 0.0, t.grid_bg);         p.rect_filled(h, 0.0, t.sel_border);     }     // Fill-handle drag preview.@@ -3212,7 +3230,7 @@ fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 { }  /// Draws the in-cell editor, growing and wrapping with its text, mirroring for right-to-left sheets, choosing contrasting caret ink and clipping cells hidden under frozen panes.-fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) {+fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook, cf: &mut CfCache) {     let Some(ed) = app.editor.as_ref() else { return };     if ed.sheet != wb.active_sheet {         return;@@ -3222,7 +3240,11 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,         Some(m) => geo.range_rect(sh, m),         None => geo.cell_rect(sh, cell),     };-    let st = wb.styles.get(sh.style_id(cell)).clone();+    let t = Tokens::get(ui.ctx());+    let (lk, _) = look(wb, wb.active_sheet, sh, cell, cf, &t);+    let st = lk.style;+    let fill = lk.fill.unwrap_or(t.grid_bg);+    let ink = lk.text_color;     let size = st.font.size * 96.0 / 72.0 * geo.z;     let font = FontId::new(size, theme::cell_family(&st.font.name, st.font.bold, st.font.italic));     let lines = ed.text.lines().count().max(1) as f32 + if ed.text.ends_with('\n') { 1.0 } else { 0.0 };@@ -3245,10 +3267,6 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,     };     // Grows to the left on a right-to-left sheet.     let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h)));-    let fill = match st.fill.pattern {-        PatternType::Solid => st.fill.fg.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::WHITE),-        _ => Color32::WHITE,-    };     let split = pos2(geo.cells.left() + geo.frozen_w, geo.cells.top() + geo.frozen_h);     let quadrant = Rect::from_min_max(         pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }),@@ -3272,8 +3290,9 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,     let id = egui::Id::unique("gridcraft.cell_editor");     let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0))));     child.set_clip_rect(clip);-    // The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark-    // theme, on the white paper): pick black or white against the fill, like Excel.+    // Match the grid's resolved ink, including explicit table and conditional font colours.+    child.visuals_mut().override_text_color = Some(ink);+    // The caret must remain visible even when an explicit font colour matches the fill.     child.visuals_mut().text_cursor.stroke.color = ink_on(fill);     crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0);     ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect));@@ -3285,10 +3304,9 @@ fn popup_dismissed(response: &egui::Response, was_open: bool) -> bool {     response.ctx.input(|i| !i.focused || i.key_pressed(egui::Key::Escape)) || (was_open && response.clicked_elsewhere()) } -/// Black or white, whichever reads on `fill` (the threshold chart labels use).+/// Black or white, whichever has the higher WCAG contrast against the resolved fill. fn ink_on(fill: Color32) -> Color32 {-    let l = 0.299 * fill.r() as f32 + 0.587 * fill.g() as f32 + 0.114 * fill.b() as f32;-    if l < 150.0 { Color32::WHITE } else { Color32::BLACK }+    if theme::contrast(Color32::BLACK, fill) >= theme::contrast(Color32::WHITE, fill) { Color32::BLACK } else { Color32::WHITE } }  /// The table whose header cell (`row`, `col`) was clicked, not just the first table in the column.@@ -4333,6 +4351,36 @@ mod tests {         assert_eq!(center_across_end(0, 1, |_| true), 1);     } +    /// Automatic ink follows the theme and resolved fill while explicit workbook colours remain intact.+    #[test]+    fn automatic_cell_ink_follows_theme_and_fill_without_changing_explicit_colors() {+        let mut session = gridcraft_engine::Session::new();+        session.new_workbook();+        session.execute("home.fillColor", json!({"range": "B1", "color": "#FFFF00"})).unwrap();+        session.execute("home.fillColor", json!({"range": "C1", "color": "#1F3864"})).unwrap();+        session.execute("home.fontColor", json!({"range": "D1", "color": "#FF0000"})).unwrap();+        session.execute("home.fillColor", json!({"range": "E1", "color": "#00FF00"})).unwrap();+        let wb = &session.active().unwrap().wb;+        let sh = wb.sheet(0).unwrap();+        let mut cf = CfCache::new();+        for t in [Tokens::light(), Tokens::dark()] {+            let automatic = look(wb, 0, sh, CellRef::new(0, 0), &mut cf, &t).0;+            assert_eq!(automatic.text_color, t.cell_text);+            assert_eq!(automatic.fill, None);+            let light_fill = look(wb, 0, sh, CellRef::new(0, 1), &mut cf, &t).0;+            assert_eq!(light_fill.fill, Some(Color32::YELLOW));+            assert_eq!(light_fill.text_color, Color32::BLACK);+            let dark_fill = look(wb, 0, sh, CellRef::new(0, 2), &mut cf, &t).0;+            assert_eq!(dark_fill.fill, Some(Color32::from_rgb(0x1F, 0x38, 0x64)));+            assert_eq!(dark_fill.text_color, Color32::WHITE);+            assert_eq!(look(wb, 0, sh, CellRef::new(0, 3), &mut cf, &t).0.text_color, Color32::RED);+            let green_fill = look(wb, 0, sh, CellRef::new(0, 4), &mut cf, &t).0;+            assert_eq!(green_fill.fill, Some(Color32::GREEN));+            assert_eq!(green_fill.text_color, Color32::BLACK, "bright green needs black automatic text");+        }+    }++    /// Conditional number formats override matching cells without changing unmatched cells.     #[test]     fn conditional_number_format_reaches_the_cell_text() {         // #1163: a rule's number format replaces the cell's own; unmatched cells keep theirs.@@ -4347,7 +4395,7 @@ mod tests {         let mut cf = CfCache::new();         let text = |row: u32, cf: &mut CfCache| {             let c = CellRef::new(row, 0);-            let (lk, _) = look(&wb, 0, sh, c, cf);+            let (lk, _) = look(&wb, 0, sh, c, cf, &Tokens::light());             display_text(&wb, sh, c, &sh.value(c), &lk.style, 200.0, 7.0).0         };         assert_eq!(text(0, &mut cf), "1");diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs--- a/crates/ui-egui/src/icons.rs+++ b/crates/ui-egui/src/icons.rs@@ -1,7 +1,7 @@ //! GridCraft's icon set, drawn in code on a 16×16 design grid (original artwork; see //! ATTRIBUTION.md). Large ribbon icons use the same drawings at 32×32 with accent colours. //! Accent and paper colours come from the theme tokens, so icons follow light and dark mode;-//! icons placed on the sheet ([`paint_on_sheet`]) keep the light colours, as the sheet does.+//! icons placed on the sheet ([`paint_on_sheet`]) follow the same theme.  use egui::epaint::{CubicBezierShape, PathShape, PathStroke}; use egui::{Color32, Painter, Pos2, Rect, Stroke, pos2, vec2};@@ -227,9 +227,9 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {     paint_with(p, rect, icon, c, &Palette::of(&Tokens::get(p.ctx()))); } -/// Draws `icon` on the sheet, which stays white in both themes, so with the light palette.+/// Draws `icon` on the sheet using the current theme palette. pub fn paint_on_sheet(p: &Painter, rect: Rect, icon: Icon, c: Color32) {-    paint_with(p, rect, icon, c, &Palette::of(&Tokens::light()));+    paint(p, rect, icon, c); }  /// Draws `icon` into `rect` (square-ish), in colour `c`, with palette `pal`.diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs--- a/crates/ui-egui/src/theme.rs+++ b/crates/ui-egui/src/theme.rs@@ -9,8 +9,8 @@ //! into craft-fonts' OFL LXGW Chinese face, so a host without a Han system face still draws Chinese //! interface text; no font file is stored in this repo. //!-//! Chrome colours belong in [`Tokens`], each with a light and a dark value; colours-//! drawn on the sheet keep their light value in both themes, because the sheet stays white. The+//! Chrome and default sheet colours belong in [`Tokens`], each with a light and a dark value.+//! Explicit workbook colours are preserved. The //! foreground/background pairs are listed in `contrast_tests`, which holds them to WCAG 2 (4.5:1 //! for text, 3:1 for glyphs) in both themes: a new token goes there too. @@ -95,15 +95,14 @@ pub struct Tokens {     pub paper: Color32,     /// Reference colours for formulas edited in the window-coloured formula bar.     pub ref_colors: [Color32; 8],-    /// Reference colours on the sheet (outlines and the in-cell editor). The sheet stays white.+    /// Reference colours on the sheet (outlines and the in-cell editor).     pub sheet_ref_colors: [Color32; 8],     /// Code colours in the code editor (and the window-coloured fields it sits in).     pub syntax: Syntax,     /// The message box warning disc and its glyph.     pub warning: Color32,     pub on_warning: Color32,-    /// Controls drawn on the sheet (filter and validation buttons, object handles). They follow-    /// the sheet, so they are light in both themes.+    /// Controls drawn on the sheet (filter and validation buttons, object handles).     pub sheet_control_bg: Color32,     pub sheet_control_border: Color32,     pub sheet_control_text: Color32,@@ -128,14 +127,16 @@ impl Cat {     pub const ALL: [Cat; 8] = [Cat::Blue, Cat::Green, Cat::Orange, Cat::Red, Cat::Purple, Cat::Teal, Cat::Amber, Cat::Slate]; } +/// Convert a packed 0xRRGGBB token into an opaque egui colour. const fn rgb(hex: u32) -> Color32 {     Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8) } -/// Reference colours on the white sheet, the same in both themes (the sheet does not follow the-/// theme). The light theme's formula bar is white too, so it uses them as well.+/// Reference colours on light surfaces, including the sheet and formula bar. const SHEET_REF_COLORS: [Color32; 8] =     [rgb(0x2E6FD8), rgb(0xD03C3C), rgb(0x7A4FC9), rgb(0x23803D), rgb(0xB5531A), rgb(0x16798A), rgb(0xB83B8E), rgb(0x77681A)];+const DARK_REF_COLORS: [Color32; 8] =+    [rgb(0x6FA3F5), rgb(0xF27C7C), rgb(0xB394EE), rgb(0x6CCB85), rgb(0xF0A061), rgb(0x5CC9DA), rgb(0xE77FC4), rgb(0xD4C25A)]; /// Code colours for one background; plain text uses the surface's own text colour. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Syntax {@@ -156,10 +157,12 @@ const SHEET_CONTROL_TEXT: Color32 = rgb(0x1F1F1F); const SHEET_TRACK: Color32 = rgb(0xD6D6D6);  impl Tokens {+    /// The light palette uses white default cells with black automatic text.     pub fn light() -> Tokens {         Tokens::palette(false)     } +    /// The dark palette uses black default cells with white automatic text.     pub fn dark() -> Tokens {         Tokens::palette(true)     }@@ -180,9 +183,9 @@ impl Tokens {             hover: pick(dark, Color32::from_rgb(0xEB, 0xEB, 0xEB), Color32::from_rgb(0x42, 0x42, 0x42)),             pressed: pick(dark, Color32::from_rgb(0xDD, 0xDD, 0xDD), Color32::from_rgb(0x4D, 0x4D, 0x4D)),             separator: pick(dark, Color32::from_rgb(0xE0, 0xE0, 0xE0), Color32::from_rgb(0x48, 0x48, 0x48)),-            grid_bg: Color32::WHITE,+            grid_bg: pick(dark, Color32::WHITE, Color32::BLACK),             desk: pick(dark, Color32::from_rgb(0xDE, 0xDE, 0xDE), Color32::from_rgb(0x3A, 0x3A, 0x3A)),-            gridline: Color32::from_rgb(0xE1, 0xE1, 0xE1),+            gridline: pick(dark, rgb(0xE1E1E1), rgb(0x383838)),             header_bg: pick(dark, Color32::from_rgb(0xF8, 0xF8, 0xF8), Color32::from_rgb(0x30, 0x30, 0x30)),             header_text: pick(dark, Color32::from_rgb(0x42, 0x42, 0x42), Color32::from_rgb(0xCC, 0xCC, 0xCC)),             header_line: pick(dark, Color32::from_rgb(0xD6, 0xD6, 0xD6), Color32::from_rgb(0x45, 0x45, 0x45)),@@ -196,7 +199,7 @@ impl Tokens {             tab_bar: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)),             tab_active: pick(dark, Color32::WHITE, Color32::from_rgb(0x3A, 0x3A, 0x3A)),             status_bar: pick(dark, Color32::from_rgb(0xF3, 0xF3, 0xF3), Color32::from_rgb(0x29, 0x29, 0x29)),-            cell_text: Color32::BLACK,+            cell_text: pick(dark, Color32::BLACK, Color32::WHITE),             menu_bg: pick(dark, Color32::WHITE, Color32::from_rgb(0x30, 0x30, 0x30)),             shadow: pick(dark, Color32::from_black_alpha(40), Color32::from_black_alpha(90)),             danger: pick(dark, Color32::from_rgb(0xC4, 0x2B, 0x1C), Color32::from_rgb(0xFF, 0x95, 0x85)),@@ -214,22 +217,19 @@ impl Tokens {                 [rgb(0x6EA8F7), rgb(0x5CC587), rgb(0xF0965A), rgb(0xF07B6E), rgb(0xB394EE), rgb(0x4CC7CF), rgb(0xF2C14E), rgb(0xA9B6C4)],             ),             paper: pick(dark, Color32::WHITE, rgb(0x454545)),-            ref_colors: pick(-                dark,-                SHEET_REF_COLORS,-                [rgb(0x6FA3F5), rgb(0xF27C7C), rgb(0xB394EE), rgb(0x6CCB85), rgb(0xF0A061), rgb(0x5CC9DA), rgb(0xE77FC4), rgb(0xD4C25A)],-            ),-            sheet_ref_colors: SHEET_REF_COLORS,+            ref_colors: pick(dark, SHEET_REF_COLORS, DARK_REF_COLORS),+            sheet_ref_colors: pick(dark, SHEET_REF_COLORS, DARK_REF_COLORS),             syntax: pick(dark, LIGHT_SYNTAX, DARK_SYNTAX),             warning: rgb(0xF2C32E),             on_warning: rgb(0x1F1F1F),-            sheet_control_bg: SHEET_CONTROL_BG,+            sheet_control_bg: pick(dark, SHEET_CONTROL_BG, rgb(0x303030)),             sheet_control_border: SHEET_CONTROL_BORDER,-            sheet_control_text: SHEET_CONTROL_TEXT,-            sheet_track: SHEET_TRACK,+            sheet_control_text: pick(dark, SHEET_CONTROL_TEXT, Color32::WHITE),+            sheet_track: pick(dark, SHEET_TRACK, rgb(0x383838)),         }     } +    /// Select the palette matching the active egui context theme.     pub fn get(ctx: &egui::Context) -> Tokens {         if ctx.global_style().visuals.dark_mode { Tokens::dark() } else { Tokens::light() }     }@@ -1251,15 +1251,17 @@ mod contrast_tests {         assert!((contrast(rgb(0x767676), Color32::WHITE) - 4.54).abs() < 0.01);     } +    /// Sheet surfaces, reference highlights and controls must switch along with the chrome.     #[test]-    fn sheet_tokens_follow_the_sheet_not_the_theme() {+    fn sheet_tokens_follow_the_theme() {         let (l, d) = (Tokens::light(), Tokens::dark());-        assert_eq!(l.grid_bg, d.grid_bg);-        assert_eq!(l.sheet_ref_colors, d.sheet_ref_colors);-        assert_eq!(-            (l.sheet_control_bg, l.sheet_control_border, l.sheet_control_text, l.sheet_track),-            (d.sheet_control_bg, d.sheet_control_border, d.sheet_control_text, d.sheet_track)-        );+        assert_eq!((l.grid_bg, l.cell_text), (Color32::WHITE, Color32::BLACK));+        assert_eq!((d.grid_bg, d.cell_text), (Color32::BLACK, Color32::WHITE));+        assert_ne!(l.gridline, d.gridline);+        assert_ne!(l.sheet_ref_colors, d.sheet_ref_colors);+        assert_ne!(l.sheet_control_bg, d.sheet_control_bg);+        assert_ne!(l.sheet_control_text, d.sheet_control_text);+        assert_ne!(l.sheet_track, d.sheet_track);         for c in Cat::ALL {             assert_ne!(l.cat(c), d.cat(c), "{c:?} has a dark-mode value of its own");         }diff --git a/crates/ui-egui/tests/it/cell_editor_caret.rs b/crates/ui-egui/tests/it/cell_editor_caret.rs--- a/crates/ui-egui/tests/it/cell_editor_caret.rs+++ b/crates/ui-egui/tests/it/cell_editor_caret.rs@@ -4,6 +4,7 @@ use gridcraft_engine::Session; use gridcraft_ui_egui::{SheetApp, theme}; use serde_json::json; +/// Run a full application frame so editor colour assertions inspect the painted UI. fn frame(ctx: &Context, app: &mut SheetApp) -> egui::FullOutput {     let mut output =         ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0))), ..Default::default() }, |ui| {@@ -22,6 +23,7 @@ fn caret(fill: Option<&str>, dark: bool) -> Color32 {     let mut session = Session::new();     session.new_workbook();     let mut app = crate::common::app(session);+    app.ui.dark = dark;     if let Some(color) = fill {         app.run("home.fillColor", json!({"range": "A1", "color": color})).unwrap();     }@@ -34,12 +36,21 @@ fn caret(fill: Option<&str>, dark: bool) -> Color32 {     }     let output = frame(&ctx, &mut app);     // The editor paints its backdrop first; the caret is the vertical segment after it.-    let paper = fill.map_or(Color32::WHITE, |c| Color32::from_hex(c).unwrap());+    let paper = fill.map_or(if dark { Color32::BLACK } else { Color32::WHITE }, |c| Color32::from_hex(c).unwrap());     let backdrop = output         .shapes         .iter()         .rposition(|s| matches!(&s.shape, Shape::Rect(r) if r.fill == paper && r.rect.width() > 20.0))         .expect("editor backdrop painted");+    let text = output.shapes[backdrop..]+        .iter()+        .find_map(|s| match &s.shape {+            Shape::Text(t) if t.galley.job.text == "abc" => Some(t),+            _ => None,+        })+        .expect("editor text painted");+    let ink = if paper == Color32::BLACK || fill == Some("#1F3864") { Color32::WHITE } else { Color32::BLACK };+    assert!(text.galley.job.sections.iter().all(|s| s.format.color == ink), "editor text contrasts with fill");     output.shapes[backdrop..]         .iter()         .find_map(|s| match &s.shape {@@ -49,11 +60,181 @@ fn caret(fill: Option<&str>, dark: bool) -> Color32 {         .expect("caret painted") } +/// Automatic editor text and its caret stay readable on default, dark and saturated fills. #[test] fn caret_contrasts_with_the_cell_fill() {     assert_eq!(caret(Some("#1F3864"), false), Color32::WHITE, "dark fill, light theme");     assert_eq!(caret(Some("#1F3864"), true), Color32::WHITE, "dark fill, dark theme");     assert_eq!(caret(Some("#FFFF00"), true), Color32::BLACK, "light fill, dark theme");-    assert_eq!(caret(None, true), Color32::BLACK, "no fill, dark theme: the cell is still white paper");+    assert_eq!(caret(Some("#00FF00"), false), Color32::BLACK, "bright green fill, light theme");+    assert_eq!(caret(Some("#00FF00"), true), Color32::BLACK, "bright green fill, dark theme");+    assert_eq!(caret(None, true), Color32::WHITE, "no fill, dark theme");     assert_eq!(caret(None, false), Color32::BLACK, "no fill, light theme"); }++/// Switching light to dark and back repaints both ordinary text and merged-cell backdrops.+#[test]+fn ordinary_and_merged_cells_repaint_when_the_theme_changes() {+    let ctx = Context::default();+    let mut session = Session::new();+    session.new_workbook();+    session.execute("cell.set", json!({"cell": "B2", "input": "Ordinary"})).unwrap();+    session.execute("cell.set", json!({"cell": "D2", "input": "Merged"})).unwrap();+    session.execute("home.mergeCells", json!({"range": "D2:E2"})).unwrap();+    let mut app = crate::common::app(session);+    for dark in [false, true, false] {+        theme::apply(&ctx, dark);+        app.ui.dark = dark;+        for _ in 0..4 {+            frame(&ctx, &mut app);+        }+        let output = frame(&ctx, &mut app);+        let (paper, ink) = if dark { (Color32::BLACK, Color32::WHITE) } else { (Color32::WHITE, Color32::BLACK) };+        for label in ["Ordinary", "Merged"] {+            let text = output+                .shapes+                .iter()+                .find_map(|s| match &s.shape {+                    Shape::Text(t) if t.galley.job.text == label => Some(t),+                    _ => None,+                })+                .expect("cell text painted");+            assert!(text.galley.job.sections.iter().all(|s| s.format.color == ink), "{label}, dark={dark}");+            if label == "Merged" {+                assert!(+                    output.shapes.iter().any(|s| matches!(&s.shape, Shape::Rect(r)+                    if r.fill == paper && r.rect.contains(text.pos) && r.rect.width() < 400.0 && r.rect.height() < 50.0)),+                    "merged cell backdrop, dark={dark}"+                );+            }+        }+    }+}++/// Check the resolved editor fill and text independently from its high-contrast caret.+fn assert_editor_colors(session: Session, dark: bool, fill: Color32, ink: Color32) {+    let ctx = Context::default();+    theme::apply(&ctx, dark);+    let mut app = crate::common::app(session);+    app.ui.dark = dark;+    for _ in 0..4 {+        frame(&ctx, &mut app);+    }+    app.begin_edit(Some("abc".into()), false);+    for _ in 0..4 {+        frame(&ctx, &mut app);+    }+    let output = frame(&ctx, &mut app);+    let rect = ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique("gridcraft.cell_editor.rect"))).expect("editor rect");+    let backdrop = output.shapes.iter().rposition(|s| matches!(&s.shape, Shape::Rect(r) if r.rect == rect)).expect("editor backdrop");+    let Shape::Rect(backdrop_shape) = &output.shapes[backdrop].shape else { unreachable!() };+    assert_eq!(backdrop_shape.fill, fill, "resolved editor fill, dark={dark}");+    let text = output.shapes[backdrop..]+        .iter()+        .find_map(|s| match &s.shape {+            Shape::Text(t) if t.galley.job.text == "abc" => Some(t),+            _ => None,+        })+        .expect("editor text");+    assert!(text.galley.job.sections.iter().all(|s| s.format.color == ink), "resolved editor text, dark={dark}");+    let caret = output.shapes[backdrop..]+        .iter()+        .find_map(|s| match &s.shape {+            Shape::LineSegment { points, stroke } if points[0].x == points[1].x => Some(stroke.color),+            _ => None,+        })+        .expect("editor caret");+    assert!(matches!(caret, Color32::BLACK | Color32::WHITE), "caret uses contrasting black or white, dark={dark}");+    assert!(theme::contrast(caret, fill) >= 4.5, "caret must remain visible independently of text colour, dark={dark}");+}++/// Editing retains an explicitly chosen font colour while keeping the caret visible.+#[test]+fn editor_preserves_explicit_font_color() {+    for dark in [false, true] {+        let mut session = Session::new();+        session.new_workbook();+        session.execute("home.fillColor", json!({"range":"A1", "color":"#FFFF00"})).unwrap();+        session.execute("home.fontColor", json!({"range":"A1", "color":"#FF0000"})).unwrap();+        assert_editor_colors(session, dark, Color32::YELLOW, Color32::RED);+    }+}++/// Conditional fill and font colours must survive entering edit mode in either theme.+#[test]+fn editor_preserves_conditional_fill_and_font_color() {+    for dark in [false, true] {+        let mut session = Session::new();+        session.new_workbook();+        session+            .execute(+                "home.conditionalFormat",+                json!({"range":"A1", "rule":{+                    "type":"expression", "formula":"=TRUE", "style":{"fill":"#00FF00", "font":{"color":"#FF0000"}}+                }}),+            )+            .unwrap();+        assert_editor_colors(session, dark, Color32::GREEN, Color32::RED);+    }+}++/// Editing a cell retains its colour-scale result instead of reverting to the base fill.+#[test]+fn editor_preserves_color_scale_fill() {+    for dark in [false, true] {+        let mut session = Session::new();+        session.new_workbook();+        session.execute("range.setValues", json!({"range":"A1", "values":[[1],[10]]})).unwrap();+        session.execute("home.conditionalFormat", json!({"range":"A1:A2", "rule":{"type":"colorScale"}})).unwrap();+        assert_editor_colors(session, dark, Color32::from_rgb(0xF8, 0x69, 0x6B), Color32::BLACK);+    }+}++/// Table header fill and font colours apply to the editor as they do to the grid.+#[test]+fn editor_preserves_table_header_fill_and_font_color() {+    for dark in [false, true] {+        let mut session = Session::new();+        session.new_workbook();+        session.execute("range.setValues", json!({"range":"A1", "values":[["Header"],[1]]})).unwrap();+        session.execute("insert.table", json!({"range":"A1:A2", "header":true})).unwrap();+        let wb = &session.active().unwrap().wb;+        let cell = gridcraft_engine::core::CellRef::new(0, 0);+        let table = wb.sheet(0).unwrap().table_at(cell).unwrap();+        let style = gridcraft_engine::tables::table_cell_style(wb, 0, table, cell);+        let fill = theme::color32(style.fill.fg.resolve(&wb.theme).expect("table header fill"));+        let ink = theme::color32(style.font.color.resolve(&wb.theme).expect("table header ink"));+        assert_editor_colors(session, dark, fill, ink);+    }+}++/// The editor matches the grid's blended approximation of a patterned workbook fill.+#[test]+fn editor_preserves_patterned_fill() {+    use gridcraft_engine::model::{Color, Fill, PatternType, Style};+    for dark in [false, true] {+        let mut session = Session::new();+        session.new_workbook();+        let wb = std::sync::Arc::make_mut(&mut session.active_mut().unwrap().wb);+        let style = Style { fill: Fill { pattern: PatternType::Gray50, fg: Color::Rgb(0), bg: Color::Rgb(0xFFFFFF) }, ..Default::default() };+        let id = wb.styles.intern(style);+        wb.sheet_mut(0).unwrap().set_style(gridcraft_engine::core::CellRef::new(0, 0), id);+        // The grid approximates patterns with one part foreground to three parts background.+        assert_editor_colors(session, dark, Color32::from_gray(191), Color32::BLACK);+    }+}++/// Explicit text may intentionally match its fill; the editing caret must still be visible.+#[test]+fn editor_caret_contrasts_when_explicit_font_matches_fill() {+    for dark in [false, true] {+        for color in ["#000000", "#FFFFFF"] {+            let mut session = Session::new();+            session.new_workbook();+            session.execute("home.fillColor", json!({"range":"A1", "color":color})).unwrap();+            session.execute("home.fontColor", json!({"range":"A1", "color":color})).unwrap();+            let color = Color32::from_hex(color).unwrap();+            assert_editor_colors(session, dark, color, color);+        }+    }+}diff --git a/crates/ui-egui/tests/it/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs--- a/crates/ui-egui/tests/it/text_overflow_scroll.rs+++ b/crates/ui-egui/tests/it/text_overflow_scroll.rs@@ -11,6 +11,7 @@ type Harness = egui_kittest::Harness<'static, SheetApp>; const LEFT: &str = "This is a very long left-aligned text that overflows into many empty neighbouring cells"; const RIGHT: &str = "This is a very long right-aligned text that overflows into many empty neighbouring cells"; +/// Build a blank sheet with deterministic fonts and keep the selection away from tested text. fn harness(setup: impl FnOnce(&mut Session)) -> Harness {     let mut s = Session::new();     s.new_workbook();@@ -49,6 +50,7 @@ fn text_clips(h: &Harness, text: &str) -> Vec<Rect> {         .collect() } +/// Locate a cell in the current scrolled pane for painted-clip assertions. fn cell_rect(h: &Harness, cell: &str) -> Rect {     let app = h.state();     let sheet = app.session.active().unwrap().wb.active().unwrap();@@ -56,6 +58,7 @@ fn cell_rect(h: &Harness, cell: &str) -> Rect {     geo.cell_rect(sheet, CellRef::parse(cell).unwrap()) } +/// Unblocked left-aligned text remains visible after its source scrolls out of view. #[test] fn overflow_text_stays_visible_when_source_cell_scrolls_out_left() {     let mut h = harness(|s| {@@ -68,6 +71,7 @@ fn overflow_text_stays_visible_when_source_cell_scrolls_out_left() {     assert!(clips.iter().any(|r| r.intersects(c1.shrink(1.0))), "B1's overflow is still drawn into C1 after scrolling: {clips:?}"); } +/// Unblocked right-aligned text can enter the view from an offscreen source. #[test] fn right_aligned_overflow_stays_visible_when_source_cell_is_right_of_view() {     let mut h = harness(|s| {@@ -95,6 +99,7 @@ fn right_aligned_overflow_stays_visible_when_source_cell_is_right_of_view() {     assert!(clips.iter().any(|r| r.intersects(visible)), "Z1's overflow is drawn into Y1: {clips:?} vs {visible:?}"); } +/// Overflow from the scrolling pane must not paint over frozen columns. #[test] fn scrolled_overflow_does_not_draw_over_frozen_columns() {     let mut h = harness(|s| {@@ -108,3 +113,101 @@ fn scrolled_overflow_does_not_draw_over_frozen_columns() {     assert!(clips.iter().any(|r| r.intersects(e1.shrink(1.0))), "C1's overflow reaches E1: {clips:?}");     assert!(clips.iter().all(|r| !r.intersects(a1.shrink(1.0))), "C1's overflow stays out of frozen column A: {clips:?}"); }++/// Filled neighbours block both left- and right-aligned overflow in either theme.+#[test]+fn text_overflow_stops_at_explicit_fills_in_both_directions() {+    for dark in [false, true] {+        for (source, align) in [("B2", "Left"), ("D2", "Right")] {+            let mut h = harness(|s| {+                s.execute("cell.set", json!({"cell":source,"input":LEFT})).unwrap();+                s.execute("home.fillColor", json!({"range":source,"color":"#1F3864"})).unwrap();+                s.execute("home.formatCells", json!({"range":source,"style":{"align":{"h":align}}})).unwrap();+            });+            h.state_mut().ui.dark = dark;+            h.run_steps(4);+            let neighbor = cell_rect(&h, "C2").shrink(1.0);+            assert!(text_clips(&h, LEFT).iter().any(|r| r.intersects(neighbor)), "unfilled neighbour permits overflow");+            h.state_mut().run("home.fillColor", json!({"range":"C2","color":"#FFFF00"})).unwrap();+            h.run_steps(4);+            let clips = text_clips(&h, LEFT);+            assert!(!clips.is_empty(), "source text remains painted");+            assert!(clips.iter().all(|r| !r.intersects(neighbor)), "filled neighbour blocks {align} overflow, dark={dark}: {clips:?}");+        }+    }+}++/// Table, conditional and patterned fills block overflow even when the neighbour has no value.+#[test]+fn text_overflow_stops_at_resolved_fills() {+    for dark in [false, true] {+        for fill in ["conditional", "table", "pattern"] {+            let mut h = harness(|s| {+                s.execute("cell.set", json!({"cell":"B2","input":LEFT})).unwrap();+                s.execute("home.fillColor", json!({"range":"B2","color":"#1F3864"})).unwrap();+                match fill {+                    "conditional" => {+                        s.execute(+                            "home.conditionalFormat",+                            json!({"range":"C2","rule":{"type":"expression","formula":"=TRUE","style":{"fill":"#FFFF00"}}}),+                        )+                        .unwrap();+                    }+                    "table" => {+                        s.execute("cell.set", json!({"cell":"C1","input":"Header"})).unwrap();+                        s.execute("insert.table", json!({"range":"C1:C3","header":true})).unwrap();+                    }+                    "pattern" => {+                        s.execute(+                            "home.formatCells",+                            json!({"range":"C2","style":{"fill":{"pattern":"Gray50","fg":{"Rgb":0},"bg":{"Rgb":16777215}}}}),+                        )+                        .unwrap();+                    }+                    _ => unreachable!(),+                }+            });+            h.state_mut().ui.dark = dark;+            h.run_steps(4);+            let neighbor = cell_rect(&h, "C2").shrink(1.0);+            let clips = text_clips(&h, LEFT);+            assert!(!clips.is_empty(), "source text remains painted");+            assert!(clips.iter().all(|r| !r.intersects(neighbor)), "{fill} fill blocks overflow, dark={dark}: {clips:?}");+        }+    }+}++/// Center Across Selection ends before a filled cell instead of centring across its background.+#[test]+fn center_across_selection_stops_at_resolved_fill() {+    for dark in [false, true] {+        let mut h = harness(|s| {+            s.execute("cell.set", json!({"cell":"B2","input":"Centered"})).unwrap();+            s.execute("home.formatCells", json!({"range":"B2:E2","style":{"align":{"h":"CenterAcross"}}})).unwrap();+            s.execute("home.conditionalFormat", json!({"range":"D2","rule":{"type":"expression","formula":"=TRUE","style":{"fill":"#FFFF00"}}}))+                .unwrap();+        });+        h.state_mut().ui.dark = dark;+        h.run_steps(4);+        let d2 = cell_rect(&h, "D2");+        let clips = text_clips(&h, "Centered");+        assert!(!clips.is_empty(), "centred text remains painted");+        assert!(clips.iter().all(|r| r.right() <= d2.left() + 0.5), "centred span stops before filled D2, dark={dark}: {clips:?}");+        assert!(clips.iter().any(|r| (r.right() - d2.left()).abs() < 0.5), "unfilled C2 remains part of the span");+    }+}++/// An offscreen filled blocker prevents text farther outside the view from spilling past it.+#[test]+fn offscreen_filled_neighbor_blocks_text_from_an_outside_source() {+    for dark in [false, true] {+        let mut h = harness(|s| {+            s.execute("cell.set", json!({"cell":"B1","input":LEFT})).unwrap();+            s.execute("home.fillColor", json!({"range":"C1","color":"#FFFF00"})).unwrap();+        });+        h.state_mut().ui.dark = dark;+        scroll_to_col(&mut h, 3);+        let d1 = cell_rect(&h, "D1").shrink(1.0);+        assert!(text_clips(&h, LEFT).iter().all(|r| !r.intersects(d1)), "offscreen filled C1 blocks B1 before visible D1, dark={dark}");+    }+}diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · ** Last updated:** 2026-10-24 · **Change:** minor (engine building blocks for multi-command actions) · **Target:** Microsoft Excel (Microsoft 365)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (sheet colours follow dark mode) · **Target:** Microsoft Excel (Microsoft 365)  How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md). @@ -54,12 +54,18 @@ including tests, 2026-10-10. The panels in `crates/ui-egui` share a few pieces, so a new feature reuses them instead of drawing its own: -- **Colours**: `theme::Tokens`, each with a light and a dark value. The sheet stays white in both-  themes, so colours drawn on it (`sheet_ref_colors`, `sheet_control_*`, icons through-  `icons::paint_on_sheet`) keep their light values. `theme::contrast_tests` holds every-  foreground/background pair to WCAG 2 (4.5:1 for text, 3:1 for glyphs) in both themes; a new-  token pair is added there. Icons (`icons.rs`, drawn in code) take their paper and category-  colours (`theme::Cat`) from the tokens.+- **Colours**: `theme::Tokens`, each with a light and a dark value. Default cells use black+  backgrounds and white text in dark mode, including merged cells and the in-cell editor.+  Explicit workbook fills and font colours are preserved; automatic text uses whichever of black+  or white has the higher WCAG contrast against the resolved cell fill. The in-cell editor uses+  the grid's same resolved style and active conditional-format cache for its fill, font and ink,+  preserving explicit text colours while choosing contrasting caret ink independently. Gridlines,+  formula references, sheet controls and icons follow the theme. Text overflow and Center Across+  Selection only extend through unmerged empty cells without a resolved fill, including offscreen+  neighbours checked when finding an overflowing source.+  `theme::contrast_tests` holds foreground/background pairs to WCAG 2 (4.5:1 for text, 3:1 for+  glyphs) in both themes. Icons (`icons.rs`, drawn in code) take their paper and category colours+  (`theme::Cat`) from the tokens. - **Ribbon widgets**: `widgets.rs` has the buttons every tab is built from (`big_button`,   `small_button`, `split_button`, `big_split_button`, `toggle_button`), translated through   `i18n` in one place. Split buttons return the main and arrow responses separately, with@@ -150,6 +156,11 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Text overflow and Center Across Selection stop at empty neighbours with resolved explicit, table, conditional or patterned fills; offscreen source discovery respects the same blockers in both themes |+| 2026-10-26 | minor | Editor text preserves its resolved font colour while the caret independently chooses contrasting black or white, including when explicit text matches its fill; documented the affected rendering and regression-test contracts |+| 2026-10-26 | minor | In-cell editing uses the grid's shared resolved style and active conditional-format cache, preserving table, conditional, scale and patterned fills plus explicit font colours for text and caret |+| 2026-10-26 | minor | Automatic cell ink and in-cell editor text/caret compare WCAG contrast for black and white against the resolved fill, keeping saturated bright green readable |+| 2026-10-26 | minor | Dark mode uses black default cells with white text, including merged cells and editing; gridlines, formula references and sheet controls follow the theme. Explicit workbook colours stay intact, with contrasting automatic text on filled cells | | 2026-10-24 | minor | Engine building blocks for multi-command actions: undo groups, recording buffer, document trust, chunked hidden-name storage | | 2026-10-14 | minor | Document Undo's retained-cell-storage budget and latest-step guarantee | | 2026-10-10 | major | Created from the code on main: crates, layers, data flow, agent control, known gaps |diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (black cells and white text in dark mode) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4  Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured@@ -30,7 +30,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Status bar: Sum/Count/Average and more, view buttons, zoom | yes | yes; a right-click on the status bar opens a menu that turns the statistics on and off (Average, Count, Numerical Count, Minimum, Maximum, Sum; `view.statusBar`) | done for the statistics; the other status bar options are not customizable | | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes; PivotTable source-field list scrollbar sits at the right edge of its pane | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | template gallery missing |-| Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239); icons, reference colours, primary buttons and the comments pane follow the theme, and a unit test holds every token pair to WCAG 2 contrast in both themes |+| Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239); default cells (black with white text), merged cells, the in-cell editor, gridlines, icons, reference colours, primary buttons and the comments pane follow the theme; explicit workbook colours are preserved, and a unit test holds every token pair to WCAG 2 contrast in both themes | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation mode and iterative calculation with Maximum Iterations and Maximum Change, 10-15) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | Dark mode, system appearance | yes | yes | done (10-09) | | Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab, whose Different First Page and Different Odd & Even Pages give the first page and the even pages their own header and footer (#344), and whose Scale with Document and Align with Page Margins set whether header and footer text follows the print scale and the side margins, on paper and in the view | margins drag on the rulers (#792, with header and footer margins); header/footer pictures missing (1–3 h). Unverified: with Align with Page Margins off, header and footer text sits 0.7 in from the paper edge, a value not yet measured in the target app |@@ -186,6 +186,11 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | minor | Text overflow and Center Across Selection stop at empty neighbours with resolved explicit, table, conditional or patterned fills; offscreen source discovery respects the same blockers in both themes |+| 2026-10-26 | minor | Editor text preserves its resolved font colour while the caret independently chooses contrasting black or white, including when explicit text matches its fill; documented the affected rendering and regression-test contracts |+| 2026-10-26 | minor | In-cell editing uses the grid's shared resolved style and active conditional-format cache, preserving table, conditional, scale and patterned fills plus explicit font colours for text and caret |+| 2026-10-26 | minor | Automatic cell text and in-cell editor text/caret use the higher-contrast black or white ink; explicit bright green fills now use black in both themes |+| 2026-10-26 | minor | Dark mode uses black default cells with white text, including merged cells and editing; gridlines, formula references and sheet controls follow the theme. Explicit workbook colours stay intact, with contrasting automatic text on filled cells | | 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty | | 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified | | 2026-10-24 | minor | Go To lists the named ranges on every sheet, as Excel's does ("The Go to popup window shows named ranges on every worksheet in your workbook"): a name scoped to another sheet appears as `Sheet2!Name`, and hidden names, constants and formulas are left out. It keeps the references entered (newest first, ten at most), and a double-click on an entry goes there. The Reference box and `edit.goTo` take several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30,H21:H29`) and select them all; `edit.goTo {list: true}` returns the list |