fix(agent): close deck opacity and cta corner-radius blind spots

Structural fixes with no name heuristics: exempt decorative stack layers from the empty-shell blocker (it was pushing models to stuff content into deck back layers), give a transparent text-bearing front card a surface fill when a painted layer bleeds through, and round compact painted text capsules missing a corner radius when the design already shows a rounded convention. Teach mobile-app domain that tappable CTAs carry cornerRadius.
This commit is contained in:
Fini 2026-07-25 08:01:53 +08:00
parent ac14c0c3b6
commit 7831cea7b1
5 changed files with 637 additions and 8 deletions

View file

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

View file

@ -1014,32 +1014,117 @@ pub(crate) fn scan_header_icon_row_issues(nodes: &[PenNode]) -> Vec<String> {
pub(crate) fn scan_empty_shells(nodes: &[PenNode]) -> Vec<String> {
let mut out = Vec::new();
fn walk(nodes: &[PenNode], out: &mut Vec<String>) {
for node in nodes {
fn walk(nodes: &[PenNode], parent_layout_is_none: bool, out: &mut Vec<String>) {
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<String> {
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<PenNode> = 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<PenNode> = 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<PenNode> = 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"));
}
}

View file

@ -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<Value>, 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::<PenNode>(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;

View file

@ -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<PenNode>) -> 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<PenNode> = 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<PenNode> = 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<PenNode> = 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<PenNode> = 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}"
);
}

View file

@ -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<PenNode>) -> 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<f64>) -> 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<PenNode> = 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<PenNode> = 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<PenNode> = 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<PenNode> = 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}"
);
}