git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commitf7c9c13020250067da7944585a797d9fe9d68e40
parentd39809d54d
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 21:29
feat(network): a node tint of its own, and dark names on a light one

style.surface.graph.node_tint tints the node bodies and the name floors
apart from the pane, so a light tint under node_compression is a light
node. With floors and a tint whose linear luminance is past 0.179 the
names are written dark (State::node_ink); without floors they stand on
the scene and stay light.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md     | 11 +++++++++++
 src/app.rs    | 16 ++++++++++++++++
 src/main.rs   | 19 +++++++++++++++++++
 src/render.rs | 53 ++++++++++++++++++++++++++++++++++++++++++-----------
 4 files changed, 88 insertions(+), 11 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index f06d20c..2290566 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2732,6 +2732,17 @@ shows and a dark key under light ink: the mid-grey `#6c6c7b` it replaced
 capped full compression at about 3.3:1 against `#ccccd4`. 0.85 was where
 labels read over a bright model in a shadow session; 0.6 left them grey
 on grey.
+**The other way round is a light node with dark names** (since
+2026-10-06): `style.surface.graph.node_tint` (rgba, absent = the pane
+colour) tints the node bodies and the name floors alone, so a light
+tint under compression is a light node on a faint plate.
+`State::node_ink` writes the names dark (`NODE_DARK_INK`) when there
+are floors and the tint's linear luminance is past 0.179, where black
+and white ink contrast it equally; without floors a name stands on the
+bare scene and keeps the widget's light grey. Dark ink reads LIGHTER than
+its colour at label sizes: the glyph pass blends coverage in linear
+space, which thins dark-on-light strokes (a 14 px stem bottoms out near
+`#5c` on `#e6e8f0`). `a_node_name_on_a_light_floor_is_written_dark`.
 The params widget alone also reads `style.surface.param.backdrop_compression`.
 The rules live in cce-ui's CLAUDE.md ("There is one roll width", "The
 relief is two shapes", "Frost is one block"); this is the designer's view of
diff --git a/src/app.rs b/src/app.rs
index d6efd03..5dd5bc5 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -3279,6 +3279,12 @@ pub struct State {
     /// pull the view toward the tint's key and read as solid. None = the
     /// pane's.
     pub node_compression: Option<f32>,
+    /// The node bodies' own tint (`style.surface.graph.node_tint`, rgba,
+    /// LINEAR once read), overriding the pane material's for the bodies and
+    /// the name floors — what compression pulls the backdrop toward, so a
+    /// light tint is a light node. None = the pane's. See
+    /// [`State::node_ink`] for what it does to the names.
+    pub node_tint: Option<[f32; 4]>,
     pub last_design_mod_time: Option<std::time::SystemTime>,
     pub last_config_mod_time: Option<std::time::SystemTime>,
     pub floating_network_layout: (f32, f32, f32, f32),
@@ -8754,6 +8760,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             network_opacity: 0.95,
             node_opacity: 1.0,
             node_compression: None,
+            node_tint: None,
             last_design_mod_time: {
                 let design_path = DesignSettings::file_path();
                 std::fs::metadata(&design_path).and_then(|m| m.modified()).ok()
@@ -9190,9 +9197,18 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
             .pointer("/style/surface/graph/node_compression")
             .and_then(|v| v.as_f64())
             .map(|k| (k as f32).clamp(0.0, 1.0));
+        let node_tint = cce_ui::config::cached_config()
+            .pointer("/style/surface/graph/node_tint")
+            .and_then(|v| v.as_str())
+            .and_then(cce_ui::color::parse_hex_rgba_linear);
 
         let mut changed = false;
 
+        if self.node_tint != node_tint {
+            self.node_tint = node_tint;
+            changed = true;
+        }
+
         if self.node_compression != node_compression {
             self.node_compression = node_compression;
             changed = true;
diff --git a/src/main.rs b/src/main.rs
index 006627b..bb212e1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2254,6 +2254,25 @@ mod tests {
     /// as the first is under the top — and on by default; without it the
     /// rows stand on the scene. Either way the HUD claims only the band its
     /// rows cover: a press or the wheel under it is the viewport's.
+    #[test]
+    fn a_node_name_on_a_light_floor_is_written_dark() {
+        let mut state = State::new(false);
+        let light = cce_ui::color::parse_hex_rgba_linear("#d8dae4cc").unwrap();
+        let dark = cce_ui::color::parse_hex_rgba_linear("#202028cc").unwrap();
+        // No floor: a name stands on the bare scene and keeps the widget's
+        // light ink, whatever the tint.
+        state.node_compression = None;
+        state.node_tint = Some(light);
+        assert_eq!(state.node_ink(), None);
+        // A floor in a dark tint keeps it too; a floor in a light one is
+        // written dark.
+        state.node_compression = Some(0.85);
+        state.node_tint = Some(dark);
+        assert_eq!(state.node_ink(), None);
+        state.node_tint = Some(light);
+        assert!(state.node_ink().is_some_and(|ink| ink.iter().all(|&c| c < 0x40)));
+    }
+
     #[test]
     fn the_params_plate_fits_its_rows() {
         let mut state = State::new(false);
diff --git a/src/render.rs b/src/render.rs
index 105f485..07cdf5a 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -46,6 +46,11 @@ const BYPASS_TINT: [f32; 3] = [1.0, 0.74, 0.18];
 
 /// The point numbers' font size, logical px.
 const POINT_NUMBER_PX: f32 = 10.0;
+/// The linear luminance above which a node name's floor takes dark ink
+/// (State::node_ink): where black and white text contrast it equally.
+const NODE_INK_CROSSOVER: f32 = 0.179;
+/// The dark ink a node name on a light floor is written in, sRGB.
+const NODE_DARK_INK: [u8; 3] = [0x1a, 0x1a, 0x22];
 
 
 impl State {
@@ -536,17 +541,7 @@ impl State {
                 // so the plate's full bevel width would eat most of the body —
                 // a tighter lip keeps the flat face reading.
                 let node_bevel = cce_ui::colors::plate_bevel_width() * 0.5;
-                let node_fill = cce_ui::colors::param_plate_fill();
-                // The pane material, with the nodes' own compression when
-                // configured (State::node_compression): the one knob that
-                // differs between a node body and the plate it sits on.
-                let node_mat = {
-                    let mut m = cce_ui::scene::Material::from_fill(node_fill);
-                    if let (Some(k), cce_ui::scene::Frost::Frosted { compression, .. }) = (self.node_compression, &mut m.frost) {
-                        *compression = k;
-                    }
-                    m
-                };
+                let node_mat = self.node_material();
                 let sel = cce_ui::colors::node_selected_color();
                 let drag = cce_ui::colors::node_drag_color();
                 let hl = cce_ui::colors::highlight_primary_color();
@@ -1035,6 +1030,36 @@ impl State {
             .collect()
     }
 
+    /// The node bodies' material: the pane material, with the nodes' own
+    /// compression (State::node_compression) and tint (State::node_tint)
+    /// where configured — the knobs that differ between a node body and the
+    /// plate it sits on. The name floors wear it too.
+    fn node_material(&self) -> cce_ui::scene::Material {
+        let mut m = cce_ui::scene::Material::from_fill(cce_ui::colors::param_plate_fill());
+        if let (Some(k), cce_ui::scene::Frost::Frosted { compression, .. }) = (self.node_compression, &mut m.frost) {
+            *compression = k;
+        }
+        if let Some(t) = self.node_tint {
+            m.tint = t;
+        }
+        m
+    }
+
+    /// The ink a node name is written in, where it is not the widget's own
+    /// light grey: dark, when the name stands on a floor (node_compression
+    /// set) whose key is light. Compression pulls the backdrop's luminance
+    /// toward the tint's, and the tint is laid over that at its alpha, so
+    /// the floor's luminance is the tint's to within the (1 - k) the
+    /// compression leaves of the backdrop. 0.179 linear is where black and
+    /// white ink have the same contrast against it. Without a floor a name
+    /// stands on the bare scene, and stays light.
+    pub(crate) fn node_ink(&self) -> Option<[u8; 3]> {
+        self.node_compression?;
+        let t = self.node_material().tint;
+        let key = 0.2126 * t[0] + 0.7152 * t[1] + 0.0722 * t[2];
+        (key > NODE_INK_CROSSOVER).then_some(NODE_DARK_INK)
+    }
+
     fn append_frame_text(&self, pc: &mut PaintCtx) {
         let circular = self.circular_network_pane;
         let ncx = self.circular_network_layout.x;
@@ -1092,6 +1117,12 @@ impl State {
                     }
                     // Node text belongs to the node domain: it fades with
                     // node_opacity, not the pane's network_opacity.
+                    // A name on a light floor is written dark (node_ink).
+                    let color = if is_node || i == crate::slots::CONTENT2_IDX {
+                        self.node_ink().unwrap_or(color)
+                    } else {
+                        color
+                    };
                     let alpha = if is_node {
                         self.node_opacity.clamp(0.0, 1.0)
                     } else if is_network_part {