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:
Fini 2026-07-04 02:15:56 +08:00
parent aec565f3b3
commit dbf6bc0d1d
2 changed files with 164 additions and 0 deletions

View file

@ -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;
}

View file

@ -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());
}