fix(agent): enforce mobile content rails
This commit is contained in:
parent
78a80e74f6
commit
37d0898bd4
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
306
crates/op-orchestrator/src/mobile_content_rail.rs
Normal file
306
crates/op-orchestrator/src/mobile_content_rail.rs
Normal 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]
|
||||
}
|
||||
221
crates/op-orchestrator/src/mobile_content_rail_tests.rs
Normal file
221
crates/op-orchestrator/src/mobile_content_rail_tests.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue