fix(orchestrator): remap text tokens painted on containers
A container filled with a $color-text-* token is a slot-category error (a search pill filled text-primary rendered as a white capsule on a dark theme); remap to the surface token family.
This commit is contained in:
parent
aec565f3b3
commit
dbf6bc0d1d
|
|
@ -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::<PenNode>(v) {
|
||||
*node = new_node;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<jian_ops_schema::node::PenNode> = 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<jian_ops_schema::node::PenNode> = 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<jian_ops_schema::node::PenNode> = 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());
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue