graphic design tool
git clone https://git.lucas.co/cce-designer.git
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 {