fix(agent): centre a deck board's content instead of stacking it at the top
A slide root is a fixed 1080 tall while its sections hug their own height, so content piled up from the top edge and left the lower half of every board blank — visible on every deck generated so far. `is_deck` now travels from `NormInfo` through `CleanupPolicy`, so the judgement is the design type the planner already decided, not a guess from the board's dimensions: a 1920x1080 design that is not a deck is a perfectly ordinary thing to make. Only `justifyContent` is written. The alternative — stretching sections to `fill_container` — would distort whatever composition the model produced; centring moves where the block sits without changing what it is. A board that states its own distribution (`space_between` and friends) is a composition rather than the default top-stack, and is left untouched. Both cases are covered by tests that build a board and run real cleanup, including one asserting the pinned 1080 height survives the centring — the two repairs touch the same node and must not undo each other. Verified end to end: six boards, all 1920x1080, all centred. Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
This commit is contained in:
parent
7807c06aec
commit
0ca9aa6bca
|
|
@ -257,6 +257,10 @@ fn find_root<'a>(state: &'a EditorState, root_id: &str) -> Option<&'a PenNode> {
|
|||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub(crate) struct CleanupPolicy {
|
||||
pub(crate) preserve_requested_root_height: bool,
|
||||
/// Deck boards are fixed 16:9 surfaces. Their content is centred rather
|
||||
/// than left to stack from the top edge, which on a 1080-tall board reads
|
||||
/// as a half-empty slide.
|
||||
pub(crate) is_deck: bool,
|
||||
}
|
||||
|
||||
/// 阶段 4 清理 pass —— 在全部 subtask 插入完成后运行。
|
||||
|
|
@ -369,6 +373,43 @@ pub fn run_cleanup_passes_with_summary(
|
|||
);
|
||||
}
|
||||
|
||||
/// Centre a deck board's content on its fixed 16:9 surface.
|
||||
///
|
||||
/// A slide root is 1080 tall no matter how much content it holds, and its
|
||||
/// sections hug their own height, so without this they stack from the top and
|
||||
/// leave the lower half blank — measured on every generated deck.
|
||||
///
|
||||
/// Only `justifyContent` is written. Stretching the sections to fill instead
|
||||
/// would distort whatever the model composed; centring changes where the block
|
||||
/// sits, not what it is.
|
||||
fn centre_deck_board_content(sink: &mut dyn DocSink, root_id: &str) {
|
||||
let Some(root) = sink
|
||||
.state()
|
||||
.active_children()
|
||||
.iter()
|
||||
.find(|node| node.id_str() == root_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let value = serde_json::to_value(root).unwrap_or(serde_json::Value::Null);
|
||||
// Respect an explicit distribution: a board that deliberately pushes
|
||||
// content apart (space_between) or pins it low is a composition, not the
|
||||
// default top-stack this repairs.
|
||||
if value
|
||||
.get("justifyContent")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|mode| !mode.is_empty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
let node_id = NodeId::new(root.id_str());
|
||||
sink.apply(EditorCommand::PatchNodeData {
|
||||
node_id,
|
||||
patch_json: r#"{"justifyContent":"center"}"#.to_string(),
|
||||
page_id: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// Write the repaired root gap as a property patch.
|
||||
///
|
||||
/// Deliberately NOT an `apply_root_transform`: that rebuilds the subtree and
|
||||
|
|
@ -402,6 +443,18 @@ fn patch_root_section_gap(sink: &mut dyn DocSink, root_id: &str) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Test-only alias so policy-dependent passes can be driven directly.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn run_cleanup_passes_with_summary_and_policy_for_tests(
|
||||
sink: &mut dyn DocSink,
|
||||
plan: &OrchestratorPlan,
|
||||
root_ids: &[&str],
|
||||
summary: &mut RepairSummary,
|
||||
policy: CleanupPolicy,
|
||||
) {
|
||||
run_cleanup_passes_with_summary_and_policy(sink, plan, root_ids, summary, policy);
|
||||
}
|
||||
|
||||
fn run_cleanup_passes_with_summary_and_policy(
|
||||
sink: &mut dyn DocSink,
|
||||
plan: &OrchestratorPlan,
|
||||
|
|
@ -507,6 +560,9 @@ fn run_cleanup_passes_with_summary_and_policy(
|
|||
// reach it in the same state.
|
||||
patch_root_section_gap(sink, &rid);
|
||||
debug_probe_child_height(sink, &rid, "root_gap");
|
||||
if policy.is_deck {
|
||||
centre_deck_board_content(sink, &rid);
|
||||
}
|
||||
// Text that resolves to ~1:1 against its own background is not
|
||||
// styled, it is missing. The lint crate has detected this since
|
||||
// 2026-05, but the generation path called exactly one of its
|
||||
|
|
|
|||
|
|
@ -492,6 +492,7 @@ fn cleanup_policy_preserves_only_requested_fixed_root_height() {
|
|||
&[&preserved_root_id],
|
||||
&mut summary,
|
||||
CleanupPolicy {
|
||||
is_deck: false,
|
||||
preserve_requested_root_height: true,
|
||||
},
|
||||
);
|
||||
|
|
@ -560,3 +561,118 @@ fn cleanup_recolors_safe_dark_bottom_nav_on_light_mobile_root() {
|
|||
"cleanup should replace safe-dark bottom nav fill with the light mobile root surface"
|
||||
);
|
||||
}
|
||||
|
||||
/// A deck board centres its content instead of stacking it from the top.
|
||||
///
|
||||
/// The board is a fixed 1080 tall while its sections hug their own height, so
|
||||
/// the default leaves the lower half of every slide blank.
|
||||
#[test]
|
||||
fn a_deck_board_centres_its_content() {
|
||||
use op_editor_core::{EditorCommand, NodeId};
|
||||
|
||||
let mut sink = VecDocSink::new();
|
||||
let tree: jian_ops_schema::node::PenNode = serde_json::from_value(serde_json::json!({
|
||||
"type": "frame",
|
||||
"id": "board",
|
||||
"name": "Cover",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"layout": "vertical",
|
||||
"children": [
|
||||
{"type": "frame", "id": "s1", "name": "Title", "width": "fill_container", "height": "fit_content"},
|
||||
{"type": "frame", "id": "s2", "name": "Body", "width": "fill_container", "height": "fit_content"},
|
||||
{"type": "frame", "id": "s3", "name": "Meta", "width": "fill_container", "height": "fit_content"}
|
||||
]
|
||||
}))
|
||||
.expect("board");
|
||||
sink.state.apply(EditorCommand::InsertSubtree {
|
||||
nodes: vec![tree],
|
||||
parent_id: NodeId::NONE,
|
||||
page_id: None,
|
||||
});
|
||||
let root_id = sink.state.active_children()[0].id_str().to_string();
|
||||
|
||||
let mut summary = crate::repair_summary::RepairSummary::default();
|
||||
crate::cleanup::run_cleanup_passes_with_summary_and_policy_for_tests(
|
||||
&mut sink,
|
||||
&deck_plan(&root_id),
|
||||
&[&root_id],
|
||||
&mut summary,
|
||||
CleanupPolicy {
|
||||
is_deck: true,
|
||||
preserve_requested_root_height: true,
|
||||
},
|
||||
);
|
||||
|
||||
let root = serde_json::to_value(&sink.state.active_children()[0]).expect("serialize");
|
||||
assert_eq!(
|
||||
root["justifyContent"].as_str(),
|
||||
Some("center"),
|
||||
"a deck board must centre its content"
|
||||
);
|
||||
assert_eq!(
|
||||
root["height"].as_f64(),
|
||||
Some(1080.0),
|
||||
"centring must not disturb the pinned board height"
|
||||
);
|
||||
}
|
||||
|
||||
/// An authored distribution is a composition, not the default top-stack.
|
||||
#[test]
|
||||
fn a_deck_board_with_an_explicit_distribution_is_left_alone() {
|
||||
use op_editor_core::{EditorCommand, NodeId};
|
||||
|
||||
let mut sink = VecDocSink::new();
|
||||
let tree: jian_ops_schema::node::PenNode = serde_json::from_value(serde_json::json!({
|
||||
"type": "frame",
|
||||
"id": "board",
|
||||
"name": "Cover",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"layout": "vertical",
|
||||
"justifyContent": "space_between",
|
||||
"children": [
|
||||
{"type": "frame", "id": "s1", "name": "Title", "width": "fill_container", "height": "fit_content"},
|
||||
{"type": "frame", "id": "s2", "name": "Meta", "width": "fill_container", "height": "fit_content"}
|
||||
]
|
||||
}))
|
||||
.expect("board");
|
||||
sink.state.apply(EditorCommand::InsertSubtree {
|
||||
nodes: vec![tree],
|
||||
parent_id: NodeId::NONE,
|
||||
page_id: None,
|
||||
});
|
||||
let root_id = sink.state.active_children()[0].id_str().to_string();
|
||||
|
||||
let mut summary = crate::repair_summary::RepairSummary::default();
|
||||
crate::cleanup::run_cleanup_passes_with_summary_and_policy_for_tests(
|
||||
&mut sink,
|
||||
&deck_plan(&root_id),
|
||||
&[&root_id],
|
||||
&mut summary,
|
||||
CleanupPolicy {
|
||||
is_deck: true,
|
||||
preserve_requested_root_height: true,
|
||||
},
|
||||
);
|
||||
|
||||
let root = serde_json::to_value(&sink.state.active_children()[0]).expect("serialize");
|
||||
assert_eq!(root["justifyContent"].as_str(), Some("space_between"));
|
||||
}
|
||||
|
||||
fn deck_plan(root_id: &str) -> crate::plan::OrchestratorPlan {
|
||||
crate::plan::OrchestratorPlan {
|
||||
root_frame: crate::plan::RootFrameSpec {
|
||||
id: root_id.to_string(),
|
||||
name: "Deck".into(),
|
||||
width: 1920.0,
|
||||
height: 1080.0,
|
||||
layout: Some("vertical".into()),
|
||||
gap: None,
|
||||
padding: None,
|
||||
fill: None,
|
||||
},
|
||||
subtasks: Vec::new(),
|
||||
style_guide_name: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ pub struct NormInfo {
|
|||
/// The request explicitly fixed both root dimensions, so fresh-root
|
||||
/// cleanup must preserve the requested height instead of growing it.
|
||||
pub preserve_requested_root_height: bool,
|
||||
/// A presentation deck. Its boards are fixed 16:9 surfaces, so cleanup
|
||||
/// centres their content instead of letting it pile up at the top edge.
|
||||
pub is_deck: bool,
|
||||
}
|
||||
|
||||
/// 移动端宽度上限(含)—— ≤ 此值视为移动端单屏。
|
||||
|
|
@ -263,6 +266,7 @@ pub fn normalize(plan: &mut OrchestratorPlan, req: &DesignRequest) -> NormInfo {
|
|||
NormInfo {
|
||||
is_mobile,
|
||||
preserve_requested_root_height,
|
||||
is_deck,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -629,6 +629,7 @@ impl Orchestrator {
|
|||
&mut quality,
|
||||
CleanupPolicy {
|
||||
preserve_requested_root_height: norm.preserve_requested_root_height,
|
||||
is_deck: norm.is_deck,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue