fix(agent): enforce mobile content rails

This commit is contained in:
Fini 2026-07-24 00:10:16 +08:00 committed by Kayshen-X
parent 78a80e74f6
commit 37d0898bd4
7 changed files with 933 additions and 0 deletions

View file

@ -1404,6 +1404,12 @@ pub fn run_cleanup_passes(sink: &mut dyn DocSink, plan: &OrchestratorPlan, root_
// can grow the root around the stale numeric wrapper and erase the
// evidence that the wrapper used to consume the entire viewport.
cleanup_mobile_chrome::anchor_bottom_nav_last(sink, rid);
crate::mobile_content_rail::repair_mobile_content_rails(sink, rid);
// The newly-established section rail may expose a redundant transparent
// inner wrapper carrying the same horizontal inset. Re-run the
// existing ownership collapse after rail repair so only one layer owns
// the gutter.
collapse_nested_horizontal_padding(sink, rid);
crate::mobile_reflow::repair_mobile_trailing_nav_reflow_for_root_in_sink(sink, rid);
cleanup_mobile_dense::repair_dense_mobile_rows(sink, rid);
cleanup_desktop_dashboard::repair_sparse_desktop_dashboard_rows(sink, plan, rid);

View file

@ -70,6 +70,12 @@ pub(crate) fn repair_mobile_structural_chrome_for_all_roots(sink: &mut dyn DocSi
}
pub(crate) fn repair_mobile_structural_chrome(sink: &mut dyn DocSink, root_id: &str) {
// Some model turns incorrectly tag a large late-page content shell as the
// bottom nav, then append the real tab row inside it after unrelated
// alerts/cards. Split only that unambiguous mixed shell first so the normal
// chrome pass below sees the real nav as a root-level surface.
promote_mixed_bottom_nav_shell(sink, root_id);
let repairs = {
let Some(root) = super::find_root(sink.state(), root_id) else {
return;
@ -134,6 +140,129 @@ pub(crate) fn repair_mobile_structural_chrome(sink: &mut dyn DocSink, root_id: &
}
}
#[derive(Debug)]
struct MixedBottomNavPromotion {
shell_id: NodeId,
nav_id: NodeId,
}
fn promote_mixed_bottom_nav_shell(sink: &mut dyn DocSink, root_id: &str) {
let promotion = {
let Some(root) = super::find_root(sink.state(), root_id) else {
return;
};
if !super::is_mobile_root(root) {
return;
}
mixed_bottom_nav_promotion(root)
};
let Some(promotion) = promotion else {
return;
};
if !sink.apply(EditorCommand::PatchNodeData {
node_id: promotion.shell_id,
// The bottom-nav name is semantic input to later cleanup passes. Once
// the real tab row is promoted, keep this mixed shell eligible for
// ordinary content-rail repair under a deliberately neutral,
// transparent structural wrapper. Business cards inside retain their
// own authored surfaces.
patch_json: r#"{"role":null,"name":"App Content","fill":null,"stroke":null,"effects":null,"cornerRadius":0}"#.to_string(),
page_id: None,
}) {
return;
}
sink.apply(EditorCommand::MoveNode {
node_id: promotion.nav_id,
target_parent: NodeId::new(root_id.to_string()),
page_id: None,
index: None,
});
}
fn mixed_bottom_nav_promotion(root: &PenNode) -> Option<MixedBottomNavPromotion> {
let children = root.children()?;
let last_index = children.len().saturating_sub(1);
let candidates: Vec<(usize, MixedBottomNavPromotion)> = children
.iter()
.enumerate()
.filter_map(|(index, child)| {
mixed_bottom_nav_shell_candidate(child, index == last_index)
.map(|promotion| (index, promotion))
})
.collect();
let [(shell_index, promotion)] = candidates.as_slice() else {
return None;
};
// A second root-level nav (or another wrapper containing one) makes it
// unclear which surface is canonical. Leave that tree intact for the
// existing duplicate-nav logic instead of reparenting speculatively.
if children.iter().enumerate().any(|(index, child)| {
index != *shell_index && bottom_nav_surface_target(child, index == last_index).is_some()
}) {
return None;
}
Some(MixedBottomNavPromotion {
shell_id: promotion.shell_id.clone(),
nav_id: promotion.nav_id.clone(),
})
}
fn mixed_bottom_nav_shell_candidate(
shell: &PenNode,
allow_structural: bool,
) -> Option<MixedBottomNavPromotion> {
if !is_bottom_nav_surface(shell, allow_structural) {
return None;
}
let children = shell.children()?;
let nested_navs: Vec<&PenNode> = children
.iter()
.filter(|child| is_bottom_nav_surface(child, true))
.collect();
let [nav] = nested_navs.as_slice() else {
return None;
};
if !children.iter().any(|child| {
child.id_str() != nav.id_str() && is_substantial_non_nav_business_sibling(child)
}) {
return None;
}
Some(MixedBottomNavPromotion {
shell_id: NodeId::new(shell.id_str().to_string()),
nav_id: NodeId::new(nav.id_str().to_string()),
})
}
fn is_substantial_non_nav_business_sibling(node: &PenNode) -> bool {
if is_bottom_nav_surface(node, true) || node.height_px().is_some_and(|height| height <= 8.0) {
return false;
}
let hay = super::node_identity_haystack(node);
if super::contains_any(
&hay,
&["divider", "separator", "hairline", "rule", "分隔", "分割线"],
) {
return false;
}
contains_meaningful_business_content(node)
}
fn contains_meaningful_business_content(node: &PenNode) -> bool {
match node {
PenNode::Text(text) => match &text.content {
jian_ops_schema::node::text::TextContent::Plain(content) => !content.trim().is_empty(),
jian_ops_schema::node::text::TextContent::Styled(segments) => !segments.is_empty(),
},
PenNode::Image(_) => true,
_ => node
.children()
.map(|children| children.iter().any(contains_meaningful_business_content))
.unwrap_or(false),
}
}
#[derive(Default)]
struct MobileChromeRepairs {
structural_shells: Vec<NodeId>,

View file

@ -106,6 +106,12 @@ fn bottom_nav_wrapper_with_divider_keeps_tabbar_full_width() {
.expect("root survives");
let outer = find_node(root, "bottom-nav").expect("outer nav survives");
let inner = find_node(root, "tabbar").expect("inner tabbar survives");
assert!(
root.children().expect("root children").iter().any(|child| {
child.id_str() == "bottom-nav" && find_node(child, "tabbar").is_some()
}),
"a divider-only wrapper must retain its nested tabbar"
);
let outer_json = serde_json::to_value(outer).expect("outer serializes");
let inner_json = serde_json::to_value(inner).expect("inner serializes");
assert_eq!(
@ -121,6 +127,267 @@ fn bottom_nav_wrapper_with_divider_keeps_tabbar_full_width() {
assert_eq!(inner_json["layout"], json!("horizontal"));
}
#[test]
fn mixed_business_wrapper_promotes_real_tabbar_to_mobile_root() {
let mut sink = VecDocSink::new();
let tree: PenNode = serde_json::from_value(json!({
"type": "frame",
"id": "root",
"name": "Weather App",
"width": 375,
"height": 1285,
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "content",
"name": "Forecast Content",
"width": "fill_container",
"height": 800,
"children": []
},
{
"type": "frame",
"id": "mixed-shell",
"name": "Bottom Navigation Bar",
"role": "bottom-tab-bar",
"width": "fill_container",
"height": 458,
"layout": "vertical",
"cornerRadius": 24,
"fill": [{"type": "solid", "color": "#111111"}],
"stroke": {
"thickness": 1,
"fill": [{"type": "solid", "color": "#334155"}]
},
"effects": [{
"type": "shadow",
"offsetX": 0,
"offsetY": 8,
"blur": 20,
"spread": 0,
"color": "#00000033"
}],
"children": [
{
"type": "frame",
"id": "alert",
"name": "Weather Alert Banner",
"width": "fill_container",
"height": 136,
"children": [
{
"type": "text",
"id": "alert-copy",
"content": "Flash Flood & High Wind Warning",
"width": "fill_container",
"height": 24
}
]
},
{
"type": "frame",
"id": "metrics",
"name": "Metrics Grid",
"width": "fill_container",
"height": 226,
"children": [
{
"type": "text",
"id": "metrics-copy",
"content": "Humidity 88%",
"width": "fit_content",
"height": 24
}
]
},
{
"type": "frame",
"id": "tabbar",
"name": "Bottom Tab Bar",
"role": "bottom-tab-bar",
"width": "fill_container",
"height": 72,
"layout": "horizontal",
"children": [
tab("now", "Now", "cloud-sun"),
tab("radar", "Radar", "radar"),
tab("locations", "Locations", "map-pin"),
tab("settings", "Settings", "settings")
]
}
]
}
]
}))
.expect("mixed nav wrapper json");
sink.state.apply(EditorCommand::InsertAuthoredSubtree {
nodes: vec![tree],
parent_id: NodeId::NONE,
page_id: None,
});
sink.applied.clear();
crate::cleanup::repair_mobile_structural_chrome_for_all_roots(&mut sink);
let root = sink
.state
.active_children()
.iter()
.find(|node| node.id_str() == "root")
.expect("root survives");
let root_children = root.children().expect("root children");
assert_eq!(
root_children
.iter()
.map(PenNodeExt::id_str)
.collect::<Vec<_>>(),
vec!["content", "mixed-shell", "tabbar"],
"the real tabbar must become the mobile root's last child"
);
let shell = &root_children[1];
assert_eq!(
shell.base().role.as_deref(),
None,
"the mixed content shell must stop claiming bottom-tab-bar semantics"
);
assert_eq!(
shell.base().name.as_deref(),
Some("App Content"),
"the promoted shell must no longer be excluded from content cleanup by a nav name"
);
let shell_json = serde_json::to_value(shell).expect("shell serializes");
assert!(
shell_json
.get("fill")
.is_none_or(serde_json::Value::is_null)
&& shell_json
.get("stroke")
.is_none_or(serde_json::Value::is_null)
&& shell_json
.get("effects")
.is_none_or(serde_json::Value::is_null)
&& shell_json["cornerRadius"] == json!(0.0),
"the demoted content shell must be transparent: {shell_json}"
);
assert_eq!(
shell
.children()
.expect("shell children")
.iter()
.map(PenNodeExt::id_str)
.collect::<Vec<_>>(),
vec!["alert", "metrics"],
"business sections remain grouped while only the true nav row is promoted"
);
let nav = &root_children[2];
assert_eq!(nav.height_px(), Some(72.0));
assert_eq!(
serde_json::to_value(nav).expect("nav serializes")["width"],
json!(375.0),
"normal nav normalization runs after promotion"
);
}
#[test]
fn ambiguous_mixed_wrapper_with_two_nav_rows_is_not_split() {
let mut sink = VecDocSink::new();
let tree: PenNode = serde_json::from_value(json!({
"type": "frame",
"id": "root",
"name": "Ambiguous App",
"width": 375,
"height": 812,
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "mixed-shell",
"name": "Bottom Navigation Bar",
"role": "bottom-tab-bar",
"width": "fill_container",
"height": 300,
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "business",
"name": "Summary",
"width": "fill_container",
"height": 120,
"children": [
{
"type": "text",
"id": "summary-copy",
"content": "Summary",
"width": "fit_content",
"height": 24
}
]
},
{
"type": "frame",
"id": "tabbar-a",
"name": "Bottom Tab Bar A",
"role": "bottom-tab-bar",
"width": "fill_container",
"height": 72,
"layout": "horizontal",
"children": [
tab("a-home", "Home", "home"),
tab("a-search", "Search", "search"),
tab("a-profile", "Profile", "user")
]
},
{
"type": "frame",
"id": "tabbar-b",
"name": "Bottom Tab Bar B",
"role": "bottom-tab-bar",
"width": "fill_container",
"height": 72,
"layout": "horizontal",
"children": [
tab("b-home", "Home", "home"),
tab("b-search", "Search", "search"),
tab("b-profile", "Profile", "user")
]
}
]
}
]
}))
.expect("ambiguous nav wrapper json");
sink.state.apply(EditorCommand::InsertAuthoredSubtree {
nodes: vec![tree],
parent_id: NodeId::NONE,
page_id: None,
});
sink.applied.clear();
crate::cleanup::repair_mobile_structural_chrome_for_all_roots(&mut sink);
let root = sink
.state
.active_children()
.iter()
.find(|node| node.id_str() == "root")
.expect("root survives");
let root_children = root.children().expect("root children");
assert_eq!(
root_children
.iter()
.map(PenNodeExt::id_str)
.collect::<Vec<_>>(),
vec!["mixed-shell"],
"two plausible nav rows are ambiguous and must not be reparented"
);
let shell = &root_children[0];
assert_eq!(shell.base().role.as_deref(), Some("bottom-tab-bar"));
assert!(find_node(shell, "tabbar-a").is_some());
assert!(find_node(shell, "tabbar-b").is_some());
}
fn tab(id: &str, label: &str, icon: &str) -> serde_json::Value {
json!({
"type": "frame",

View file

@ -19,6 +19,7 @@ pub mod design_md_policy;
pub mod design_system;
pub mod design_type;
pub mod intent;
pub(crate) mod mobile_content_rail;
mod mobile_reflow;
pub mod model_profile;
pub(crate) mod orchestration_self_check;
@ -93,6 +94,8 @@ mod geometry_chip_tests;
#[cfg(test)]
mod geometry_root_containment_tests;
#[cfg(test)]
mod mobile_content_rail_tests;
#[cfg(test)]
mod prompt_resolved_style_tests;
#[cfg(test)]
mod radial_preinsert_tests;

View file

@ -453,6 +453,7 @@ pub fn apply_loop_finalize(state: &mut EditorState) {
crate::cleanup::repair_mobile_structural_chrome_for_all_roots(&mut sink);
crate::avatar_repair::repair_avatar_slots_for_all_roots(&mut sink);
crate::cleanup::anchor_bottom_nav_last_for_all_roots(&mut sink);
crate::mobile_content_rail::repair_mobile_content_rails_for_all_roots(&mut sink);
crate::cleanup::distribute_bottom_nav_tabs_for_all_roots(&mut sink);
crate::cleanup::collapse_nested_horizontal_padding_for_all_roots(&mut sink);
crate::cleanup::expand_absolute_container_to_children_for_all_roots(&mut sink);

View file

@ -0,0 +1,306 @@
//! Deterministic mobile content-rail ownership.
//!
//! Generated mobile screens commonly mix full-width chrome with individually
//! padded content sections. A root-level gutter cannot represent that shape:
//! it would inset status/navigation chrome and destroy intentional horizontal
//! scrollers. This pass therefore repairs only transparent root-direct content
//! sections, and gives clipped horizontal scrollers a leading rail while
//! keeping their trailing edge flush.
use crate::types::DocSink;
use jian_ops_schema::node::{
container::{ContainerProps, LayoutMode},
Padding, PenNode,
};
use jian_ops_schema::sizing::SizingBehavior;
use op_editor_core::{EditorCommand, LayoutPropValue, NodeId, PenNodeExt};
use std::collections::BTreeMap;
const DEFAULT_MOBILE_RAIL: f64 = 24.0;
const MIN_MOBILE_WIDTH: f64 = 320.0;
const MAX_MOBILE_WIDTH: f64 = 480.0;
pub(crate) fn repair_mobile_content_rails_for_all_roots(sink: &mut dyn DocSink) {
let root_ids: Vec<String> = sink
.state()
.active_children()
.iter()
.map(|node| node.id_str().to_string())
.collect();
for root_id in root_ids {
repair_mobile_content_rails(sink, &root_id);
}
}
pub(crate) fn repair_mobile_content_rails(sink: &mut dyn DocSink, root_id: &str) {
let repairs = {
let Some(root) = sink
.state()
.active_children()
.iter()
.find(|node| node.id_str() == root_id)
else {
return;
};
collect_repairs(root)
};
for repair in repairs {
sink.apply(EditorCommand::SetNodeLayoutProp {
node_id: NodeId::new(repair.node_id),
property: "padding".to_string(),
value: LayoutPropValue::NumberArray(repair.padding),
});
}
}
#[derive(Debug, PartialEq)]
struct RailRepair {
node_id: String,
padding: Vec<f64>,
}
fn collect_repairs(root: &PenNode) -> Vec<RailRepair> {
if !looks_like_mobile_screen(root)
|| has_expression_padding(root)
|| horizontal_padding(root).is_some_and(nonzero_pair)
{
return Vec::new();
}
let Some(sections) = root.children() else {
return Vec::new();
};
let rail = infer_content_rail(sections);
let mut repairs = Vec::new();
for section in sections {
if !is_repairable_content_section(section)
|| has_expression_padding(section)
|| horizontal_padding(section).is_some_and(nonzero_pair)
{
continue;
}
let scrollers: Vec<&PenNode> = section
.children()
.into_iter()
.flatten()
.filter(|child| is_clipped_horizontal_scroller(child))
.collect();
if scrollers.is_empty() {
repairs.push(RailRepair {
node_id: section.id_str().to_string(),
padding: padding_with_horizontal_rail(section, rail),
});
continue;
}
// A horizontal rail needs a flush trailing edge so the last card can
// scroll offscreen. Keep the section full-width, inset its short
// header siblings, and add only a leading inset to each viewport.
for child in section.children().into_iter().flatten() {
if is_clipped_horizontal_scroller(child) {
if !has_expression_padding(child)
&& horizontal_padding(child).is_none_or(|pair| !nonzero_pair(pair))
{
repairs.push(RailRepair {
node_id: child.id_str().to_string(),
padding: padding_with_leading_rail(child, rail),
});
}
} else if is_scroller_header(child)
&& !has_expression_padding(child)
&& horizontal_padding(child).is_none_or(|pair| !nonzero_pair(pair))
{
repairs.push(RailRepair {
node_id: child.id_str().to_string(),
padding: padding_with_horizontal_rail(child, rail),
});
}
}
}
repairs
}
fn looks_like_mobile_screen(root: &PenNode) -> bool {
let Some(props) = container_props(root) else {
return false;
};
let Some(SizingBehavior::Number(width)) = props.width else {
return false;
};
if !(MIN_MOBILE_WIDTH..=MAX_MOBILE_WIDTH).contains(&width)
|| props.layout != Some(LayoutMode::Vertical)
{
return false;
}
let Some(children) = root.children() else {
return false;
};
let tall_or_screen_structured = match props.height {
Some(SizingBehavior::Number(height)) => height >= 568.0,
_ => children.len() >= 4 || children.iter().any(is_mobile_chrome),
};
tall_or_screen_structured && children.len() >= 2
}
fn infer_content_rail(sections: &[PenNode]) -> f64 {
let mut counts: BTreeMap<i64, usize> = BTreeMap::new();
for section in sections {
if is_mobile_chrome(section) {
continue;
}
let Some((left, right)) = horizontal_padding(section) else {
continue;
};
if (left - right).abs() > 0.5 || !(16.0..=28.0).contains(&left) {
continue;
}
*counts.entry(left.round() as i64).or_default() += 1;
}
counts
.into_iter()
.max_by_key(|(rail, count)| (*count, *rail))
.map(|(rail, _)| rail as f64)
.unwrap_or(DEFAULT_MOBILE_RAIL)
}
fn is_repairable_content_section(node: &PenNode) -> bool {
node.is_container()
&& !is_mobile_chrome(node)
&& is_transparent_surface(node)
&& has_text_or_icon_descendant(node)
}
fn is_mobile_chrome(node: &PenNode) -> bool {
let role = node
.base()
.role
.as_deref()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
if matches!(
role.as_str(),
"status-bar" | "bottom-tab-bar" | "bottom-nav" | "tab-bar" | "tabbar"
) {
return true;
}
let name = node
.base()
.name
.as_deref()
.unwrap_or("")
.to_ascii_lowercase();
name.contains("status bar")
|| name.contains("bottom navigation")
|| name.contains("bottom nav")
|| name.contains("bottom tab")
}
fn is_transparent_surface(node: &PenNode) -> bool {
let Ok(value) = serde_json::to_value(node) else {
return false;
};
let has_fill = value
.get("fill")
.and_then(|fill| fill.as_array())
.is_some_and(|fill| !fill.is_empty());
let has_stroke = value.get("stroke").is_some_and(|stroke| !stroke.is_null());
let has_effects = value
.get("effects")
.and_then(|effects| effects.as_array())
.is_some_and(|effects| !effects.is_empty());
let has_radius = value
.get("cornerRadius")
.and_then(|radius| radius.as_f64())
.is_some_and(|radius| radius > 0.0);
!has_fill && !has_stroke && !has_effects && !has_radius
}
fn has_text_or_icon_descendant(node: &PenNode) -> bool {
matches!(node, PenNode::Text(_) | PenNode::IconFont(_))
|| node
.children()
.is_some_and(|children| children.iter().any(has_text_or_icon_descendant))
}
fn is_clipped_horizontal_scroller(node: &PenNode) -> bool {
container_props(node).is_some_and(|props| {
props.layout == Some(LayoutMode::Horizontal) && props.clip_content == Some(true)
})
}
fn is_scroller_header(node: &PenNode) -> bool {
if !node.is_container() || !is_transparent_surface(node) || !has_text_or_icon_descendant(node) {
return false;
}
let name = node
.base()
.name
.as_deref()
.unwrap_or("")
.to_ascii_lowercase();
if name.contains("header") || name.contains("title") {
return true;
}
let child_count = node.children().map_or(0, |children| children.len());
child_count <= 3
&& container_props(node).is_some_and(|props| {
props.layout == Some(LayoutMode::Horizontal)
&& props.height.as_ref().is_none_or(|height| match height {
SizingBehavior::Number(height) => *height <= 80.0,
_ => true,
})
})
}
fn container_props(node: &PenNode) -> Option<&ContainerProps> {
match node {
PenNode::Frame(node) => Some(&node.container),
PenNode::Group(node) => Some(&node.container),
PenNode::Rectangle(node) => Some(&node.container),
_ => None,
}
}
fn horizontal_padding(node: &PenNode) -> Option<(f64, f64)> {
match container_props(node)?.padding.as_ref()? {
Padding::Uniform(value) => Some((*value, *value)),
Padding::XY([_, horizontal]) => Some((*horizontal, *horizontal)),
Padding::LtrB([_, right, _, left]) => Some((*left, *right)),
Padding::Expression(_) => None,
}
}
fn has_expression_padding(node: &PenNode) -> bool {
matches!(
container_props(node).and_then(|props| props.padding.as_ref()),
Some(Padding::Expression(_))
)
}
fn vertical_padding(node: &PenNode) -> (f64, f64) {
match container_props(node).and_then(|props| props.padding.as_ref()) {
Some(Padding::Uniform(value)) => (*value, *value),
Some(Padding::XY([vertical, _])) => (*vertical, *vertical),
Some(Padding::LtrB([top, _, bottom, _])) => (*top, *bottom),
Some(Padding::Expression(_)) | None => (0.0, 0.0),
}
}
fn nonzero_pair((left, right): (f64, f64)) -> bool {
left > 0.0 || right > 0.0
}
fn padding_with_horizontal_rail(node: &PenNode, rail: f64) -> Vec<f64> {
let (top, bottom) = vertical_padding(node);
vec![top, rail, bottom, rail]
}
fn padding_with_leading_rail(node: &PenNode, rail: f64) -> Vec<f64> {
let (top, bottom) = vertical_padding(node);
let right = horizontal_padding(node).map_or(0.0, |(_, right)| right);
vec![top, right, bottom, rail]
}

View file

@ -0,0 +1,221 @@
use crate::mobile_content_rail::repair_mobile_content_rails_for_all_roots;
use crate::test_support::VecDocSink;
use crate::types::DocSink;
use jian_ops_schema::node::PenNode;
use op_editor_core::{EditorCommand, NodeId, PenNodeExt};
use serde_json::{json, Value};
fn insert(value: Value) -> VecDocSink {
let tree: PenNode = serde_json::from_value(value).expect("fixture");
let mut sink = VecDocSink::new();
sink.apply(EditorCommand::InsertAuthoredSubtree {
nodes: vec![tree],
parent_id: NodeId::NONE,
page_id: None,
});
sink.applied.clear();
sink
}
fn find<'a>(node: &'a PenNode, id: &str) -> Option<&'a PenNode> {
if node.id_str() == id {
return Some(node);
}
node.children()?.iter().find_map(|child| find(child, id))
}
fn node_json(sink: &VecDocSink, id: &str) -> Value {
let node = sink
.state
.active_children()
.iter()
.find_map(|root| find(root, id))
.expect("node exists");
serde_json::to_value(node).expect("node json")
}
fn text(id: &str, content: &str) -> Value {
json!({"type":"text","id":id,"content":content,"width":"fit_content","height":"fit_content"})
}
#[test]
fn mixed_mobile_sections_converge_on_existing_content_rail() {
let mut sink = insert(json!({
"type":"frame","id":"root","width":375,"height":1285,"layout":"vertical",
"children":[
{"type":"frame","id":"status","role":"status-bar","width":"fill_container","height":44},
{"type":"frame","id":"hero","width":"fill_container","layout":"vertical",
"children":[{"type":"frame","id":"hero-card","cornerRadius":24,
"fill":[{"type":"solid","color":"#222222"}],"children":[text("hero-t","68°")]}]},
{"type":"frame","id":"week","width":"fill_container","layout":"vertical","padding":[0,24],
"children":[text("week-t","7-Day")]},
{"type":"frame","id":"details","width":"fill_container","layout":"horizontal",
"children":[{"type":"frame","id":"aqi","cornerRadius":16,
"fill":[{"type":"solid","color":"#181818"}],"children":[text("aqi-t","AQI")]}]},
{"type":"frame","id":"nav","role":"bottom-tab-bar","width":"fill_container","height":72}
]
}));
repair_mobile_content_rails_for_all_roots(&mut sink);
assert_eq!(
node_json(&sink, "hero")["padding"],
json!([0.0, 24.0, 0.0, 24.0])
);
assert_eq!(
node_json(&sink, "details")["padding"],
json!([0.0, 24.0, 0.0, 24.0])
);
assert_eq!(node_json(&sink, "week")["padding"], json!([0.0, 24.0]));
assert!(node_json(&sink, "status").get("padding").is_none());
assert!(node_json(&sink, "nav").get("padding").is_none());
}
#[test]
fn clipped_scroller_repairs_header_and_leading_edge_only() {
let mut sink = insert(json!({
"type":"frame","id":"root","width":390,"height":844,"layout":"vertical",
"children":[
{"type":"frame","id":"status","role":"status-bar","height":44},
{"type":"frame","id":"rail-section","width":"fill_container","layout":"vertical",
"children":[
{"type":"frame","id":"header","name":"Section Header","layout":"horizontal",
"children":[text("title","Popular")]},
{"type":"frame","id":"viewport","layout":"horizontal","clipContent":true,
"children":[{"type":"frame","id":"cards","layout":"horizontal",
"children":[{"type":"frame","id":"card","cornerRadius":16,
"children":[text("card-t","Kyoto")]}]}]}
]},
{"type":"frame","id":"body","padding":[0,20],"children":[text("body-t","Body")]},
{"type":"frame","id":"nav","role":"bottom-tab-bar","height":72}
]
}));
repair_mobile_content_rails_for_all_roots(&mut sink);
assert!(node_json(&sink, "rail-section").get("padding").is_none());
assert_eq!(
node_json(&sink, "header")["padding"],
json!([0.0, 20.0, 0.0, 20.0])
);
assert_eq!(
node_json(&sink, "viewport")["padding"],
json!([0.0, 0.0, 0.0, 20.0])
);
}
#[test]
fn full_bleed_surface_existing_root_rail_and_desktop_are_untouched() {
let mut sink = insert(json!({
"type":"frame","id":"mobile","width":375,"height":812,"layout":"vertical","padding":[0,16],
"children":[
{"type":"frame","id":"filled","width":"fill_container","cornerRadius":20,
"fill":[{"type":"solid","color":"#111111"}],"children":[text("filled-t","Hero")]},
{"type":"frame","id":"content","children":[text("content-t","Content")]}
]
}));
repair_mobile_content_rails_for_all_roots(&mut sink);
assert!(
sink.applied.is_empty(),
"root-owned rail must not be stacked"
);
let mut desktop = insert(json!({
"type":"frame","id":"desktop","width":1440,"height":900,"layout":"vertical",
"children":[
{"type":"frame","id":"a","children":[text("a-t","A")]},
{"type":"frame","id":"b","children":[text("b-t","B")]}
]
}));
repair_mobile_content_rails_for_all_roots(&mut desktop);
assert!(desktop.applied.is_empty(), "desktop roots are out of scope");
let mut token_rail = insert(json!({
"type":"frame","id":"token-root","width":375,"height":812,"layout":"vertical",
"padding":"$spacing-page",
"children":[
{"type":"frame","id":"token-a","children":[text("token-a-t","A")]},
{"type":"frame","id":"token-b","children":[text("token-b-t","B")]}
]
}));
repair_mobile_content_rails_for_all_roots(&mut token_rail);
assert!(
token_rail.applied.is_empty(),
"variable-owned root padding must never be overwritten"
);
}
#[test]
fn repair_is_idempotent_and_preserves_asymmetric_authored_padding() {
let mut sink = insert(json!({
"type":"frame","id":"root","width":375,"height":812,"layout":"vertical",
"children":[
{"type":"frame","id":"status","role":"status-bar","height":44},
{"type":"frame","id":"plain","children":[text("plain-t","Plain")]},
{"type":"frame","id":"authored","padding":[4,8,6,24],"children":[text("auth-t","Authored")]},
{"type":"frame","id":"nav","role":"bottom-tab-bar","height":72}
]
}));
repair_mobile_content_rails_for_all_roots(&mut sink);
assert_eq!(
node_json(&sink, "plain")["padding"],
json!([0.0, 24.0, 0.0, 24.0])
);
assert_eq!(
node_json(&sink, "authored")["padding"],
json!([4.0, 8.0, 6.0, 24.0])
);
sink.applied.clear();
repair_mobile_content_rails_for_all_roots(&mut sink);
assert!(
sink.applied.is_empty(),
"a second repair pass must be a no-op"
);
}
#[test]
fn mixed_bottom_nav_shell_is_demoted_before_content_rail_repair() {
let mut sink = insert(json!({
"type":"frame","id":"root","width":375,"height":1285,"layout":"vertical",
"children":[
{"type":"frame","id":"status","role":"status-bar","height":44},
{"type":"frame","id":"forecast","padding":[0,24],"children":[text("forecast-t","Forecast")]},
{"type":"frame","id":"mixed","name":"Bottom Navigation Bar","role":"bottom-tab-bar",
"layout":"vertical","fill":[{"type":"solid","color":"#151515"}],"cornerRadius":20,
"children":[
{"type":"frame","id":"alert","children":[text("alert-t","Flood warning")]},
{"type":"frame","id":"metrics","children":[text("metrics-t","Humidity 88%")]},
{"type":"frame","id":"real-nav","name":"Bottom Tab Bar","role":"bottom-tab-bar",
"layout":"horizontal","height":72,"children":[]}
]}
]
}));
crate::cleanup::repair_mobile_structural_chrome_for_all_roots(&mut sink);
repair_mobile_content_rails_for_all_roots(&mut sink);
let root = sink
.state
.active_children()
.iter()
.find(|node| node.id_str() == "root")
.expect("root");
assert_eq!(
root.children()
.expect("root children")
.iter()
.map(PenNodeExt::id_str)
.collect::<Vec<_>>(),
vec!["status", "forecast", "mixed", "real-nav"]
);
let mixed = node_json(&sink, "mixed");
assert_eq!(mixed["name"], "App Content");
assert!(mixed.get("role").is_none());
assert!(mixed.get("fill").is_none());
assert_eq!(mixed["padding"], json!([0.0, 24.0, 0.0, 24.0]));
assert!(
node_json(&sink, "real-nav").get("padding").is_some(),
"real nav keeps its own normalized internal chrome padding"
);
}