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:
Fini 2026-07-05 02:40:10 +08:00
parent 2e8bf40f47
commit c734f98dfb
5 changed files with 359 additions and 1 deletions

View file

@ -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;

View file

@ -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");
}

View file

@ -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;

View file

@ -151,6 +151,12 @@ fn dense_row_kind(node: &PenNode) -> Option<DenseRowKind> {
"tab bar",
"tab-bar",
"tabbar",
"底部导航",
"底部导航栏",
"导航栏",
"底栏",
"标签栏",
"底部标签栏",
],
) {
return Some(DenseRowKind::Nav);

View file

@ -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;