feat(orchestrator): mobile-artboard layout cleanup passes

Geometry-driven post-passes on generated mobile screens: equalize
side-by-side card heights, collapse a bloating fill_container content
section on a fixed-height artboard, pad a clipping horizontal scroll row
so child strokes aren't shaved on any side, expand a collapsed absolute
(layout:none) image wrapper to its image height, and collapse nested
double horizontal padding. Wired into run_cleanup_passes + loop_finalize.
This commit is contained in:
Fini 2026-07-06 00:47:09 +08:00
parent ea27689d0e
commit 8a9919579e
6 changed files with 1305 additions and 3 deletions

View file

@ -11,14 +11,14 @@ use crate::cleanup_typography::repair_overbold_text_hierarchy;
use crate::plan::OrchestratorPlan;
use crate::types::DocSink;
use jian_ops_schema::node::{
container::{ContainerProps, Padding},
container::{ContainerProps, LayoutMode, Padding},
PenNode,
};
use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
use jian_ops_schema::style::PenEffect;
use op_editor_core::{
first_fill_type, first_solid_fill_hex, EditorCommand, EditorState, FillType, LayoutPropValue,
NodeId, PenNodeExt,
fills::node_stroke_width, first_fill_type, first_solid_fill_hex, EditorCommand, EditorState,
FillType, LayoutPropValue, NodeId, PenNodeExt,
};
#[path = "cleanup_desktop_dashboard.rs"]
@ -110,6 +110,126 @@ pub(crate) fn collapse_nested_horizontal_padding_for_all_roots(sink: &mut dyn Do
}
}
pub(crate) fn expand_absolute_container_to_children_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 {
expand_absolute_container_to_children(sink, &root_id);
}
}
pub(crate) fn pad_clipping_horizontal_row_for_stroke_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 {
pad_clipping_horizontal_row_for_stroke(sink, &root_id);
}
}
pub(crate) fn collapse_fill_container_content_sections_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 {
collapse_fill_container_content_sections(sink, &root_id);
}
}
pub(crate) fn equalize_horizontal_card_heights_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 {
equalize_horizontal_card_heights(sink, &root_id);
}
}
fn pad_clipping_horizontal_row_for_stroke(sink: &mut dyn DocSink, root_id: &str) {
let repairs: Vec<ClipRowStrokePaddingRepair> = {
let Some(root) = find_root(sink.state(), root_id) else {
return;
};
let mut repairs = Vec::new();
collect_clip_row_stroke_padding_repairs(root, &mut repairs);
repairs
};
for repair in repairs {
sink.apply(EditorCommand::SetNodeLayoutProp {
node_id: repair.node_id,
property: "padding".to_string(),
value: LayoutPropValue::NumberArray(vec![
repair.padding[0],
repair.padding[1],
repair.padding[2],
repair.padding[3],
]),
});
}
}
#[derive(Debug, Clone)]
struct ClipRowStrokePaddingRepair {
node_id: NodeId,
padding: [f64; 4],
}
fn collect_clip_row_stroke_padding_repairs(
node: &PenNode,
repairs: &mut Vec<ClipRowStrokePaddingRepair>,
) {
if let Some(repair) = clip_row_stroke_padding_repair(node) {
repairs.push(repair);
}
if let Some(children) = node.children() {
for child in children {
collect_clip_row_stroke_padding_repairs(child, repairs);
}
}
}
fn clip_row_stroke_padding_repair(node: &PenNode) -> Option<ClipRowStrokePaddingRepair> {
let props = frame_container_props(node)?;
if props.layout.as_ref() != Some(&LayoutMode::Horizontal) || props.clip_content != Some(true) {
return None;
}
let max_stroke = node
.children()?
.iter()
.filter_map(node_stroke_width)
.max_by(f64::total_cmp)?;
let mut padding = props
.padding
.as_ref()
.map(padding_sides)
.unwrap_or([0.0, 0.0, 0.0, 0.0]);
let stroke_padding = max_stroke.ceil();
if padding.iter().all(|side| *side >= stroke_padding) {
return None;
}
for side in &mut padding {
*side = side.max(stroke_padding);
}
Some(ClipRowStrokePaddingRepair {
node_id: NodeId::new(node.id_str().to_string()),
padding,
})
}
/// Mobile root-level bottom-nav dedupe. Weak-model Chinese prompts can produce
/// both a localized bottom nav section and an English normalized bottom nav.
/// Keep the bottom-most/last top-level nav and remove earlier duplicates.
@ -151,6 +271,211 @@ fn remove_duplicate_bottom_nav_sections(sink: &mut dyn DocSink, root_id: &str) {
}
}
/// Fixed-height vertical mobile artboards must keep direct content sections
/// content-driven; fill-height direct children can consume all leftover space
/// when a sibling subtask emits zero nodes.
fn collapse_fill_container_content_sections(sink: &mut dyn DocSink, root_id: &str) {
let repairs: Vec<NodeId> = {
let Some(root) = find_root(sink.state(), root_id) else {
return;
};
if !is_fixed_height_vertical_root(root) {
return;
}
let Some(children) = root.children() else {
return;
};
children
.iter()
.filter(|child| is_fill_height_content_frame(child))
.map(|child| NodeId::new(child.id_str().to_string()))
.collect()
};
for node_id in repairs {
sink.apply(EditorCommand::SetNodeLayoutProp {
node_id,
property: "height".to_string(),
value: LayoutPropValue::Keyword("fit_content".to_string()),
});
}
}
fn is_fixed_height_vertical_root(root: &PenNode) -> bool {
let Some(props) = frame_container_props(root) else {
return false;
};
props
.layout
.as_ref()
.is_some_and(|layout| layout == &LayoutMode::Vertical)
&& matches!(props.height.as_ref(), Some(SizingBehavior::Number(_)))
}
fn is_fill_height_content_frame(node: &PenNode) -> bool {
let Some(props) = frame_container_props(node) else {
return false;
};
matches!(
props.height.as_ref(),
Some(SizingBehavior::Keyword(SizingKeyword::FillContainer))
) && node
.children()
.map(|children| !children.is_empty())
.unwrap_or(false)
}
fn equalize_horizontal_card_heights(sink: &mut dyn DocSink, root_id: &str) {
let repairs: Vec<CardHeightRepair> = {
let Some(root) = find_root(sink.state(), root_id) else {
return;
};
let mut repairs = Vec::new();
collect_horizontal_card_height_repairs(root, &mut repairs);
repairs
};
for repair in repairs {
sink.apply(EditorCommand::SetNodeLayoutProp {
node_id: repair.card_id,
property: "height".to_string(),
value: LayoutPropValue::Keyword("fill_container".to_string()),
});
}
}
#[derive(Debug, Clone)]
struct CardHeightRepair {
card_id: NodeId,
}
fn collect_horizontal_card_height_repairs(node: &PenNode, repairs: &mut Vec<CardHeightRepair>) {
if let Some(card_repairs) = horizontal_card_height_repairs(node) {
repairs.extend(card_repairs);
}
if let Some(children) = node.children() {
for child in children {
collect_horizontal_card_height_repairs(child, repairs);
}
}
}
fn horizontal_card_height_repairs(node: &PenNode) -> Option<Vec<CardHeightRepair>> {
let props = frame_container_props(node)?;
if props.layout.as_ref() != Some(&LayoutMode::Horizontal) {
return None;
}
let mut cards = Vec::new();
for child in node.children()? {
let Some(child_props) = frame_container_props(child) else {
continue;
};
if child
.children()
.map(|children| children.is_empty())
.unwrap_or(true)
{
continue;
}
if !width_is_fill_container(child_props) {
return None;
}
if matches!(
child_props.height.as_ref(),
Some(SizingBehavior::Keyword(SizingKeyword::FitContent))
) {
cards.push(child);
}
}
(cards.len() >= 2).then(|| {
cards
.into_iter()
.map(|card| CardHeightRepair {
card_id: NodeId::new(card.id_str().to_string()),
})
.collect()
})
}
fn expand_absolute_container_to_children(sink: &mut dyn DocSink, root_id: &str) {
let repairs: Vec<AbsoluteContainerRepair> = {
let Some(root) = find_root(sink.state(), root_id) else {
return;
};
let mut repairs = Vec::new();
collect_absolute_container_repairs(root, &mut repairs);
repairs
};
for repair in repairs {
sink.apply(EditorCommand::UpdateNode {
node_id: repair.node_id,
x: None,
y: None,
width: None,
height: Some(repair.height.ceil() as i32),
name: None,
fill_hex: None,
page_id: None,
});
}
}
#[derive(Debug, Clone)]
struct AbsoluteContainerRepair {
node_id: NodeId,
height: f64,
}
fn collect_absolute_container_repairs(node: &PenNode, repairs: &mut Vec<AbsoluteContainerRepair>) {
if let Some(repair) = absolute_container_repair(node) {
repairs.push(repair);
}
if let Some(children) = node.children() {
for child in children {
collect_absolute_container_repairs(child, repairs);
}
}
}
fn absolute_container_repair(node: &PenNode) -> Option<AbsoluteContainerRepair> {
let props = frame_container_props(node)?;
if props.layout.as_ref() != Some(&LayoutMode::None)
|| !matches!(
props.height.as_ref(),
Some(SizingBehavior::Keyword(SizingKeyword::FitContent))
)
{
return None;
}
let children = node.children()?;
if !children
.iter()
.any(|child| matches!(child, PenNode::Image(_)))
{
return None;
}
let height = children
.iter()
.filter_map(|child| {
if !matches!(child, PenNode::Image(_)) {
return None;
}
child
.height_px()
.map(|height| child.base().y.unwrap_or(0.0) + height)
})
.max_by(f64::total_cmp)?;
(height > 0.0).then(|| AbsoluteContainerRepair {
node_id: NodeId::new(node.id_str().to_string()),
height,
})
}
/// Collapse `section > transparent fill-width wrapper` double horizontal
/// padding while preserving all vertical padding.
fn collapse_nested_horizontal_padding(sink: &mut dyn DocSink, root_id: &str) {
@ -1017,6 +1342,10 @@ pub fn run_cleanup_passes(sink: &mut dyn DocSink, plan: &OrchestratorPlan, root_
remove_duplicate_status_bars(sink, rid);
remove_duplicate_bottom_nav_sections(sink, rid);
collapse_nested_horizontal_padding(sink, rid);
expand_absolute_container_to_children(sink, rid);
pad_clipping_horizontal_row_for_stroke(sink, rid);
equalize_horizontal_card_heights(sink, rid);
collapse_fill_container_content_sections(sink, rid);
repair_light_mobile_nav_surfaces(sink, rid);
repair_mobile_content_sections(sink, rid);
cleanup_mobile_chrome::repair_mobile_structural_chrome(sink, rid);
@ -1171,6 +1500,22 @@ mod tests_mobile_bottom_nav_dedup;
#[path = "cleanup_nested_horizontal_padding_tests.rs"]
mod tests_nested_horizontal_padding;
#[cfg(test)]
#[path = "cleanup_absolute_container_tests.rs"]
mod tests_absolute_container;
#[cfg(test)]
#[path = "cleanup_fill_container_content_tests.rs"]
mod tests_fill_container_content;
#[cfg(test)]
#[path = "cleanup_clip_row_stroke_tests.rs"]
mod tests_clip_row_stroke;
#[cfg(test)]
#[path = "cleanup_card_height_equalize_tests.rs"]
mod tests_card_height_equalize;
#[cfg(test)]
#[path = "cleanup_desktop_dashboard_tests.rs"]
mod tests_desktop_dashboard;

View file

@ -0,0 +1,289 @@
use super::*;
use crate::test_support::VecDocSink;
use serde_json::{json, Value};
fn insert_tree(sink: &mut VecDocSink, json: &str) {
let tree: PenNode = serde_json::from_str(json).expect("test tree json");
sink.state.apply(EditorCommand::InsertAuthoredSubtree {
nodes: vec![tree],
parent_id: NodeId::NONE,
page_id: None,
});
sink.applied.clear();
}
fn find_node<'a>(node: &'a PenNode, id: &str) -> Option<&'a PenNode> {
if node.id_str() == id {
return Some(node);
}
node.children()?
.iter()
.find_map(|child| find_node(child, id))
}
fn find_active_node<'a>(sink: &'a VecDocSink, id: &str) -> &'a PenNode {
sink.state
.active_children()
.iter()
.find_map(|node| find_node(node, id))
.expect("node exists")
}
fn node_json(sink: &VecDocSink, id: &str) -> Value {
serde_json::to_value(find_active_node(sink, id)).expect("serialize node")
}
#[test]
fn absolute_fit_content_wrapper_expands_to_image_height() {
let mut sink = VecDocSink::new();
insert_tree(
&mut sink,
r##"{
"type": "frame",
"id": "root",
"name": "Root",
"width": 390,
"height": 844,
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "image-wrapper",
"name": "Image Wrapper",
"width": 210,
"height": "fit_content",
"layout": "none",
"children": [
{
"type": "image",
"id": "product-image",
"name": "Product Image",
"src": "",
"width": 210,
"height": 240
}
]
}
]
}"##,
);
expand_absolute_container_to_children(&mut sink, "root");
assert_eq!(node_json(&sink, "image-wrapper")["height"], json!(240.0));
}
#[test]
fn absolute_wrapper_with_y_offset_uses_y_plus_height() {
let mut sink = VecDocSink::new();
insert_tree(
&mut sink,
r##"{
"type": "frame",
"id": "root",
"name": "Root",
"width": 390,
"height": 844,
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "image-wrapper",
"name": "Image Wrapper",
"width": 210,
"height": "fit_content",
"layout": "none",
"children": [
{
"type": "image",
"id": "product-image",
"name": "Product Image",
"src": "",
"y": 10,
"width": 210,
"height": 240
}
]
}
]
}"##,
);
expand_absolute_container_to_children(&mut sink, "root");
assert_eq!(node_json(&sink, "image-wrapper")["height"], json!(250.0));
}
#[test]
fn layout_none_container_without_image_child_untouched() {
let mut sink = VecDocSink::new();
insert_tree(
&mut sink,
r##"{
"type": "frame",
"id": "root",
"name": "Root",
"width": 390,
"height": 844,
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "guest-counter",
"name": "Guest Counter",
"width": 112,
"height": "fit_content",
"layout": "none",
"children": [
{
"type": "icon_font",
"id": "minus-icon",
"name": "Minus Icon",
"iconFontName": "minus",
"y": 200,
"width": 20,
"height": 20
},
{
"type": "text",
"id": "guest-count",
"name": "Guest Count",
"content": "2",
"y": 212,
"width": 16,
"height": 20
}
]
}
]
}"##,
);
let before = node_json(&sink, "guest-counter");
expand_absolute_container_to_children(&mut sink, "root");
assert_eq!(node_json(&sink, "guest-counter"), before);
}
#[test]
fn flex_fit_content_untouched() {
let mut sink = VecDocSink::new();
insert_tree(
&mut sink,
r##"{
"type": "frame",
"id": "root",
"name": "Root",
"width": 390,
"height": 844,
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "image-wrapper",
"name": "Image Wrapper",
"width": 210,
"height": "fit_content",
"layout": "vertical",
"children": [
{
"type": "image",
"id": "product-image",
"name": "Product Image",
"src": "",
"width": 210,
"height": 240
}
]
}
]
}"##,
);
let before = node_json(&sink, "image-wrapper");
expand_absolute_container_to_children(&mut sink, "root");
assert_eq!(node_json(&sink, "image-wrapper"), before);
}
#[test]
fn absolute_wrapper_with_numeric_height_untouched() {
let mut sink = VecDocSink::new();
insert_tree(
&mut sink,
r##"{
"type": "frame",
"id": "root",
"name": "Root",
"width": 390,
"height": 844,
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "status-bar",
"name": "Status Bar",
"width": "fill_container",
"height": 62,
"layout": "none",
"children": [
{
"type": "frame",
"id": "status-content",
"name": "Status Content",
"width": 210,
"height": 240,
"children": []
}
]
}
]
}"##,
);
let before = node_json(&sink, "status-bar");
expand_absolute_container_to_children(&mut sink, "root");
assert_eq!(node_json(&sink, "status-bar"), before);
}
#[test]
fn absolute_wrapper_only_fill_children_untouched() {
let mut sink = VecDocSink::new();
insert_tree(
&mut sink,
r##"{
"type": "frame",
"id": "root",
"name": "Root",
"width": 390,
"height": 844,
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "image-wrapper",
"name": "Image Wrapper",
"width": 210,
"height": "fit_content",
"layout": "none",
"children": [
{
"type": "frame",
"id": "fill-child",
"name": "Fill Child",
"width": "fill_container",
"height": "fill_container",
"children": []
}
]
}
]
}"##,
);
let before = node_json(&sink, "image-wrapper");
expand_absolute_container_to_children(&mut sink, "root");
assert_eq!(node_json(&sink, "image-wrapper"), before);
}

View file

@ -0,0 +1,288 @@
use super::*;
use crate::test_support::VecDocSink;
use serde_json::{json, Value};
fn insert_tree(sink: &mut VecDocSink, json: &str) {
let tree: PenNode = serde_json::from_str(json).expect("test tree json");
sink.state.apply(EditorCommand::InsertAuthoredSubtree {
nodes: vec![tree],
parent_id: NodeId::NONE,
page_id: None,
});
sink.applied.clear();
}
fn find_node<'a>(node: &'a PenNode, id: &str) -> Option<&'a PenNode> {
if node.id_str() == id {
return Some(node);
}
node.children()?
.iter()
.find_map(|child| find_node(child, id))
}
fn find_active_node<'a>(sink: &'a VecDocSink, id: &str) -> &'a PenNode {
sink.state
.active_children()
.iter()
.find_map(|node| find_node(node, id))
.expect("node exists")
}
fn node_json(sink: &VecDocSink, id: &str) -> Value {
serde_json::to_value(find_active_node(sink, id)).expect("serialize node")
}
#[test]
fn two_fill_width_fit_height_cards_in_row_become_fill_height() {
let mut sink = VecDocSink::new();
insert_tree(
&mut sink,
r##"{
"type": "frame",
"id": "root",
"name": "Page",
"width": 390,
"height": "fit_content",
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "deals-row",
"name": "Deals of the Week",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"children": [
{
"type": "frame",
"id": "card-a",
"name": "Deal Card A",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"children": [
{"type": "text", "id": "card-a-title", "content": "Weekend Bento"}
]
},
{
"type": "frame",
"id": "card-b",
"name": "Deal Card B",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"children": [
{"type": "text", "id": "card-b-title", "content": "Family feast with extra dessert"}
]
}
]
}
]
}"##,
);
equalize_horizontal_card_heights(&mut sink, "root");
assert_eq!(
node_json(&sink, "card-a")["height"],
json!("fill_container")
);
assert_eq!(
node_json(&sink, "card-b")["height"],
json!("fill_container")
);
}
#[test]
fn single_card_row_untouched() {
let mut sink = VecDocSink::new();
insert_tree(
&mut sink,
r##"{
"type": "frame",
"id": "root",
"name": "Page",
"width": 390,
"height": "fit_content",
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "deals-row",
"name": "Deals of the Week",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"children": [
{
"type": "frame",
"id": "card-a",
"name": "Deal Card A",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"children": [
{"type": "text", "id": "card-a-title", "content": "Weekend Bento"}
]
}
]
}
]
}"##,
);
let before = node_json(&sink, "root");
equalize_horizontal_card_heights(&mut sink, "root");
assert_eq!(node_json(&sink, "root"), before);
}
#[test]
fn mixed_width_row_untouched() {
let mut sink = VecDocSink::new();
insert_tree(
&mut sink,
r##"{
"type": "frame",
"id": "root",
"name": "Page",
"width": 390,
"height": "fit_content",
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "deals-row",
"name": "Deals of the Week",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"children": [
{
"type": "frame",
"id": "card-a",
"name": "Deal Card A",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"children": [
{"type": "text", "id": "card-a-title", "content": "Weekend Bento"}
]
},
{
"type": "frame",
"id": "card-b",
"name": "Deal Card B",
"width": 180,
"height": "fit_content",
"layout": "vertical",
"children": [
{"type": "text", "id": "card-b-title", "content": "Family feast"}
]
}
]
}
]
}"##,
);
let before = node_json(&sink, "root");
equalize_horizontal_card_heights(&mut sink, "root");
assert_eq!(node_json(&sink, "root"), before);
}
#[test]
fn vertical_container_untouched() {
let mut sink = VecDocSink::new();
insert_tree(
&mut sink,
r##"{
"type": "frame",
"id": "root",
"name": "Page",
"width": 390,
"height": "fit_content",
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "card-a",
"name": "Deal Card A",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"children": [
{"type": "text", "id": "card-a-title", "content": "Weekend Bento"}
]
},
{
"type": "frame",
"id": "card-b",
"name": "Deal Card B",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"children": [
{"type": "text", "id": "card-b-title", "content": "Family feast"}
]
}
]
}"##,
);
let before = node_json(&sink, "root");
equalize_horizontal_card_heights(&mut sink, "root");
assert_eq!(node_json(&sink, "root"), before);
}
#[test]
fn empty_frame_siblings_untouched() {
let mut sink = VecDocSink::new();
insert_tree(
&mut sink,
r##"{
"type": "frame",
"id": "root",
"name": "Page",
"width": 390,
"height": "fit_content",
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "deals-row",
"name": "Deals of the Week",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"children": [
{
"type": "frame",
"id": "spacer-a",
"name": "Spacer A",
"width": "fill_container",
"height": "fit_content",
"children": []
},
{
"type": "frame",
"id": "spacer-b",
"name": "Spacer B",
"width": "fill_container",
"height": "fit_content",
"children": []
}
]
}
]
}"##,
);
let before = node_json(&sink, "root");
equalize_horizontal_card_heights(&mut sink, "root");
assert_eq!(node_json(&sink, "root"), before);
}

View file

@ -0,0 +1,171 @@
use super::*;
use crate::test_support::VecDocSink;
use serde_json::{json, Value};
fn insert_tree(sink: &mut VecDocSink, json: &str) {
let tree: PenNode = serde_json::from_str(json).expect("test tree json");
sink.state.apply(EditorCommand::InsertAuthoredSubtree {
nodes: vec![tree],
parent_id: NodeId::NONE,
page_id: None,
});
sink.applied.clear();
}
fn find_node<'a>(node: &'a PenNode, id: &str) -> Option<&'a PenNode> {
if node.id_str() == id {
return Some(node);
}
node.children()?
.iter()
.find_map(|child| find_node(child, id))
}
fn find_active_node<'a>(sink: &'a VecDocSink, id: &str) -> &'a PenNode {
sink.state
.active_children()
.iter()
.find_map(|node| find_node(node, id))
.expect("node exists")
}
fn node_json(sink: &VecDocSink, id: &str) -> Value {
serde_json::to_value(find_active_node(sink, id)).expect("serialize node")
}
fn insert_date_scroller(
sink: &mut VecDocSink,
row_layout: &str,
clip_content: bool,
row_padding: &str,
child_stroke: &str,
) {
insert_tree(
sink,
&format!(
r##"{{
"type": "frame",
"id": "root",
"name": "Mobile Root",
"width": 390,
"height": 844,
"layout": "vertical",
"children": [
{{
"type": "frame",
"id": "section",
"name": "Upcoming",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"children": [
{{
"type": "frame",
"id": "date-row",
"name": "Date Scroller",
"width": "fill_container",
"height": "fit_content",
"layout": "{row_layout}",
"clipContent": {clip_content},
"padding": {row_padding},
"children": [
{{
"type": "frame",
"id": "date-chip",
"name": "Tue 14",
"width": 48,
"height": 60,
"layout": "vertical",
{child_stroke}
"children": []
}}
]
}}
]
}}
]
}}"##
),
);
}
#[test]
fn clipping_horizontal_row_with_stroked_chip_gets_stroke_padding_on_all_sides() {
let mut sink = VecDocSink::new();
insert_date_scroller(
&mut sink,
"horizontal",
true,
"[0, 0, 0, 0]",
r##""stroke": {"thickness": 1, "fill": [{"type": "solid", "color": "#E5E7EB"}]},"##,
);
pad_clipping_horizontal_row_for_stroke(&mut sink, "root");
assert_eq!(
node_json(&sink, "date-row")["padding"],
json!([1.0, 1.0, 1.0, 1.0])
);
}
#[test]
fn non_clipping_row_untouched() {
let mut sink = VecDocSink::new();
insert_date_scroller(
&mut sink,
"horizontal",
false,
"[0, 12]",
r##""stroke": {"thickness": 1, "fill": [{"type": "solid", "color": "#E5E7EB"}]},"##,
);
let before = node_json(&sink, "date-row");
pad_clipping_horizontal_row_for_stroke(&mut sink, "root");
assert_eq!(node_json(&sink, "date-row"), before);
}
#[test]
fn row_without_stroked_children_untouched() {
let mut sink = VecDocSink::new();
insert_date_scroller(&mut sink, "horizontal", true, "[0, 12]", "");
let before = node_json(&sink, "date-row");
pad_clipping_horizontal_row_for_stroke(&mut sink, "root");
assert_eq!(node_json(&sink, "date-row"), before);
}
#[test]
fn row_with_sufficient_vertical_padding_untouched() {
let mut sink = VecDocSink::new();
insert_date_scroller(
&mut sink,
"horizontal",
true,
"[2, 12, 2, 12]",
r##""stroke": {"thickness": 1, "fill": [{"type": "solid", "color": "#E5E7EB"}]},"##,
);
let before = node_json(&sink, "date-row");
pad_clipping_horizontal_row_for_stroke(&mut sink, "root");
assert_eq!(node_json(&sink, "date-row"), before);
}
#[test]
fn vertical_container_untouched() {
let mut sink = VecDocSink::new();
insert_date_scroller(
&mut sink,
"vertical",
true,
"[0, 12]",
r##""stroke": {"thickness": 1, "fill": [{"type": "solid", "color": "#E5E7EB"}]},"##,
);
let before = node_json(&sink, "date-row");
pad_clipping_horizontal_row_for_stroke(&mut sink, "root");
assert_eq!(node_json(&sink, "date-row"), before);
}

View file

@ -0,0 +1,205 @@
use super::*;
use crate::plan::{OrchestratorPlan, RootFrameSpec};
use crate::test_support::VecDocSink;
use serde_json::{json, Value};
fn plan() -> OrchestratorPlan {
OrchestratorPlan {
root_frame: RootFrameSpec {
id: "root".into(),
name: "Mobile Food App".into(),
width: 375.0,
height: 812.0,
layout: None,
gap: None,
padding: None,
fill: None,
},
subtasks: vec![],
style_guide_name: None,
}
}
fn insert_root(value: Value) -> VecDocSink {
let mut sink = VecDocSink::new();
let root: PenNode = serde_json::from_value(value).expect("root json");
sink.state.apply(EditorCommand::InsertAuthoredSubtree {
nodes: vec![root],
parent_id: NodeId::NONE,
page_id: None,
});
sink.applied.clear();
sink
}
fn find_node<'a>(node: &'a PenNode, id: &str) -> Option<&'a PenNode> {
if node.id_str() == id {
return Some(node);
}
node.children()?
.iter()
.find_map(|child| find_node(child, id))
}
fn active_node_value(sink: &VecDocSink, id: &str) -> Value {
let node = sink
.state
.active_children()
.iter()
.find_map(|node| find_node(node, id))
.expect("node exists");
serde_json::to_value(node).expect("serialize node")
}
#[test]
fn fill_container_content_section_on_fixed_vertical_root_collapses() {
let mut sink = insert_root(json!({
"type": "frame",
"id": "root",
"name": "Food App",
"width": 375,
"height": 812,
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "promo",
"name": "Featured Promo Banner",
"width": "fill_container",
"height": "fill_container",
"layout": "vertical",
"children": [
{"type": "text", "id": "promo-title", "content": "Half-price ramen"}
]
},
{
"type": "frame",
"id": "categories",
"name": "Food Categories",
"width": "fill_container",
"height": "fit_content",
"layout": "horizontal",
"children": [
{"type": "text", "id": "cat-title", "content": "Sushi"}
]
},
{
"type": "frame",
"id": "restaurants",
"name": "Popular Restaurants",
"width": "fill_container",
"height": "fit_content",
"layout": "vertical",
"children": []
}
]
}));
run_cleanup_passes(&mut sink, &plan(), &["root"]);
assert_eq!(
active_node_value(&sink, "promo")["height"],
json!("fit_content")
);
assert_eq!(
active_node_value(&sink, "categories")["height"],
json!("fit_content")
);
assert_eq!(
active_node_value(&sink, "restaurants")["height"],
json!("fit_content")
);
}
#[test]
fn empty_spacer_fill_container_not_collapsed() {
let mut sink = insert_root(json!({
"type": "frame",
"id": "root",
"name": "Food App",
"width": 375,
"height": 812,
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "spacer",
"name": "Flexible Spacer",
"width": "fill_container",
"height": "fill_container",
"children": []
}
]
}));
run_cleanup_passes(&mut sink, &plan(), &["root"]);
assert_eq!(
active_node_value(&sink, "spacer")["height"],
json!("fill_container")
);
}
#[test]
fn fill_container_on_horizontal_root_child_untouched() {
let mut sink = insert_root(json!({
"type": "frame",
"id": "root",
"name": "Web App Shell",
"width": 1200,
"height": 800,
"layout": "horizontal",
"children": [
{
"type": "frame",
"id": "sidebar",
"name": "Sidebar",
"width": 260,
"height": "fill_container",
"layout": "vertical",
"children": [
{"type": "text", "id": "nav", "content": "Home"}
]
}
]
}));
run_cleanup_passes(&mut sink, &plan(), &["root"]);
assert_eq!(
active_node_value(&sink, "sidebar")["height"],
json!("fill_container")
);
}
#[test]
fn fit_content_root_untouched() {
let mut sink = insert_root(json!({
"type": "frame",
"id": "root",
"name": "Scrolling Food App",
"width": 375,
"height": "fit_content",
"layout": "vertical",
"children": [
{
"type": "frame",
"id": "promo",
"name": "Featured Promo Banner",
"width": "fill_container",
"height": "fill_container",
"layout": "vertical",
"children": [
{"type": "text", "id": "promo-title", "content": "Daily deal"}
]
}
]
}));
run_cleanup_passes(&mut sink, &plan(), &["root"]);
assert_eq!(
active_node_value(&sink, "promo")["height"],
json!("fill_container")
);
}

View file

@ -448,6 +448,10 @@ pub fn apply_loop_finalize(state: &mut EditorState) {
crate::abandoned_duplicate_roots::remove_abandoned_duplicate_roots(&mut sink);
crate::cleanup::remove_duplicate_bottom_nav_sections_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);
crate::cleanup::pad_clipping_horizontal_row_for_stroke_for_all_roots(&mut sink);
crate::cleanup::equalize_horizontal_card_heights_for_all_roots(&mut sink);
crate::cleanup::collapse_fill_container_content_sections_for_all_roots(&mut sink);
}
if state.active_children().is_empty() {
return;