fix(orchestrator): recognize CJK-named bottom nav + dedup duplicate bottom-nav sections
Chinese-prompt mobile designs rendered two bottom navs (e.g. 底部导航栏 + Bottom Navigation). The mobile-chrome bottom-nav matchers were English-only, so a CJK-named nav was not recognized — it wasn't anchored or merged, and a second English nav survived. Add Chinese synonyms (底部导航/底部导航栏/导航栏/底栏/标签栏/底部标签栏) to both matchers, and add a root-level dedup that keeps the bottom-anchored nav and removes redundant bottom-nav sections. Wired into cleanup + loop_finalize (both paths). Narrow guards: mobile artboards, >=2 detected navs, never the sole nav, top navbars untouched.
This commit is contained in:
parent
2e8bf40f47
commit
c734f98dfb
|
|
@ -82,6 +82,75 @@ fn remove_duplicate_status_bars(sink: &mut dyn DocSink, root_id: &str) {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove_duplicate_bottom_nav_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 {
|
||||
remove_duplicate_bottom_nav_sections(sink, &root_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn remove_duplicate_bottom_nav_sections(sink: &mut dyn DocSink, root_id: &str) {
|
||||
let dupes: Vec<NodeId> = {
|
||||
let Some(root) = find_root(sink.state(), root_id) else {
|
||||
return;
|
||||
};
|
||||
if !is_mobile_root(root) {
|
||||
return;
|
||||
}
|
||||
let Some(children) = root.children() else {
|
||||
return;
|
||||
};
|
||||
let nav_indices: Vec<usize> = children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, child)| is_bottom_nav_section(child).then_some(index))
|
||||
.collect();
|
||||
if nav_indices.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let keep_index = nav_indices
|
||||
.iter()
|
||||
.copied()
|
||||
.max_by(|a, b| compare_bottom_nav_position(children, *a, *b))
|
||||
.expect("nav_indices is non-empty");
|
||||
nav_indices
|
||||
.into_iter()
|
||||
.filter(|index| *index != keep_index)
|
||||
.map(|index| NodeId::new(children[index].id_str().to_string()))
|
||||
.collect()
|
||||
};
|
||||
for id in dupes {
|
||||
sink.apply(EditorCommand::DeleteNode {
|
||||
node_id: id,
|
||||
page_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn is_bottom_nav_section(node: &PenNode) -> bool {
|
||||
cleanup_mobile_chrome::bottom_nav_surface_target(node, false).is_some()
|
||||
}
|
||||
|
||||
fn compare_bottom_nav_position(
|
||||
children: &[PenNode],
|
||||
left_index: usize,
|
||||
right_index: usize,
|
||||
) -> std::cmp::Ordering {
|
||||
let left_y = children[left_index].base().y.unwrap_or(left_index as f64);
|
||||
let right_y = children[right_index].base().y.unwrap_or(right_index as f64);
|
||||
left_y
|
||||
.total_cmp(&right_y)
|
||||
.then_with(|| left_index.cmp(&right_index))
|
||||
}
|
||||
|
||||
/// Pass ②:移动端浅色 root 下的 nav surface 纠偏。弱模型常把
|
||||
/// bottom nav / tab bar 套用成黑色安全模板,和当前浅色页面调性断裂。
|
||||
/// TS 端只补"缺失 fill"的 nav;Rust cleanup 还需要兜住已写
|
||||
|
|
@ -818,6 +887,7 @@ pub fn run_cleanup_passes(sink: &mut dyn DocSink, plan: &OrchestratorPlan, root_
|
|||
let rid = rid.as_str();
|
||||
|
||||
remove_duplicate_status_bars(sink, rid);
|
||||
remove_duplicate_bottom_nav_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);
|
||||
|
|
@ -964,6 +1034,10 @@ mod tests_mobile_dense;
|
|||
#[path = "cleanup_mobile_chrome_tests.rs"]
|
||||
mod tests_mobile_chrome;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cleanup_mobile_bottom_nav_dedup_tests.rs"]
|
||||
mod tests_mobile_bottom_nav_dedup;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "cleanup_desktop_dashboard_tests.rs"]
|
||||
mod tests_desktop_dashboard;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
use super::*;
|
||||
use crate::plan::{OrchestratorPlan, RootFrameSpec};
|
||||
use crate::test_support::VecDocSink;
|
||||
|
||||
fn plan() -> OrchestratorPlan {
|
||||
OrchestratorPlan {
|
||||
root_frame: RootFrameSpec {
|
||||
id: "root".into(),
|
||||
name: "Mobile".into(),
|
||||
width: 390.0,
|
||||
height: 844.0,
|
||||
layout: None,
|
||||
gap: None,
|
||||
padding: None,
|
||||
fill: None,
|
||||
},
|
||||
subtasks: vec![],
|
||||
style_guide_name: None,
|
||||
}
|
||||
}
|
||||
|
||||
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) -> Option<&'a PenNode> {
|
||||
sink.state
|
||||
.active_children()
|
||||
.iter()
|
||||
.find_map(|node| find_node(node, id))
|
||||
}
|
||||
|
||||
fn active_root(sink: &VecDocSink) -> &PenNode {
|
||||
find_active_node(sink, "root").expect("root survives")
|
||||
}
|
||||
|
||||
fn direct_child_ids(root: &PenNode) -> Vec<&str> {
|
||||
root.children()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(PenNode::id_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bottom_nav_detected_by_cjk_name() {
|
||||
let mut sink = VecDocSink::new();
|
||||
insert_tree(
|
||||
&mut sink,
|
||||
r##"{
|
||||
"type": "frame",
|
||||
"id": "root",
|
||||
"name": "Chinese Mobile Screen",
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"layout": "vertical",
|
||||
"fill": [{ "type": "solid", "color": "#FFFFFF" }],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "content",
|
||||
"name": "Content",
|
||||
"role": "section",
|
||||
"width": "fill_container",
|
||||
"height": 300,
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cjk-nav-section",
|
||||
"name": "底部导航栏",
|
||||
"role": "section",
|
||||
"x": 24,
|
||||
"width": 342,
|
||||
"height": 88,
|
||||
"layout": "horizontal",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}"##,
|
||||
);
|
||||
|
||||
run_cleanup_passes(&mut sink, &plan(), &["root"]);
|
||||
|
||||
let nav = find_active_node(&sink, "cjk-nav-section").expect("CJK bottom nav survives");
|
||||
assert_eq!(nav.base().x, Some(0.0));
|
||||
assert_eq!(nav.width_px(), Some(390.0));
|
||||
assert_eq!(nav.height_px(), Some(72.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_cjk_and_english_bottom_nav_deduped() {
|
||||
let mut sink = VecDocSink::new();
|
||||
insert_tree(
|
||||
&mut sink,
|
||||
r##"{
|
||||
"type": "frame",
|
||||
"id": "root",
|
||||
"name": "Chinese Mobile Screen",
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"layout": "vertical",
|
||||
"fill": [{ "type": "solid", "color": "#FFFFFF" }],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "content",
|
||||
"name": "Content",
|
||||
"role": "section",
|
||||
"width": "fill_container",
|
||||
"height": 620,
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cjk-nav-section",
|
||||
"name": "底部导航栏",
|
||||
"role": "section",
|
||||
"width": "fill_container",
|
||||
"height": 72,
|
||||
"layout": "horizontal",
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "english-bottom-nav",
|
||||
"name": "Bottom Navigation",
|
||||
"role": "section",
|
||||
"width": "fill_container",
|
||||
"height": 72,
|
||||
"layout": "horizontal",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}"##,
|
||||
);
|
||||
|
||||
run_cleanup_passes(&mut sink, &plan(), &["root"]);
|
||||
|
||||
let ids = direct_child_ids(active_root(&sink));
|
||||
assert!(
|
||||
!ids.contains(&"cjk-nav-section"),
|
||||
"earlier duplicate is removed"
|
||||
);
|
||||
assert!(
|
||||
ids.contains(&"english-bottom-nav"),
|
||||
"bottom-most duplicate is kept"
|
||||
);
|
||||
assert_eq!(
|
||||
ids.iter()
|
||||
.filter(|id| id.contains("bottom-nav"))
|
||||
.copied()
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["english-bottom-nav"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_bottom_nav_not_removed() {
|
||||
let mut sink = VecDocSink::new();
|
||||
insert_tree(
|
||||
&mut sink,
|
||||
r##"{
|
||||
"type": "frame",
|
||||
"id": "root",
|
||||
"name": "Single Nav Mobile Screen",
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"layout": "vertical",
|
||||
"fill": [{ "type": "solid", "color": "#FFFFFF" }],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "content",
|
||||
"name": "Content",
|
||||
"role": "section",
|
||||
"width": "fill_container",
|
||||
"height": 620,
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "cjk-nav-section",
|
||||
"name": "底部导航栏",
|
||||
"role": "section",
|
||||
"width": "fill_container",
|
||||
"height": 72,
|
||||
"layout": "horizontal",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}"##,
|
||||
);
|
||||
|
||||
run_cleanup_passes(&mut sink, &plan(), &["root"]);
|
||||
|
||||
let ids = direct_child_ids(active_root(&sink));
|
||||
assert!(ids.contains(&"cjk-nav-section"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_navbar_not_treated_as_bottom_nav() {
|
||||
let mut sink = VecDocSink::new();
|
||||
insert_tree(
|
||||
&mut sink,
|
||||
r##"{
|
||||
"type": "frame",
|
||||
"id": "root",
|
||||
"name": "Top And Bottom Nav Mobile Screen",
|
||||
"width": 390,
|
||||
"height": 844,
|
||||
"layout": "vertical",
|
||||
"fill": [{ "type": "solid", "color": "#FFFFFF" }],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "top-navbar",
|
||||
"name": "Top Navigation Bar",
|
||||
"role": "top-app-bar",
|
||||
"width": "fill_container",
|
||||
"height": 64,
|
||||
"layout": "horizontal",
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "content",
|
||||
"name": "Content",
|
||||
"role": "section",
|
||||
"width": "fill_container",
|
||||
"height": 556,
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "bottom-nav",
|
||||
"name": "Bottom Navigation",
|
||||
"role": "section",
|
||||
"width": "fill_container",
|
||||
"height": 72,
|
||||
"layout": "horizontal",
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}"##,
|
||||
);
|
||||
|
||||
run_cleanup_passes(&mut sink, &plan(), &["root"]);
|
||||
|
||||
let ids = direct_child_ids(active_root(&sink));
|
||||
assert!(ids.contains(&"top-navbar"), "top navbar is untouched");
|
||||
assert!(ids.contains(&"bottom-nav"), "sole bottom nav is untouched");
|
||||
}
|
||||
|
|
@ -247,7 +247,10 @@ fn collect_bottom_nav_chrome_repairs(
|
|||
}
|
||||
}
|
||||
|
||||
fn bottom_nav_surface_target(root_child: &PenNode, allow_structural: bool) -> Option<&PenNode> {
|
||||
pub(super) fn bottom_nav_surface_target(
|
||||
root_child: &PenNode,
|
||||
allow_structural: bool,
|
||||
) -> Option<&PenNode> {
|
||||
if is_bottom_nav_surface(root_child, allow_structural) {
|
||||
return Some(root_child);
|
||||
}
|
||||
|
|
@ -286,6 +289,12 @@ fn is_bottom_nav_surface(node: &PenNode, allow_structural: bool) -> bool {
|
|||
"bottom-navigation",
|
||||
"bottom tab",
|
||||
"bottom-tab",
|
||||
"底部导航",
|
||||
"底部导航栏",
|
||||
"导航栏",
|
||||
"底栏",
|
||||
"标签栏",
|
||||
"底部标签栏",
|
||||
],
|
||||
) {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -151,6 +151,12 @@ fn dense_row_kind(node: &PenNode) -> Option<DenseRowKind> {
|
|||
"tab bar",
|
||||
"tab-bar",
|
||||
"tabbar",
|
||||
"底部导航",
|
||||
"底部导航栏",
|
||||
"导航栏",
|
||||
"底栏",
|
||||
"标签栏",
|
||||
"底部标签栏",
|
||||
],
|
||||
) {
|
||||
return Some(DenseRowKind::Nav);
|
||||
|
|
|
|||
|
|
@ -446,6 +446,7 @@ pub fn apply_loop_finalize(state: &mut EditorState) {
|
|||
{
|
||||
let mut sink = StateDocSink { state: &mut *state };
|
||||
crate::abandoned_duplicate_roots::remove_abandoned_duplicate_roots(&mut sink);
|
||||
crate::cleanup::remove_duplicate_bottom_nav_sections_for_all_roots(&mut sink);
|
||||
}
|
||||
if state.active_children().is_empty() {
|
||||
return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue