diff --git a/crates/op-ai-skills/skills/domains/mobile-app.md b/crates/op-ai-skills/skills/domains/mobile-app.md index 403812488..5f6162a18 100644 --- a/crates/op-ai-skills/skills/domains/mobile-app.md +++ b/crates/op-ai-skills/skills/domains/mobile-app.md @@ -27,7 +27,7 @@ The status bar (time, signal, wifi, battery) is **automatically pre-inserted** b ## 2) APP CONTENT (your layout) -Chip rows (filter/date/guests pills): each chip HUGS (width fit_content, single-line text, height 36-44, cornerRadius=full); the ROW clips overflow (clipContent) instead of squeezing chips — never let a pill's text wrap. A badge/pill/button frame ALWAYS carries its content (text or icon) — an empty decorated frame renders as a mystery blob. +Chip rows (filter/date/guests pills): each chip HUGS (width fit_content, single-line text, height 36-44, cornerRadius=full); the ROW clips overflow (clipContent) instead of squeezing chips — never let a pill's text wrap. A badge/pill/button frame ALWAYS carries its content (text or icon) — an empty decorated frame renders as a mystery blob. Every tappable CTA/button/pill frame also carries cornerRadius (buttons 8-12, pills/chips full) — omitting it reads as an unstyled placeholder. Keep the mobile root at 0 horizontal padding so status chrome, integrated bottom navigation, and intentional full-bleed media can remain full width. Emit ordinary diff --git a/crates/op-host-services/src/design_agent_tools.rs b/crates/op-host-services/src/design_agent_tools.rs index c9e0cd178..50ffbb583 100644 --- a/crates/op-host-services/src/design_agent_tools.rs +++ b/crates/op-host-services/src/design_agent_tools.rs @@ -1014,32 +1014,117 @@ pub(crate) fn scan_header_icon_row_issues(nodes: &[PenNode]) -> Vec { pub(crate) fn scan_empty_shells(nodes: &[PenNode]) -> Vec { let mut out = Vec::new(); - fn walk(nodes: &[PenNode], out: &mut Vec) { - for node in nodes { + fn walk(nodes: &[PenNode], parent_layout_is_none: bool, out: &mut Vec) { + for (index, node) in nodes.iter().enumerate() { if out.len() >= 12 { return; } if let Some(children) = node.children() { let named = node.base().name.as_deref().unwrap_or(""); - if children.is_empty() + let is_candidate = children.is_empty() && !named.is_empty() - && node.base().role.as_deref() != Some("status-bar") - { + && node.base().role.as_deref() != Some("status-bar"); + if is_candidate { + // A childless named frame under `layout:none` that + // substantially overlaps a non-empty sibling of near- + // identical size is a decorative deck/stack "peek" layer + // (e.g. a flashcard's shadow layers behind the front + // card), not an unfinished skeleton slot — skip it. + if parent_layout_is_none && is_decorative_stack_layer(node, nodes, index) { + continue; + } // Carry the node id alongside the name (matches the other // structural scans' shape) so a loop-end corrective nudge // can name a specific, D()-able / M()-able target instead // of a possibly-ambiguous name alone. out.push(format!("{named} ({})", node.id_str())); } else { - walk(children, out); + walk(children, node_layout_is_none(node), out); } } } } - walk(nodes, &mut out); + walk(nodes, false, &mut out); out } +/// `layout: none` (or the field omitted — same default) positions children +/// by explicit x/y instead of flowing them, which is the only regime where +/// two siblings can legitimately occupy overlapping rects (a flowed +/// vertical/horizontal container never stacks children on top of each +/// other). +fn node_layout_is_none(node: &PenNode) -> bool { + match node { + PenNode::Frame(n) => matches!(n.container.layout, None | Some(LayoutMode::None)), + PenNode::Group(n) => matches!(n.container.layout, None | Some(LayoutMode::None)), + PenNode::Rectangle(n) => matches!(n.container.layout, None | Some(LayoutMode::None)), + _ => false, + } +} + +fn node_rect(node: &PenNode) -> Option<(f64, f64, f64, f64)> { + let x = node.base().x?; + let y = node.base().y?; + let w = node.width_px()?; + let h = node.height_px()?; + Some((x, y, w, h)) +} + +/// A sibling counts as "non-empty" for stack-layer detection when it isn't +/// itself an empty shell: a container with at least one child, or any leaf +/// node (text/image/icon/etc, which have no `children()` at all and always +/// carry their own content). +fn is_nonempty_sibling(node: &PenNode) -> bool { + match node.children() { + Some(children) => !children.is_empty(), + None => true, + } +} + +/// Pure geometry/structure check — no name matching — so it can't be gamed +/// by renaming and can't misfire on an ordinary empty section scaffold +/// (which has no overlapping non-empty sibling to key off). +fn is_decorative_stack_layer(node: &PenNode, siblings: &[PenNode], index: usize) -> bool { + let Some(rect) = node_rect(node) else { + return false; + }; + siblings.iter().enumerate().any(|(j, sibling)| { + if j == index || !is_nonempty_sibling(sibling) { + return false; + } + let Some(other) = node_rect(sibling) else { + return false; + }; + rects_substantially_overlap(rect, other) && rects_near_same_size(rect, other) + }) +} + +/// Intersection area is at least half of EACH rect's own area — a weak +/// corner-touch doesn't count, only a genuine stacked-on-top overlap. +fn rects_substantially_overlap(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)) -> bool { + let (ax, ay, aw, ah) = a; + let (bx, by, bw, bh) = b; + if aw <= 0.0 || ah <= 0.0 || bw <= 0.0 || bh <= 0.0 { + return false; + } + let iw = (ax + aw).min(bx + bw) - ax.max(bx); + let ih = (ay + ah).min(by + bh) - ay.max(by); + if iw <= 0.0 || ih <= 0.0 { + return false; + } + let overlap_area = iw * ih; + overlap_area >= 0.5 * (aw * ah) && overlap_area >= 0.5 * (bw * bh) +} + +/// Width AND height each within 20% of one another. +fn rects_near_same_size(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)) -> bool { + let (_, _, aw, ah) = a; + let (_, _, bw, bh) = b; + let w_diff = (aw - bw).abs() / aw.max(bw).max(1.0); + let h_diff = (ah - bh).abs() / ah.max(bh).max(1.0); + w_diff <= 0.2 && h_diff <= 0.2 +} + pub(crate) fn scan_ring_issues(nodes: &[PenNode]) -> Vec { const MIN_RING_SIZE: f64 = 48.0; const HAIRLINE: f32 = 2.5; @@ -2246,3 +2331,85 @@ mod duplicate_root_tests { assert!(!issues[0].contains("Profile")); } } + +#[cfg(test)] +mod empty_shell_decorative_stack_tests { + use super::*; + + #[test] + fn deck_back_layers_are_exempted_as_decorative_stack() { + // 0724-1-gm-3.op shape: a Flashcard Deck Stack under layout:none — + // Front Flashcard (0,0,338x124, has text children) with two + // childless "peek" layers behind it (Back Layer 1 painted, Back + // Layer 2 unpainted), both offset a few px and near-identical size. + // Neither back layer is an unfinished skeleton slot; both must be + // exempted from the empty-shell blocker. + let nodes: Vec = serde_json::from_value(serde_json::json!([ + { "type": "frame", "id": "deck", "name": "Flashcard Deck Stack", "layout": "none", + "width": 354, "height": 132, + "children": [ + { "type": "frame", "id": "front", "name": "Front Flashcard", + "x": 0, "y": 0, "width": 338, "height": 124, + "children": [ { "type": "text", "id": "t1", "content": "Hello" } ] }, + { "type": "frame", "id": "back1", "name": "Back Layer 1", + "x": 8, "y": 4, "width": 338, "height": 124, + "fill": [{"type": "solid", "color": "$color-surface-3"}], + "children": [] }, + { "type": "frame", "id": "back2", "name": "Back Layer 2", + "x": 16, "y": 8, "width": 338, "height": 124, + "children": [] } + ] } + ])) + .expect("nodes"); + let issues = scan_empty_shells(&nodes); + assert!( + issues.is_empty(), + "deck back layers must be exempted as decorative stack, got {issues:?}" + ); + } + + #[test] + fn ordinary_empty_section_scaffold_still_reported() { + // A childless named section under layout:none with NO overlapping + // non-empty sibling — an unfinished skeleton slot, must still fire. + let nodes: Vec = serde_json::from_value(serde_json::json!([ + { "type": "frame", "id": "root", "name": "Root", "layout": "none", + "width": 390, "height": 400, + "children": [ + { "type": "frame", "id": "header", "name": "Header", + "x": 0, "y": 0, "width": 390, "height": 60, + "children": [ { "type": "text", "id": "t1", "content": "Title" } ] }, + { "type": "frame", "id": "empty-section", "name": "Empty Section", + "x": 0, "y": 200, "width": 390, "height": 120, + "children": [] } + ] } + ])) + .expect("nodes"); + let issues = scan_empty_shells(&nodes); + assert_eq!(issues.len(), 1, "{issues:?}"); + assert!(issues[0].contains("Empty Section")); + } + + #[test] + fn empty_frame_under_vertical_layout_still_reported() { + // Same overlap-shaped geometry, but the parent is auto-layout + // (layout:vertical) — the decorative-stack exemption never applies + // there since flowed children can't legitimately overlap. + let nodes: Vec = serde_json::from_value(serde_json::json!([ + { "type": "frame", "id": "root", "name": "Root", "layout": "vertical", + "width": 390, "height": 400, + "children": [ + { "type": "frame", "id": "front", "name": "Front Flashcard", + "x": 0, "y": 0, "width": 338, "height": 124, + "children": [ { "type": "text", "id": "t1", "content": "Hello" } ] }, + { "type": "frame", "id": "back1", "name": "Back Layer 1", + "x": 8, "y": 4, "width": 338, "height": 124, + "children": [] } + ] } + ])) + .expect("nodes"); + let issues = scan_empty_shells(&nodes); + assert_eq!(issues.len(), 1, "{issues:?}"); + assert!(issues[0].contains("Back Layer 1")); + } +} diff --git a/crates/op-orchestrator/src/role_post_pass.rs b/crates/op-orchestrator/src/role_post_pass.rs index 4900c75b5..1da550f22 100644 --- a/crates/op-orchestrator/src/role_post_pass.rs +++ b/crates/op-orchestrator/src/role_post_pass.rs @@ -792,6 +792,88 @@ fn fix_orphan_container_contrast(node: &mut Value, parent_fill: Option<&Value>) } } +// ── fixDeckFrontCardTransparency ───────────────────────────────────────────── + +/// Explicit `x`/`y`/numeric `width`/`height` rect — only meaningful under +/// `layout: none`, where children are absolutely positioned instead of flowed. +fn deck_layer_rect(node: &Value) -> Option<(f64, f64, f64, f64)> { + let x = node.get("x").and_then(Value::as_f64)?; + let y = node.get("y").and_then(Value::as_f64)?; + let w = size_number(node, "width"); + let h = size_number(node, "height"); + if w <= 0.0 || h <= 0.0 { + return None; + } + Some((x, y, w, h)) +} + +/// Intersection area is at least half of EACH rect's own area — mirrors the +/// empty-shell decorative-stack overlap threshold (`design_agent_tools.rs`) +/// so the two structural checks agree on what "substantially overlapping" +/// means for a `layout:none` card/deck stack. +fn deck_rects_substantially_overlap(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)) -> bool { + let (ax, ay, aw, ah) = a; + let (bx, by, bw, bh) = b; + let iw = (ax + aw).min(bx + bw) - ax.max(bx); + let ih = (ay + ah).min(by + bh) - ay.max(by); + if iw <= 0.0 || ih <= 0.0 { + return false; + } + let overlap_area = iw * ih; + overlap_area >= 0.5 * (aw * ah) && overlap_area >= 0.5 * (bw * bh) +} + +/// A `layout:none` deck/stack's topmost card (`children[0]` — the canvas +/// scene paints siblings topmost-first, see `canvas_viewport_paint.rs`) +/// carrying text but no fill lets a painted sibling beneath it show straight +/// through the text whenever the two substantially overlap: a structural +/// fact (an unfilled text-bearing card sitting over a solid-fill sibling of +/// near-identical footprint always leaks), not a design-intent call, so it's +/// safe to auto-repair. Measured: 0724-1-gm-3.op's Flashcard Deck Stack, +/// where the front card's empty fill let "Back Layer 1"'s +/// `$color-surface-3` bleed across the whole card. `frame`/`rectangle` only +/// (never `ellipse`) — a ring/donut sibling behind a centered label is a +/// deliberate see-through composition, not a leak. +fn fix_deck_front_card_transparency(node: &mut Value) { + if node.get("layout").and_then(Value::as_str) != Some("none") { + return; + } + let Some(children) = node.get("children").and_then(Value::as_array) else { + return; + }; + if children.len() < 2 { + return; + } + let front = &children[0]; + if front.get("type").and_then(Value::as_str) != Some("frame") { + return; + } + if has_fill(front) || !has_text_descendant(front) { + return; + } + let Some(front_rect) = deck_layer_rect(front) else { + return; + }; + let leaks_through = children.iter().skip(1).any(|sibling| { + matches!( + sibling.get("type").and_then(Value::as_str), + Some("frame") | Some("rectangle") + ) && has_visible_fill(sibling) + && deck_layer_rect(sibling) + .is_some_and(|rect| deck_rects_substantially_overlap(front_rect, rect)) + }); + if !leaks_through { + return; + } + let Some(children) = node.get_mut("children").and_then(Value::as_array_mut) else { + return; + }; + // Semantic token, not a literal hex — same convention as + // `fix_orphan_container_contrast` (a later surface-discipline pass + // resolves `$color-surface` against the active theme). + children[0]["fill"] = solid_fill("$color-surface"); +} + // ── normalizeNestedSearchShell ────────────────────────────────────────────── fn child_role(child: &Value) -> Option<&str> { @@ -2018,6 +2100,7 @@ fn post_pass_value(node: &mut Value, parent_fill: Option, canvas_width: f fix_button_foreground_contrast(node); fix_section_alternation(node); fix_orphan_container_contrast(node, parent_fill.as_ref()); + fix_deck_front_card_transparency(node); fix_container_text_contrast(node); fix_input_sibling_consistency(node); @@ -2432,6 +2515,92 @@ fn round_count_badges(node: &mut Value) { } } +/// True when `node` has at least one DIRECT child of type `text` — the +/// structural line between a tappable LABEL surface (button/badge: icon + +/// text, or text alone) and an icon-only tap target (avatar / icon-box), +/// which must never be swept into pill-rounding by this pass. +fn has_direct_text_child(node: &Value) -> bool { + node.get("children") + .and_then(Value::as_array) + .is_some_and(|children| { + children + .iter() + .any(|child| child.get("type").and_then(Value::as_str) == Some("text")) + }) +} + +/// Structural "compact painted capsule with text" shape shared by the +/// corner-rounding consistency gate and the missing-radius candidate +/// detector below. Reuses `is_compact_capsule_surface` — the same hug/size +/// anatomy `round_count_badges` keys off, which already handles BOTH +/// literal-pixel small frames AND `fit_content`-sized ones (real CTA +/// buttons are almost always the latter: padding + content, no authored +/// width/height) — plus an explicit text-child requirement so a text-less +/// icon-box/avatar is never mistaken for a label surface. Radius state is +/// checked separately by each caller — this only describes the anatomy. +fn is_compact_painted_capsule_with_text(node: &Value) -> bool { + if node.get("type").and_then(Value::as_str) != Some("frame") + || !has_visible_fill(node) + || !has_direct_text_child(node) + { + return false; + } + let words = name_words(node); + is_compact_capsule_surface(node, &words, false) +} + +/// Count `is_compact_painted_capsule_with_text` nodes that already carry an +/// authored `cornerRadius >= 6` anywhere in `node`'s subtree — the evidence +/// that THIS design's own convention is rounded compact surfaces. +fn count_rounded_compact_capsules(node: &Value, out: &mut u32) { + if is_compact_painted_capsule_with_text(node) && corner_radius(node) >= 6.0 { + *out += 1; + } + if let Some(children) = node.get("children").and_then(Value::as_array) { + for child in children { + count_rounded_compact_capsules(child, out); + } + } +} + +/// Structural fallback for CTA/pill corner rounding — a PAINTED, compact, +/// hug-anatomy frame carrying a text child (button/badge/pill) reads as a +/// tappable label surface, not a card or an icon-only tap target. No name +/// matching: a text-less icon-box fails `has_direct_text_child`, and a +/// large/loose container fails `is_compact_capsule_surface`'s own anatomy +/// bounds. +/// +/// Gated on document consistency: fires only when this screen root already +/// has >= 2 OTHER compact painted capsules-with-text carrying an authored +/// `cornerRadius >= 6` — proof the design's own convention is rounded +/// compact surfaces, so an intentionally all-sharp-corners design system is +/// never touched by this pass. +fn round_missing_compact_pill_radius(root: &mut Value) { + let mut existing = 0u32; + count_rounded_compact_capsules(root, &mut existing); + if existing < 2 { + return; + } + fn walk(node: &mut Value) { + if node.get("cornerRadius").is_none() && is_compact_painted_capsule_with_text(node) { + // Height is usually `fit_content` (no literal number) for a hug + // button, matching the prompt guidance's "buttons 8-12" default; + // when a literal height IS authored, stay under half of it so a + // tall capsule doesn't get an accidental full-pill look. + let radius = numeric_prop(node, "height") + .map(|h| (h / 2.0).min(10.0)) + .unwrap_or(10.0); + node["cornerRadius"] = json!(radius); + } + if let Some(children) = node.get_mut("children").and_then(Value::as_array_mut) { + for child in children.iter_mut() { + walk(child); + } + } + } + walk(root); +} + /// 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. @@ -2476,6 +2645,7 @@ pub fn enforce_surface_color_discipline(nodes: &mut [PenNode]) { fix_surface_color_discipline(&mut v, true); round_missing_semantic_micro_surfaces(&mut v, false); round_count_badges(&mut v); + round_missing_compact_pill_radius(&mut v); if let Ok(new_node) = serde_json::from_value::(v) { *node = new_node; } @@ -2642,3 +2812,11 @@ mod saturated_fill_contrast_tests { #[cfg(test)] #[path = "role_post_pass_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "role_post_pass_deck_tests.rs"] +mod deck_tests; + +#[cfg(test)] +#[path = "role_post_pass_pill_radius_tests.rs"] +mod pill_radius_tests; diff --git a/crates/op-orchestrator/src/role_post_pass_deck_tests.rs b/crates/op-orchestrator/src/role_post_pass_deck_tests.rs new file mode 100644 index 000000000..66e52e6ce --- /dev/null +++ b/crates/op-orchestrator/src/role_post_pass_deck_tests.rs @@ -0,0 +1,114 @@ +//! Tests for `fix_deck_front_card_transparency` — the `layout:none` +//! card/deck-stack front-card transparency contract (0724-1-gm-3.op's +//! Flashcard Deck Stack, where the front card's empty fill let the painted +//! "Back Layer 1" bleed through the text). + +use super::*; + +fn run(mut nodes: Vec) -> serde_json::Value { + post_pass_forest(&mut nodes, 375.0); + serde_json::to_value(&nodes[0]).unwrap() +} + +#[test] +fn deck_front_card_gets_a_surface_fill_when_a_painted_layer_bleeds_through() { + let nodes: Vec = vec![serde_json::from_value(json!({ + "type":"frame","id":"deck","name":"Flashcard Deck Stack","layout":"none", + "x":0,"y":0,"width":354,"height":132, + "children":[ + {"type":"frame","id":"front","name":"Front Flashcard", + "x":0,"y":0,"width":338,"height":124, + "children":[{"type":"text","id":"t1","content":"Hello"}]}, + {"type":"frame","id":"back1","name":"Back Layer 1", + "x":8,"y":4,"width":338,"height":124, + "fill":[{"type":"solid","color":"$color-surface-3"}], + "children":[]}, + {"type":"frame","id":"back2","name":"Back Layer 2", + "x":16,"y":8,"width":338,"height":124, + "children":[]} + ] + })) + .unwrap()]; + let v = run(nodes); + assert_eq!( + v["children"][0]["fill"], + json!([{"type":"solid","color":"$color-surface"}]), + "front card must get a semantic surface fill so the painted back layer stops bleeding through: {v}" + ); +} + +#[test] +fn front_card_with_its_own_fill_is_left_alone() { + let nodes: Vec = vec![serde_json::from_value(json!({ + "type":"frame","id":"deck","name":"Deck","layout":"none", + "x":0,"y":0,"width":354,"height":132, + "children":[ + {"type":"frame","id":"front","name":"Front", + "x":0,"y":0,"width":338,"height":124, + "fill":[{"type":"solid","color":"#FFFFFF"}], + "children":[{"type":"text","id":"t1","content":"Hello"}]}, + {"type":"frame","id":"back1","name":"Back", + "x":8,"y":4,"width":338,"height":124, + "fill":[{"type":"solid","color":"$color-surface-3"}], + "children":[]} + ] + })) + .unwrap()]; + let v = run(nodes); + assert_eq!( + v["children"][0]["fill"], + json!([{"type":"solid","color":"#FFFFFF"}]), + "front card already opaque — must not be touched: {v}" + ); +} + +#[test] +fn unpainted_back_layer_is_not_a_leak_source() { + // Neither layer paints anything — no fact of a visible leak, so the + // front card (still unfilled) must not be touched. + let nodes: Vec = vec![serde_json::from_value(json!({ + "type":"frame","id":"deck","name":"Deck","layout":"none", + "x":0,"y":0,"width":354,"height":132, + "children":[ + {"type":"frame","id":"front","name":"Front", + "x":0,"y":0,"width":338,"height":124, + "children":[{"type":"text","id":"t1","content":"Hello"}]}, + {"type":"frame","id":"back1","name":"Back", + "x":8,"y":4,"width":338,"height":124, + "children":[]} + ] + })) + .unwrap()]; + let v = run(nodes); + assert_eq!( + v["children"][0].get("fill"), + None, + "no painted sibling behind the front card — nothing to leak, must stay untouched: {v}" + ); +} + +#[test] +fn donut_ring_ellipse_sibling_behind_a_label_is_not_misfired_on() { + // A ring/donut composition: an unfilled label frame sits over a PAINTED + // ellipse ring by deliberate design (the ring must show through) — the + // sibling is an ellipse, never eligible as a "leak source" painted card. + let nodes: Vec = vec![serde_json::from_value(json!({ + "type":"frame","id":"donut","name":"Donut Wrapper","layout":"none", + "x":0,"y":0,"width":120,"height":120, + "children":[ + {"type":"frame","id":"label","name":"Center Label", + "x":30,"y":45,"width":60,"height":30, + "children":[{"type":"text","id":"t1","content":"72%"}]}, + {"type":"ellipse","id":"ring","name":"Ring", + "x":0,"y":0,"width":120,"height":120, + "fill":[{"type":"solid","color":"$color-accent"}]} + ] + })) + .unwrap()]; + let v = run(nodes); + assert_eq!( + v["children"][0].get("fill"), + None, + "ellipse ring siblings must never trigger the deck front-card fix: {v}" + ); +} diff --git a/crates/op-orchestrator/src/role_post_pass_pill_radius_tests.rs b/crates/op-orchestrator/src/role_post_pass_pill_radius_tests.rs new file mode 100644 index 000000000..e84587ba6 --- /dev/null +++ b/crates/op-orchestrator/src/role_post_pass_pill_radius_tests.rs @@ -0,0 +1,170 @@ +//! Tests for `round_missing_compact_pill_radius` — the structural fallback +//! for CTA/pill corner rounding. Fixtures mirror 0724-1-gm-3.op's real +//! shape: hug-sized (`fit_content`) buttons authored via padding + content, +//! not literal pixel width/height — n180 "已完成" (icon + text), n192 +//! "继续", n203 "开始", none carrying a `cornerRadius`, sitting in a screen +//! that already has several `cornerRadius: 9999` badges (n144 "Level +//! Badge", n168, n209 "Count Badge", n233 "Rank Badge"). + +use super::*; + +fn run(mut nodes: Vec) -> serde_json::Value { + enforce_surface_color_discipline(&mut nodes); + serde_json::to_value(&nodes[0]).unwrap() +} + +/// A hug-sized painted badge/pill, `fit_content` on both axes, matching the +/// real corpus's authoring convention (padding + content, no literal size). +fn hug_badge(id: &str, content: &str, corner_radius: Option) -> serde_json::Value { + let mut v = json!({ + "type":"frame","id":id, + "width":"fit_content","height":"fit_content", + "padding":[4.0, 10.0], + "fill":[{"type":"solid","color":"$color-surface-3"}], + "children":[{"type":"text","id":format!("{id}-t"),"width":"fit_content", + "height":"fit_content","content":content}] + }); + if let Some(r) = corner_radius { + v["cornerRadius"] = json!(r); + } + v +} + +#[test] +fn unrounded_hug_cta_buttons_are_rounded_when_the_design_already_uses_rounded_badges() { + let nodes: Vec = vec![serde_json::from_value(json!({ + "type":"frame","id":"root","name":"Today Page","width":375,"height":"fit_content", + "layout":"vertical", + "children":[ + hug_badge("n144", "LVL 12", Some(9999.0)), + hug_badge("n168", "2/3 完成", Some(9999.0)), + // Real gm-3 shape: icon + text CTA, no cornerRadius. + {"type":"frame","id":"n180","layout":"horizontal","gap":4.0, + "padding":[6.0, 12.0],"alignItems":"center", + "fill":[{"type":"solid","color":"#22C55E1A"}], + "children":[ + {"type":"icon_font","id":"n181","iconFontName":"check","width":14.0,"height":14.0, + "fill":[{"type":"solid","color":"#22C55E"}]}, + {"type":"text","id":"n182","width":"fit_content","height":"fit_content", + "content":"已完成"} + ]}, + // Real gm-3 shape: text-only CTA, no cornerRadius. + {"type":"frame","id":"n192","layout":"horizontal","padding":[8.0, 16.0], + "justifyContent":"center","alignItems":"center", + "fill":[{"type":"solid","color":"$color-accent"}], + "children":[{"type":"text","id":"n193","width":"fit_content","height":"fit_content", + "content":"继续"}]} + ] + })) + .unwrap()]; + let v = run(nodes); + let children = v["children"].as_array().unwrap(); + let by_id = |id: &str| children.iter().find(|c| c["id"] == id).unwrap(); + + assert_eq!( + by_id("n180")["cornerRadius"], + json!(10.0), + "icon+text CTA (fit_content) must get a fallback radius: {v}" + ); + assert_eq!( + by_id("n192")["cornerRadius"], + json!(10.0), + "text-only CTA (fit_content) must get a fallback radius: {v}" + ); + // Already-rounded badges are untouched — their authored radius stands. + assert_eq!(by_id("n144")["cornerRadius"], json!(9999.0)); + assert_eq!(by_id("n168")["cornerRadius"], json!(9999.0)); +} + +#[test] +fn all_sharp_corner_design_is_left_alone() { + // No OTHER rounded compact capsule anywhere in the screen — the + // consistency gate has no evidence this design uses rounded pills, so + // the CTA (structurally identical to the positive case) must stay + // untouched: a deliberate all-sharp-corners system is not + // strong-armed into rounding. + let nodes: Vec = vec![serde_json::from_value(json!({ + "type":"frame","id":"root","name":"Screen","width":375,"height":"fit_content", + "layout":"vertical", + "children":[ + {"type":"frame","id":"n192","layout":"horizontal","padding":[8.0, 16.0], + "justifyContent":"center","alignItems":"center", + "fill":[{"type":"solid","color":"$color-accent"}], + "children":[{"type":"text","id":"n193","width":"fit_content","height":"fit_content", + "content":"继续"}]} + ] + })) + .unwrap()]; + let v = run(nodes); + assert!( + v["children"][0].get("cornerRadius").is_none(), + "CTA must stay untouched with no rounded-pill evidence: {v}" + ); +} + +#[test] +fn icon_only_box_is_never_rounded_even_with_rounded_badge_evidence() { + // 0724-1-gm-3.op's real n173/n185/n196 shape: a 44x44 painted icon-only + // tap target sitting right next to CTA buttons — must never be rounded + // by this pass, however strong the consistency-gate evidence, because + // it has no text child. + let nodes: Vec = vec![serde_json::from_value(json!({ + "type":"frame","id":"root","name":"Screen","width":375,"height":"fit_content", + "layout":"vertical", + "children":[ + hug_badge("n144", "LVL 12", Some(9999.0)), + hug_badge("n168", "2/3 完成", Some(9999.0)), + {"type":"frame","id":"n173","width":44.0,"height":44.0,"layout":"horizontal", + "justifyContent":"center","alignItems":"center", + "fill":[{"type":"solid","color":"#22C55E1A"}], + "children":[{"type":"icon_font","id":"n174","iconFontName":"headphones", + "width":20.0,"height":20.0, + "fill":[{"type":"solid","color":"#22C55E"}]}]} + ] + })) + .unwrap()]; + let v = run(nodes); + let icon_box = v["children"] + .as_array() + .unwrap() + .iter() + .find(|c| c["id"] == "n173") + .unwrap(); + assert!( + icon_box.get("cornerRadius").is_none(), + "icon-only box (no text child) must never be rounded by this pass: {v}" + ); +} + +#[test] +fn literal_height_capsule_uses_half_height_when_under_ten() { + // A capsule WITH an authored literal height (not the common fit_content + // case) stays under half its own height instead of always defaulting + // to 10, so a very short pill doesn't get an oversized radius. + let nodes: Vec = vec![serde_json::from_value(json!({ + "type":"frame","id":"root","name":"Screen","width":375,"height":"fit_content", + "layout":"vertical", + "children":[ + hug_badge("n144", "LVL 12", Some(9999.0)), + hug_badge("n168", "2/3 完成", Some(9999.0)), + {"type":"frame","id":"short-pill","width":80.0,"height":16.0,"layout":"horizontal", + "justifyContent":"center","alignItems":"center", + "fill":[{"type":"solid","color":"$color-accent"}], + "children":[{"type":"text","id":"sp-t","width":"fit_content","height":"fit_content", + "content":"New"}]} + ] + })) + .unwrap()]; + let v = run(nodes); + let pill = v["children"] + .as_array() + .unwrap() + .iter() + .find(|c| c["id"] == "short-pill") + .unwrap(); + assert_eq!( + pill["cornerRadius"], + json!(8.0), + "16px-tall literal-height pill must get height/2=8, not the 10 fallback: {v}" + ); +}