From 37d0898bd4ec2ac1c51294e7bf64e001ba94b527 Mon Sep 17 00:00:00 2001 From: Fini Date: Fri, 24 Jul 2026 00:10:16 +0800 Subject: [PATCH] fix(agent): enforce mobile content rails --- crates/op-orchestrator/src/cleanup.rs | 6 + .../src/cleanup_mobile_chrome.rs | 129 ++++++++ ...cleanup_mobile_chrome_nav_wrapper_tests.rs | 267 +++++++++++++++ crates/op-orchestrator/src/lib.rs | 3 + crates/op-orchestrator/src/loop_finalize.rs | 1 + .../src/mobile_content_rail.rs | 306 ++++++++++++++++++ .../src/mobile_content_rail_tests.rs | 221 +++++++++++++ 7 files changed, 933 insertions(+) create mode 100644 crates/op-orchestrator/src/mobile_content_rail.rs create mode 100644 crates/op-orchestrator/src/mobile_content_rail_tests.rs diff --git a/crates/op-orchestrator/src/cleanup.rs b/crates/op-orchestrator/src/cleanup.rs index a53a9a94e..11cdd11d1 100644 --- a/crates/op-orchestrator/src/cleanup.rs +++ b/crates/op-orchestrator/src/cleanup.rs @@ -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); diff --git a/crates/op-orchestrator/src/cleanup_mobile_chrome.rs b/crates/op-orchestrator/src/cleanup_mobile_chrome.rs index 39ba2579f..5e810d363 100644 --- a/crates/op-orchestrator/src/cleanup_mobile_chrome.rs +++ b/crates/op-orchestrator/src/cleanup_mobile_chrome.rs @@ -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 { + 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 { + 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, diff --git a/crates/op-orchestrator/src/cleanup_mobile_chrome_nav_wrapper_tests.rs b/crates/op-orchestrator/src/cleanup_mobile_chrome_nav_wrapper_tests.rs index 86d3ab157..d4161877f 100644 --- a/crates/op-orchestrator/src/cleanup_mobile_chrome_nav_wrapper_tests.rs +++ b/crates/op-orchestrator/src/cleanup_mobile_chrome_nav_wrapper_tests.rs @@ -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!["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!["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!["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", diff --git a/crates/op-orchestrator/src/lib.rs b/crates/op-orchestrator/src/lib.rs index a19b92be5..f6dfa12c6 100644 --- a/crates/op-orchestrator/src/lib.rs +++ b/crates/op-orchestrator/src/lib.rs @@ -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; diff --git a/crates/op-orchestrator/src/loop_finalize.rs b/crates/op-orchestrator/src/loop_finalize.rs index 72a7f1db4..5f1941cf3 100644 --- a/crates/op-orchestrator/src/loop_finalize.rs +++ b/crates/op-orchestrator/src/loop_finalize.rs @@ -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); diff --git a/crates/op-orchestrator/src/mobile_content_rail.rs b/crates/op-orchestrator/src/mobile_content_rail.rs new file mode 100644 index 000000000..b84955aa9 --- /dev/null +++ b/crates/op-orchestrator/src/mobile_content_rail.rs @@ -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 = 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, +} + +fn collect_repairs(root: &PenNode) -> Vec { + 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 = 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 { + let (top, bottom) = vertical_padding(node); + vec![top, rail, bottom, rail] +} + +fn padding_with_leading_rail(node: &PenNode, rail: f64) -> Vec { + let (top, bottom) = vertical_padding(node); + let right = horizontal_padding(node).map_or(0.0, |(_, right)| right); + vec![top, right, bottom, rail] +} diff --git a/crates/op-orchestrator/src/mobile_content_rail_tests.rs b/crates/op-orchestrator/src/mobile_content_rail_tests.rs new file mode 100644 index 000000000..f210dbcbb --- /dev/null +++ b/crates/op-orchestrator/src/mobile_content_rail_tests.rs @@ -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!["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" + ); +}