diff --git a/crates/op-orchestrator/src/role_post_pass.rs b/crates/op-orchestrator/src/role_post_pass.rs index c83688d03..6a62438d4 100644 --- a/crates/op-orchestrator/src/role_post_pass.rs +++ b/crates/op-orchestrator/src/role_post_pass.rs @@ -1840,6 +1840,15 @@ fn fix_surface_color_discipline(node: &mut Value, is_root: bool) { node["fill"] = solid_fill("$color-surface-2"); } else if !is_root && color == PAGE_BG_REF { node["fill"] = json!([]); + } else if color.starts_with("$color-text-") && is_container_kind(node) { + // A CONTAINER filled with a TEXT token is a slot-category error — + // a search pill painted `$color-text-primary` rendered as a WHITE + // capsule on the dark luxury theme (measured: ATELIER's search + + // FILTER pills). Text tokens color glyphs; the container slot for + // inputs/chips is surface-2. Its dark literal text (styled for + // the accidental white) flips to the text ladder with it. + node["fill"] = solid_fill("$color-surface-2"); + rebind_dark_literal_text(node); } } // An elevation shadow needs a surface to sit on. A frame with no visible @@ -1860,6 +1869,75 @@ fn fix_surface_color_discipline(node: &mut Value, is_root: bool) { } } +/// Container node kinds whose `fill` is a SURFACE slot (never a glyph color). +fn is_container_kind(node: &Value) -> bool { + matches!( + node.get("type").and_then(Value::as_str), + Some("frame" | "group" | "rectangle" | "text_input") + ) +} + +/// A COUNT BADGE (a painted chip whose only child is a 1-3 digit text — a +/// nav item's "12") reads as a stray square when the model omits its corner +/// radius; the badge convention is a pill. Only fires when `cornerRadius` +/// is ABSENT — an authored radius (0 included, the sharp-luxury look) is a +/// decision and stays. +fn round_count_badges(node: &mut Value) { + let is_frame = node.get("type").and_then(Value::as_str) == Some("frame"); + if is_frame && node.get("cornerRadius").is_none() { + let painted = node + .get("fill") + .map(|f| match f { + Value::Array(a) => !a.is_empty(), + Value::Null => false, + _ => true, + }) + .unwrap_or(false); + let kids = node + .get("children") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or(&[]); + let lone_count_text = kids.len() == 1 + && kids[0].get("type").and_then(Value::as_str) == Some("text") + && kids[0] + .get("content") + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|c| { + !c.is_empty() + && c.len() <= 3 + && c.chars().all(|ch| ch.is_ascii_digit() || ch == '+') + }); + if painted && lone_count_text { + node["cornerRadius"] = json!(100.0); + } + } + if let Some(children) = node.get_mut("children").and_then(Value::as_array_mut) { + for child in children.iter_mut() { + round_count_badges(child); + } + } +} + +/// After a container's accidental text-token fill flips to a surface, its +/// TEXT descendants styled for that light pill (dark literal hex) become +/// unreadable on the dark surface — walk them onto the text ladder. +fn rebind_dark_literal_text(node: &mut Value) { + if node.get("type").and_then(Value::as_str) == Some("text") { + if let Some(color) = get_first_solid_color(node) { + if hex_luminance(&color).is_some_and(|l| l < 0.45) { + node["fill"] = solid_fill("$color-text-muted"); + } + } + } + if let Some(children) = node.get_mut("children").and_then(Value::as_array_mut) { + for child in children.iter_mut() { + rebind_dark_literal_text(child); + } + } +} + pub fn post_pass_forest(nodes: &mut [PenNode], canvas_width: f64) { for node in nodes.iter_mut() { let Ok(mut v) = serde_json::to_value(&*node) else { @@ -1884,6 +1962,7 @@ pub fn enforce_surface_color_discipline(nodes: &mut [PenNode]) { continue; }; fix_surface_color_discipline(&mut v, true); + round_count_badges(&mut v); if let Ok(new_node) = serde_json::from_value::(v) { *node = new_node; } diff --git a/crates/op-orchestrator/src/role_post_pass_tests.rs b/crates/op-orchestrator/src/role_post_pass_tests.rs index f5e40c20a..4e460ae1c 100644 --- a/crates/op-orchestrator/src/role_post_pass_tests.rs +++ b/crates/op-orchestrator/src/role_post_pass_tests.rs @@ -1250,3 +1250,88 @@ fn post_pass_forest_round_trips_and_fills_orphan_card() { "orphan card inside an unfilled section root gets a white fill" ); } + +#[test] +fn text_token_container_fill_flips_to_surface_with_its_dark_text() { + // ATELIER's verbatim slot error: a search pill filled with + // `$color-text-primary` (white capsule on the dark theme), its + // placeholder styled #404040 FOR that accidental white. The container + // flips to the surface slot; the dark literal text joins the ladder. + let mut nodes: Vec = vec![serde_json::from_value(json!({ + "type":"frame","id":"pill","name":"Search Container","layout":"horizontal","cornerRadius":8, + "fill":[{"type":"solid","color":"$color-text-primary"}], + "children":[ + {"type":"text","id":"ph","content":"Search clients...","fill":[{"type":"solid","color":"#404040"}]}, + {"type":"text","id":"gold","content":"FILTER","fill":[{"type":"solid","color":"$color-accent"}]} + ] + })) + .unwrap()]; + enforce_surface_color_discipline(&mut nodes); + let v = serde_json::to_value(&nodes[0]).unwrap(); + assert_eq!( + v["fill"][0]["color"].as_str(), + Some("$color-surface-2"), + "container fill rebound to the surface slot: {v}" + ); + assert_eq!( + v["children"][0]["fill"][0]["color"].as_str(), + Some("$color-text-muted"), + "dark literal placeholder joins the text ladder" + ); + assert_eq!( + v["children"][1]["fill"][0]["color"].as_str(), + Some("$color-accent"), + "token-bound text is left alone" + ); +} + +#[test] +fn text_nodes_keep_text_tokens() { + // The rule targets CONTAINERS — a text node filled with a text token is + // exactly right and must not be touched. + let mut nodes: Vec = vec![serde_json::from_value(json!({ + "type":"text","id":"t","content":"Heading", + "fill":[{"type":"solid","color":"$color-text-primary"}] + })) + .unwrap()]; + enforce_surface_color_discipline(&mut nodes); + let v = serde_json::to_value(&nodes[0]).unwrap(); + assert_eq!(v["fill"][0]["color"].as_str(), Some("$color-text-primary")); +} + +#[test] +fn count_badge_without_radius_becomes_a_pill() { + let mut nodes: Vec = vec![serde_json::from_value(json!({ + "type":"frame","id":"badge","layout":"horizontal","padding":[3,8], + "fill":[{"type":"solid","color":"#C9A96220"}], + "children":[{"type":"text","id":"n","content":"12","fontSize":11}] + })) + .unwrap()]; + enforce_surface_color_discipline(&mut nodes); + let v = serde_json::to_value(&nodes[0]).unwrap(); + assert_eq!(v["cornerRadius"].as_f64(), Some(100.0), "{v}"); +} + +#[test] +fn authored_radius_and_word_chips_stay() { + // cornerRadius 0 (sharp luxury) is a decision; a WORD chip ("VIP") is + // not a count badge. + let sharp: jian_ops_schema::node::PenNode = serde_json::from_value(json!({ + "type":"frame","id":"b1","layout":"horizontal","cornerRadius":0,"padding":[3,8], + "fill":[{"type":"solid","color":"#C9A96220"}], + "children":[{"type":"text","id":"n1","content":"12"}] + })) + .unwrap(); + let word: jian_ops_schema::node::PenNode = serde_json::from_value(json!({ + "type":"frame","id":"b2","layout":"horizontal","padding":[3,8], + "fill":[{"type":"solid","color":"#22C55E18"}], + "children":[{"type":"text","id":"n2","content":"VIP"}] + })) + .unwrap(); + let mut nodes = vec![sharp, word]; + enforce_surface_color_discipline(&mut nodes); + let v0 = serde_json::to_value(&nodes[0]).unwrap(); + let v1 = serde_json::to_value(&nodes[1]).unwrap(); + assert_eq!(v0["cornerRadius"].as_f64(), Some(0.0)); + assert!(v1.get("cornerRadius").is_none() || v1["cornerRadius"].is_null()); +}