feat(agent): honor root dimensions requested in the prompt

A prompt that names its canvas size ("1200x800", "390 宽") had no path
to the root frame: planning always applied the desktop/mobile defaults,
and cleanup was free to grow the root past whatever was asked for.

Parse the request once (`request_dimensions`), apply it during plan
normalization, state it in the compact prompt so the model builds to the
same number, and carry a `preserve_requested_root_height` flag into
cleanup through an explicit `CleanupPolicy`. The policy defaults to the
historical behavior — only the fresh-root orchestrator path opts in — so
append and modify runs are untouched.

Cleanup needs the RESOLVED height to honor that flag without collapsing
real content, so `geometry_validation` grows `resolved_node_height`,
measuring the laid-out subtree against the node's own top edge rather
than trusting the declared value.

Planning corpus follows: the desktop sizes are labelled "Desktop
default", so an explicit request reads as an override rather than a
contradiction.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
This commit is contained in:
Fini 2026-07-31 21:05:55 +08:00
parent 5a0eb98a2b
commit 72fd294514
16 changed files with 828 additions and 29 deletions

View file

@ -25,7 +25,7 @@ If Type 0:
OTHERWISE classify by purpose:
1. Multi-section page — marketing, promotional, or informational content designed to be scrolled (e.g. product sites, portfolios, company pages):
- Desktop: width=1200, height=0 (scrollable), 6-10 subtasks
- Desktop default: width=1200, height=0 (scrollable), 6-10 subtasks
- Structure: navigation - hero - content sections - CTA - footer
2. Single-task SCREEN — full functional screen for one user task (e.g. login screen, signup screen, settings page, profile page):
@ -34,7 +34,7 @@ OTHERWISE classify by purpose:
- NOT a single card/badge/modal — those are Type 0 components
3. Data-rich workspace — overview screens with metrics, tables, or management panels (e.g. dashboards, admin consoles, analytics):
- Desktop: width=1200, height=0, 2-5 subtasks
- Desktop default: width=1200, height=0, 2-5 subtasks
- Structure: sidebar or topbar + content panels
- Sidebar subtasks: a sidebar is a VERTICAL rail (brand block, stacked nav items, footer profile) — NEVER a horizontal navbar, NEVER a hero headline or marketing copy.
@ -60,14 +60,14 @@ RULES:
- CJK FONT RULE: If the user's request is in Chinese/Japanese/Korean or the product targets CJK audiences, the styleGuide fonts MUST use CJK-compatible fonts: heading="Noto Sans SC" (Chinese) / "Noto Sans JP" (Japanese) / "Noto Sans KR" (Korean), body="Inter". NEVER use "Space Grotesk" or "Manrope" as heading font for CJK content — they have no CJK character support.
- Root frame fill must use the background color from the selected style guide. Each guide in the list shows its bg color (e.g. bg:#0A0F1C). Use that exact hex value for the rootFrame fill color.
- Root frame gap: Landing pages with distinct section backgrounds - gap=0 (sections flush). Mobile screens and dashboards - gap=16-24 (breathing room between sections). Always include "gap" in rootFrame.
- Root frame height: Mobile (width=375) - set height=812 (fixed viewport). Desktop (width=1200) - set height=0 (auto-expands as sections are generated).
- Root frame height: Mobile default (width=375) - set height=812 (fixed viewport). Desktop default (width=1200) - set height=0 (auto-expands as sections are generated). Preserve an explicit user-requested root height.
- Landing page height hints: nav 64-80px, hero 500-600px, feature sections 400-600px, testimonials 300-400px, CTA 200-300px, footer 200-300px.
- App screen height hints: status bar is pre-inserted (62px, do NOT plan a "Status Bar" section). Header 56-64px, form fields 48-56px each, buttons 48px, spacing 16-24px.
- If a section is about "App截图"/"XX截图"/"screenshot"/"mockup", plan it as a phone mockup placeholder block, not a detailed mini-app reconstruction.
- For landing pages: navigation sections should preserve good horizontal balance, links evenly distributed in the center group.
- Regions tile to fill rootFrame. vertical = top-to-bottom.
- Mobile: 375x812 (both width AND height are fixed). Desktop: 1200x0 (width fixed, height auto-expands).
- WIDTH SELECTION: Type 0 components - width=400, height=0. Type 2 single-task SCREENS (login screen, profile page, settings page) - width=375, height=812 (mobile). Multi-section pages and data-rich workspaces (types 1 & 3) - width=1200, height=0 (desktop). A "profile card" is Type 0 (width=400), NOT Type 2. This is mandatory.
- Mobile default: 375x812 (both width AND height are fixed). Desktop default: 1200x0 (width fixed, height auto-expands).
- WIDTH SELECTION: Type 0 components default to width=400, height=0. Type 2 single-task SCREENS (login screen, profile page, settings page) default to width=375, height=812 (mobile). Multi-section pages and data-rich workspaces (types 1 & 3) default to width=1200, height=0 (desktop). A "profile card" is Type 0 (width=400), NOT Type 2. An explicit user-requested root width or width×height pair overrides every default and must be copied exactly into rootFrame.
- MULTI-SCREEN APPS: When the request involves multiple distinct screens/pages (e.g. "登录页+个人中心", "login and profile", "continue generating the remaining 3 pages"), add "screen":"<name>" to EVERY subtask. Each DISTINCT "screen" value becomes its OWN top-level root frame, placed as a separate sibling screen on the canvas; subtasks sharing the same "screen" value land together in that one root. Tagging is mandatory to get separate screens — if you omit "screen" (or give every subtask the SAME value), all subtasks collapse into a single shared root frame, even when the request clearly asked for multiple distinct pages. This applies starting at just 2-3 distinct screens — do NOT wait for a larger count before tagging; a separate skill's guidance about when to hand a screen off to a sub-agent (parallel delegation) is a DIFFERENT decision with its own higher threshold and has no bearing on whether you tag "screen" here. Use a concise page name per screen (e.g. "登录", "Profile") — it becomes that root frame's name. Single-screen requests don't need "screen" at all. Example (2 screens, "Login" then "Profile"): [{"id":"brand","label":"Brand Area","screen":"Login","region":{...}},{"id":"form","label":"Login Form","screen":"Login","region":{...}},{"id":"card","label":"User Card","screen":"Profile","region":{...}}]
- SHARED CHROME ACROSS SCREENS: only plan a FULL bottom-nav/sidebar subtask for the FIRST screen. For every screen after that, either omit the nav subtask entirely or plan it as a minimal placeholder (no need to invent its own icon/label set) — a deterministic pass copies the first screen's nav onto every screen whose name matches one of that nav's tabs (even a screen with NO nav content at all, e.g. its own nav subtask failed) and fixes up which tab is active, so re-planning a full nav per screen wastes subtasks on content that gets replaced anyway. A screen whose name matches none of the nav's tabs (a standalone detail view, say) is correctly left alone — give it its own nav subtask only if it genuinely needs different chrome.
- PUSH-IN DETAIL SCREENS NEVER GET A TAB BAR: a screen reached by tapping a card/row on another screen (a destination/product/article detail, not one of the bottom nav's own top-level destinations) never gets a "Bottom Navigation Bar" subtask, even a placeholder one. Give it a header with a Back control instead — returning relies on that Back tap (`pop`), not a tab switch. Only plan a nav subtask for a screen that genuinely IS one of the shared nav's tabs.

View file

@ -34,7 +34,7 @@ If Type 0:
OTHERWISE classify by purpose:
1. Multi-section page — marketing, promotional, or informational content designed to be scrolled (e.g. product sites, portfolios, company pages):
- Desktop: width=1200, height=0 (scrollable), 6-10 subtasks
- Desktop default: width=1200, height=0 (scrollable), 6-10 subtasks
- Structure: navigation - hero - content sections - CTA - footer
2. Single-task screen — full functional SCREEN focused on one user task (e.g. login screen, signup screen, settings page, profile page, full onboarding flow):
@ -43,15 +43,15 @@ OTHERWISE classify by purpose:
- NOT a single card/badge/modal — those are Type 0 components
3. Data-rich workspace — overview screens with metrics, tables, or management panels (e.g. dashboards, admin consoles, analytics):
- Desktop: width=1200, height=0, 2-5 subtasks
- Desktop default: width=1200, height=0, 2-5 subtasks
- Structure: sidebar or topbar + content panels
WIDTH SELECTION RULES:
- Type 0 components — width=400, height=0
- Type 2 single-task SCREEN (login screen, profile page) — width=375, height=812
- Types 1 & 3 (multi-section / dashboard) — width=1200, height=0
- This mapping is mandatory.
- Types 1 & 3 (multi-section / dashboard) — default width=1200, height=0
- An explicit user-requested root width or width×height pair overrides these defaults and must be used exactly.
MOBILE vs MOCKUP:

View file

@ -249,6 +249,16 @@ fn find_root<'a>(state: &'a EditorState, root_id: &str) -> Option<&'a PenNode> {
op_editor_core::walkers::find_node(state.active_children(), &NodeId::new(root_id.to_string()))
}
/// Cleanup intent that exists outside the generated document tree.
///
/// The default deliberately preserves the historical cleanup behavior. Only
/// the fresh-root orchestrator path may opt into the request-derived height
/// contract; append, section, and whole-document loop finalization stay false.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct CleanupPolicy {
pub(crate) preserve_requested_root_height: bool,
}
/// 阶段 4 清理 pass —— 在全部 subtask 插入完成后运行。
///
/// `root_ids` 是本轮产出的根 frame id(S3a 单屏只有一个;S3b 并发
@ -291,6 +301,17 @@ pub fn finalize_design_with_summary(
run_cleanup_passes_with_summary(sink, plan, root_ids, summary);
}
/// Fresh-root orchestrator variant carrying request-derived cleanup intent.
pub(crate) fn finalize_design_with_summary_and_policy(
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);
}
/// Env-gated (`OPENPENCIL_DEBUG_CLEANUP=1`) probe: log the named child's
/// current height under `root_id`, tagged with the pass that just ran.
fn debug_probe_child_height(sink: &dyn DocSink, root_id: &str, tag: &str) {
@ -338,6 +359,22 @@ pub fn run_cleanup_passes_with_summary(
plan: &OrchestratorPlan,
root_ids: &[&str],
summary: &mut RepairSummary,
) {
run_cleanup_passes_with_summary_and_policy(
sink,
plan,
root_ids,
summary,
CleanupPolicy::default(),
);
}
fn run_cleanup_passes_with_summary_and_policy(
sink: &mut dyn DocSink,
plan: &OrchestratorPlan,
root_ids: &[&str],
summary: &mut RepairSummary,
policy: CleanupPolicy,
) {
let mut counter = RepairCounter::new();
let mut counting = counter.wrap(sink);
@ -548,11 +585,12 @@ pub fn run_cleanup_passes_with_summary(
}
}
}
let preserve_root_height = find_root(sink.state(), rid).is_some_and(|root| {
root_has_explicit_fit_content_height(root)
|| has_explicit_mobile_viewport_contract(root)
|| crate::mobile_reflow::has_mobile_trailing_nav_reflow_contract(root)
});
let preserve_root_height = policy.preserve_requested_root_height
|| find_root(sink.state(), rid).is_some_and(|root| {
root_has_explicit_fit_content_height(root)
|| has_explicit_mobile_viewport_contract(root)
|| crate::mobile_reflow::has_mobile_trailing_nav_reflow_contract(root)
});
if preserve_root_height {
let mut guarded = PreserveRootHeightSink {
inner: sink,

View file

@ -63,7 +63,18 @@ pub(super) fn adjust_root_height_to_content(
if root_has_explicit_fit_content_height(root) {
return;
}
(root_content_height(root), root.height_px())
let estimated = root_content_height(root);
// The tree estimator intentionally has no font shaper, so wrapped
// fit-content text can make the real jian layout a little taller.
// Reconcile against that same resolved scene used by diagnostics.
let resolved = crate::geometry_validation::resolved_node_height(sink.state(), root_id)
.filter(|height| height.is_finite() && *height > 0.0)
.map(|height| height.ceil() as i32);
let required = match (estimated, resolved) {
(Some(estimated), Some(resolved)) => Some(estimated.max(resolved)),
(estimated, resolved) => estimated.or(resolved),
};
(required, root.height_px())
};
// Non-mobile roots only GROW a too-short fixed height to fit overflowing

View file

@ -393,6 +393,121 @@ fn cleanup_expands_zero_height_desktop_root_from_fit_content_children() {
assert_eq!(root.height_px(), Some(316.0));
}
#[test]
fn cleanup_reconciles_root_height_with_resolved_wrapped_text() {
let mut sink = VecDocSink::new();
let tree: PenNode = serde_json::from_value(json!({
"type": "frame",
"id": "root",
"name": "Long Landing Page",
"width": 320,
"height": 40,
"layout": "vertical",
"children": [{
"type": "text",
"id": "copy",
"name": "Wrapped Copy",
"content": "A deliberately long fixed-width sentence that wraps across several lines in the real layout engine.",
"width": 88,
"textGrowth": "fixed-width",
"fontSize": 18,
"lineHeight": 1.5
}]
}))
.expect("wrapped text root json");
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();
sink.applied.clear();
run_cleanup_passes(&mut sink, &plan(), &[&root_id]);
let declared = sink
.state
.active_children()
.iter()
.find(|node| node.id_str() == root_id)
.and_then(PenNodeExt::height_px)
.expect("numeric root height");
let resolved = crate::geometry_validation::resolved_node_height(&sink.state, &root_id)
.expect("resolved root height");
assert!(
declared + 0.5 >= resolved,
"declared {declared}px must contain resolved {resolved}px"
);
}
fn overfull_desktop_artboard() -> (VecDocSink, String) {
let mut sink = VecDocSink::new();
let tree: PenNode = serde_json::from_value(json!({
"type": "frame",
"id": "root",
"name": "Explicit Desktop Artboard",
"width": 1440,
"height": 900,
"layout": "vertical",
"gap": 24,
"children": [
{"type":"frame", "id":"upper", "name":"Upper Section",
"width":"fill_container", "height":500, "children":[]},
{"type":"frame", "id":"lower", "name":"Lower Section",
"width":"fill_container", "height":500, "children":[]}
]
}))
.expect("desktop artboard json");
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();
sink.applied.clear();
(sink, root_id)
}
#[test]
fn cleanup_policy_preserves_only_requested_fixed_root_height() {
let (mut growing_sink, growing_root_id) = overfull_desktop_artboard();
run_cleanup_passes(&mut growing_sink, &plan(), &[&growing_root_id]);
let grown_height = growing_sink
.state
.active_children()
.iter()
.find(|node| node.base().name.as_deref() == Some("Explicit Desktop Artboard"))
.and_then(PenNodeExt::height_px)
.expect("grown root height");
assert!(
grown_height > 900.0,
"default cleanup must keep growing ordinary overfull roots"
);
let (mut preserved_sink, preserved_root_id) = overfull_desktop_artboard();
let mut summary = RepairSummary::default();
run_cleanup_passes_with_summary_and_policy(
&mut preserved_sink,
&plan(),
&[&preserved_root_id],
&mut summary,
CleanupPolicy {
preserve_requested_root_height: true,
},
);
let preserved_height = preserved_sink
.state
.active_children()
.iter()
.find(|node| node.base().name.as_deref() == Some("Explicit Desktop Artboard"))
.and_then(PenNodeExt::height_px)
.expect("preserved root height");
assert_eq!(
preserved_height, 900.0,
"request-derived cleanup policy must freeze the explicit 1440x900 root"
);
}
#[test]
fn cleanup_recolors_safe_dark_bottom_nav_on_light_mobile_root() {
let mut sink = VecDocSink::new();

View file

@ -6,6 +6,7 @@ use crate::design_md_policy::{
build_design_md_style_policy, guess_neutral_background_from_theme, infer_design_md_background,
};
use crate::design_type::{detect_design_type, DesignType};
use crate::request_dimensions::requested_root_dimensions;
use crate::style_guide_context::infer_tags_from_prompt;
use jian_ops_schema::DesignMdSpec;
use op_ai_skills::style_guide::{
@ -92,19 +93,36 @@ pub fn build_compact_planning_prompt(
DesignType::LandingPage => "Create 4-8 scrollable page sections in top-to-bottom order.",
};
let mobile_rules: Vec<&str> = match preset.type_ {
let size_rule = if let Some(dimensions) = requested_root_dimensions(prompt) {
format!(
"The user explicitly requested the root dimensions. Use width={} and height={} on the root frame exactly.",
dimensions.width,
dimensions.height.unwrap_or(0.0)
)
} else {
match preset.type_ {
DesignType::MobileScreen => {
"Use width=375 and height=812 on the root frame.".to_string()
}
DesignType::Component => "Use width=400 and height=0 on the root frame.".to_string(),
_ => "Use width=1200 and height=0 on the root frame.".to_string(),
}
};
let mobile_rules: Vec<String> = match preset.type_ {
DesignType::MobileScreen => vec![
"This is a direct mobile screen, not a phone mockup.",
"Do NOT create a status bar section. The status bar is inserted separately.",
"Use width=375 and height=812 on the root frame.",
"This is a direct mobile screen, not a phone mockup.".to_string(),
"Do NOT create a status bar section. The status bar is inserted separately."
.to_string(),
size_rule,
],
DesignType::Component => vec![
"This is a single component (Type 0), not a screen.",
"Do NOT create a status bar, navigation, or footer section.",
"Use width=400 and height=0 on the root frame.",
"Use exactly 1 subtask for the component itself.",
"This is a single component (Type 0), not a screen.".to_string(),
"Do NOT create a status bar, navigation, or footer section.".to_string(),
size_rule,
"Use exactly 1 subtask for the component itself.".to_string(),
],
_ => vec!["Use width=1200 and height=0 on the root frame."],
_ => vec![size_rule],
};
let style_rule = if design_md.is_some() {
@ -137,8 +155,8 @@ pub fn build_compact_planning_prompt(
.to_string(),
style_rule,
];
for r in &mobile_rules {
lines.push((*r).to_string());
for rule in mobile_rules {
lines.push(rule);
}
lines.push(format!(
"Always set rootFrame layout=\"vertical\" and gap={default_gap}."
@ -194,6 +212,24 @@ mod tests {
assert!(!cp.selected_style_guide_name.is_empty());
}
#[test]
fn compact_prompt_honors_explicit_dimension_pair() {
let cp =
build_compact_planning_prompt("Design a 1440×900 desktop operations dashboard", None);
assert!(cp.system.contains("width=1440 and height=900"));
assert!(!cp.system.contains("width=1200 and height=0"));
}
#[test]
fn compact_prompt_honors_explicit_wide_root() {
let cp = build_compact_planning_prompt(
"Design a desktop landing page. Make the root exactly 1440px wide.",
None,
);
assert!(cp.system.contains("width=1440 and height=0"));
assert!(!cp.system.contains("width=1200 and height=0"));
}
#[test]
fn compact_design_md_forces_custom_name() {
let spec = jian_ops_schema::DesignMdSpec {

View file

@ -0,0 +1,78 @@
use super::resolved_node_height;
use crate::cleanup::run_cleanup_passes;
use crate::plan::{OrchestratorPlan, RootFrameSpec};
use crate::test_support::VecDocSink;
use jian_ops_schema::node::PenNode;
use op_editor_core::{EditorCommand, NodeId, PenNodeExt};
use serde_json::json;
fn plan() -> OrchestratorPlan {
OrchestratorPlan {
root_frame: RootFrameSpec {
id: "root".into(),
name: "Wrapped Copy".into(),
width: 320.0,
height: 40.0,
layout: Some("vertical".into()),
gap: None,
padding: None,
fill: None,
},
subtasks: Vec::new(),
style_guide_name: None,
}
}
#[test]
fn cleanup_grows_fixed_root_to_resolved_wrapped_text_bottom() {
let mut sink = VecDocSink::new();
let tree: PenNode = serde_json::from_value(json!({
"type": "frame",
"id": "root",
"name": "Long Landing Page",
"width": 320,
"height": 40,
"layout": "vertical",
"children": [{
"type": "text",
"id": "copy",
"name": "Wrapped Copy",
"content": "A deliberately long fixed-width sentence that wraps across several lines in the real layout engine.",
"width": 88,
"textGrowth": "fixed-width",
"fontSize": 18,
"lineHeight": 1.5
}]
}))
.expect("wrapped text root json");
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 overflowing_extent =
resolved_node_height(&sink.state, &root_id).expect("resolved content extent");
assert!(
overflowing_extent > 40.0,
"wrapped text must resolve past the fixed root, got {overflowing_extent}px"
);
run_cleanup_passes(&mut sink, &plan(), &[&root_id]);
let declared = sink
.state
.active_children()
.iter()
.find(|node| node.id_str() == root_id)
.and_then(PenNodeExt::height_px)
.expect("numeric root height");
let resolved =
resolved_node_height(&sink.state, &root_id).expect("post-cleanup resolved content extent");
assert!(declared > 40.0, "cleanup must grow the fixed 40px root");
assert!(
declared + 0.5 >= resolved,
"declared {declared}px must contain descendant bottom at {resolved}px"
);
}

View file

@ -103,6 +103,63 @@ fn resolved_rects(state: &EditorState) -> HashMap<String, Rect> {
map
}
pub(crate) fn resolved_node_height(state: &EditorState, node_id: &str) -> Option<f64> {
let scene = op_pen_loader::editor_state_to_active_page_layout_scene(state);
let page = scene.active_page()?;
let root = find_scene_node(&page.children, node_id)?;
resolved_subtree_height(root)
}
fn find_scene_node<'a>(nodes: &'a [SceneNode], node_id: &str) -> Option<&'a SceneNode> {
for node in nodes {
if node.id == node_id {
return Some(node);
}
if let Some(found) = find_scene_node(&node.children, node_id) {
return Some(found);
}
}
None
}
/// Measure the resolved subtree against the node's own top edge.
///
/// `SceneNode::aggregate_bounds()` intentionally returns a bounded node's own
/// rectangle, so it cannot reveal descendants that overflow a fixed-height
/// root. Raw scene bounds are absolute; walking every descendant preserves the
/// real layout bottom even when an ancestor clips or has an authored height.
fn resolved_subtree_height(root: &SceneNode) -> Option<f64> {
let root_top = f64::from(root.bounds.origin.y);
if !root_top.is_finite() {
return None;
}
let bottom = max_raw_bottom(root)?;
let height = bottom - root_top;
height.is_finite().then_some(height.max(0.0))
}
fn max_raw_bottom(node: &SceneNode) -> Option<f64> {
let y = f64::from(node.bounds.origin.y);
let height = f64::from(node.bounds.size.y);
let own_bottom = if y.is_finite() && height.is_finite() && height >= 0.0 {
let bottom = y + height;
bottom.is_finite().then_some(bottom)
} else {
None
};
node.children
.iter()
.filter_map(max_raw_bottom)
.fold(own_bottom, |max_bottom, child_bottom| {
Some(
max_bottom
.map(|current| current.max(child_bottom))
.unwrap_or(child_bottom),
)
})
}
fn collect_rects(nodes: &[SceneNode], map: &mut HashMap<String, Rect>) {
for n in nodes {
let b = n.aggregate_bounds();
@ -332,3 +389,7 @@ mod rail_collapse_tests;
#[cfg(test)]
#[path = "geometry_compact_status_tests.rs"]
mod compact_status_tests;
#[cfg(test)]
#[path = "geometry_resolved_extent_tests.rs"]
mod resolved_extent_tests;

View file

@ -29,6 +29,7 @@ pub mod plan;
pub mod plan_normalize;
pub mod plan_repair;
pub mod program_gen;
mod request_dimensions;
mod resolved_style_prompt;
pub mod retry;
pub mod script_gen;

View file

@ -14,6 +14,9 @@ use crate::types::DesignRequest;
mod plan_home_intent;
use plan_home_intent::plan_is_app_home_screen;
#[path = "plan_normalize_dimensions.rs"]
mod plan_normalize_dimensions;
// multiscreen-fanout-break fix (item A) — screen-grouping tests, split out
// to keep this file's inline `mod tests` from crossing the 800-line cap.
#[cfg(test)]
@ -24,11 +27,18 @@ mod tests_screen_groups;
#[path = "plan_normalize_nav_tests.rs"]
mod tests_nav;
#[cfg(test)]
#[path = "plan_normalize_dimensions_tests.rs"]
mod tests_dimensions;
/// 规范化产出的派生信息。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NormInfo {
/// 根 frame 窄到移动端宽度 —— scaffold 阶段据此注入固定状态栏。
pub is_mobile: bool,
/// 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,
}
/// 移动端宽度上限(含)—— ≤ 此值视为移动端单屏。
@ -154,6 +164,9 @@ fn ensure_requested_bottom_nav_subtask(plan: &mut OrchestratorPlan, req: &Design
/// LLM 值,超出则取推断值 —— 忠实 TS `normalizeOrchestratorPlan`
/// `orchestrator.ts:259-272`)。
pub fn normalize(plan: &mut OrchestratorPlan, req: &DesignRequest) -> NormInfo {
let preserve_requested_root_height =
plan_normalize_dimensions::apply_requested_root_dimensions(plan, req);
let is_mobile = plan.root_frame.width <= MOBILE_MAX_WIDTH;
if is_mobile {
@ -228,7 +241,10 @@ pub fn normalize(plan: &mut OrchestratorPlan, req: &DesignRequest) -> NormInfo {
}
}
NormInfo { is_mobile }
NormInfo {
is_mobile,
preserve_requested_root_height,
}
}
#[cfg(test)]

View file

@ -0,0 +1,28 @@
//! Request-derived root dimensions applied before the remaining plan rules.
use crate::plan::OrchestratorPlan;
use crate::request_dimensions::requested_root_dimensions;
use crate::types::DesignRequest;
/// Apply explicit root dimensions and report whether height was also fixed.
pub(super) fn apply_requested_root_dimensions(
plan: &mut OrchestratorPlan,
req: &DesignRequest,
) -> bool {
let Some(dimensions) = requested_root_dimensions(&req.prompt) else {
return false;
};
let planned_width = plan.root_frame.width;
plan.root_frame.width = dimensions.width;
if let Some(height) = dimensions.height {
plan.root_frame.height = height;
}
if (planned_width - dimensions.width).abs() > f64::EPSILON {
for subtask in &mut plan.subtasks {
if (subtask.region.width - planned_width).abs() <= 1.0 {
subtask.region.width = dimensions.width;
}
}
}
dimensions.height.is_some()
}

View file

@ -0,0 +1,104 @@
//! Explicit root-dimension regressions for `plan_normalize`.
use super::*;
use crate::plan::{OrchestratorPlan, Region, RootFrameSpec, Subtask};
fn request(prompt: &str) -> DesignRequest {
DesignRequest {
prompt: prompt.into(),
model: None,
provider: None,
design_md: None,
concurrency: 1,
append_context: None,
validation_enabled: true,
visual_ref_enabled: false,
}
}
fn subtask(id: &str, label: &str, width: f64, height: f64) -> Subtask {
Subtask {
id: id.into(),
label: label.into(),
region: Region { width, height },
id_prefix: String::new(),
parent_frame_id: None,
elements: None,
screen: None,
generated_root_id: None,
existing_section_labels: None,
retry_feedback: None,
}
}
fn plan(name: &str, subtasks: Vec<Subtask>) -> OrchestratorPlan {
OrchestratorPlan {
root_frame: RootFrameSpec {
id: "root".into(),
name: name.into(),
width: 1200.0,
height: 800.0,
layout: None,
gap: None,
padding: None,
fill: None,
},
subtasks,
style_guide_name: None,
}
}
#[test]
fn normalize_applies_explicit_root_dimensions_before_dashboard_sizing() {
let mut plan = plan(
"Dashboard",
vec![
subtask("sidebar", "Sidebar Navigation", 100.0, 500.0),
subtask("kpi-metrics", "KPI Metrics", 800.0, 0.0),
],
);
let norm = normalize(&mut plan, &request("Design a 1440×900 analytics dashboard"));
assert_eq!(plan.root_frame.width, 1440.0);
assert_eq!(plan.root_frame.height, 900.0);
assert!(
norm.preserve_requested_root_height,
"an explicit width-height pair must become a cleanup contract"
);
let metric = plan
.subtasks
.iter()
.find(|subtask| subtask.id == "kpi-metrics")
.unwrap();
assert_eq!(
metric.region.width, 1180.0,
"dashboard main width must derive from the requested 1440px root"
);
}
#[test]
fn normalize_updates_full_width_landing_regions_for_explicit_width() {
let mut plan = plan(
"Landing Page",
vec![
subtask("nav", "Navigation Bar", 1200.0, 100.0),
subtask("hero", "Hero Section", 640.0, 100.0),
],
);
let norm = normalize(
&mut plan,
&request("Design a desktop landing page. Make the root exactly 1440px wide."),
);
assert_eq!(plan.root_frame.width, 1440.0);
assert_eq!(plan.root_frame.height, 800.0);
assert!(
!norm.preserve_requested_root_height,
"width-only requests must still let cleanup grow the root height"
);
assert_eq!(plan.subtasks[0].region.width, 1440.0);
assert_eq!(
plan.subtasks[1].region.width, 640.0,
"an intentionally partial-width region must stay partial"
);
}

View file

@ -0,0 +1,259 @@
//! Fact-based root dimensions explicitly requested in the user prompt.
use regex::Regex;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct RequestedRootDimensions {
pub width: f64,
pub height: Option<f64>,
}
#[derive(Debug, Clone, Copy)]
struct DimensionCandidate {
start: usize,
end: usize,
dimensions: RequestedRootDimensions,
}
const CONTEXT_RADIUS: usize = 56;
const ROOT_CONTEXT_TERMS: &[&str] = &[
"root",
"artboard",
"page",
"screen",
"canvas",
// "desktop dashboard" is the catalog's natural-language shorthand for
// one desktop screen, including the accepted "1440x900 desktop dashboard"
// form where no literal "screen" token is present.
"desktop",
"dashboard",
"根画板",
"画板",
"页面",
"屏幕",
"画布",
];
const NESTED_CONTEXT_TERMS: &[&str] = &[
"hero",
"image",
"card",
"banner",
"thumbnail",
"photo",
"插图",
"图片",
"卡片",
];
fn valid_dimension(value: u32) -> bool {
(240..=10_000).contains(&value)
}
fn pair_regex() -> &'static Regex {
static PAIR: OnceLock<Regex> = OnceLock::new();
PAIR.get_or_init(|| {
Regex::new(r"(?i)([0-9]{3,5})\s*(?:px\s*)?x\s*([0-9]{3,5})(?:\s*px)?")
.expect("root dimension pair regex")
})
}
fn width_regex() -> &'static Regex {
static WIDTH: OnceLock<Regex> = OnceLock::new();
WIDTH.get_or_init(|| {
Regex::new(r"(?i)([0-9]{3,5})\s*(?:px|pixels?)\s+wide").expect("root width regex")
})
}
fn is_word_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_'
}
fn term_has_boundaries(text: &str, start: usize, term: &str) -> bool {
if !term.is_ascii() {
return true;
}
let before = text[..start].chars().next_back();
let after = text[start + term.len()..].chars().next();
before.is_none_or(|ch| !is_word_char(ch)) && after.is_none_or(|ch| !is_word_char(ch))
}
fn range_distance(start: usize, end: usize, other_start: usize, other_end: usize) -> usize {
if other_end <= start {
start.saturating_sub(other_end)
} else if end <= other_start {
other_start.saturating_sub(end)
} else {
0
}
}
fn nearest_term_distance(
text: &str,
candidate: DimensionCandidate,
terms: &[&str],
) -> Option<usize> {
terms
.iter()
.flat_map(|term| {
text.match_indices(term)
.filter(move |(start, _)| term_has_boundaries(text, *start, term))
.map(move |(start, _)| {
range_distance(candidate.start, candidate.end, start, start + term.len())
})
})
.filter(|distance| *distance <= CONTEXT_RADIUS)
.min()
}
fn is_root_scoped(text: &str, candidate: DimensionCandidate) -> bool {
let Some(root_distance) = nearest_term_distance(text, candidate, ROOT_CONTEXT_TERMS) else {
return false;
};
nearest_term_distance(text, candidate, NESTED_CONTEXT_TERMS)
.is_none_or(|nested_distance| root_distance < nested_distance)
}
fn pair_candidates(text: &str) -> Vec<DimensionCandidate> {
pair_regex()
.captures_iter(text)
.filter_map(|captures| {
let whole = captures.get(0)?;
let width = captures.get(1)?.as_str().parse::<u32>().ok()?;
let height = captures.get(2)?.as_str().parse::<u32>().ok()?;
(valid_dimension(width) && valid_dimension(height)).then_some(DimensionCandidate {
start: whole.start(),
end: whole.end(),
dimensions: RequestedRootDimensions {
width: f64::from(width),
height: Some(f64::from(height)),
},
})
})
.collect()
}
fn width_candidates(text: &str, pair_candidates: &[DimensionCandidate]) -> Vec<DimensionCandidate> {
width_regex()
.captures_iter(text)
.filter_map(|captures| {
let whole = captures.get(0)?;
if pair_candidates
.iter()
.any(|pair| whole.start() < pair.end && pair.start < whole.end())
{
return None;
}
let width = captures.get(1)?.as_str().parse::<u32>().ok()?;
valid_dimension(width).then_some(DimensionCandidate {
start: whole.start(),
end: whole.end(),
dimensions: RequestedRootDimensions {
width: f64::from(width),
height: None,
},
})
})
.collect()
}
pub(crate) fn requested_root_dimensions(prompt: &str) -> Option<RequestedRootDimensions> {
let normalized = prompt.to_lowercase().replace('×', "x");
let pairs = pair_candidates(&normalized);
let mut candidates = pairs.clone();
candidates.extend(width_candidates(&normalized, &pairs));
// A later, explicitly root-scoped statement wins. This matters for prompts
// that first size a card/image and then state the page/root width.
candidates
.into_iter()
.filter(|candidate| is_root_scoped(&normalized, *candidate))
.max_by_key(|candidate| candidate.start)
.map(|candidate| candidate.dimensions)
}
#[cfg(test)]
mod tests {
use super::*;
fn dimensions(width: f64, height: Option<f64>) -> Option<RequestedRootDimensions> {
Some(RequestedRootDimensions { width, height })
}
#[test]
fn parses_dimension_pair_with_multiplication_sign() {
assert_eq!(
requested_root_dimensions("Design a 1440×900 desktop analytics dashboard"),
dimensions(1440.0, Some(900.0))
);
}
#[test]
fn parses_explicit_root_and_artboard_pairs() {
assert_eq!(
requested_root_dimensions("Use a root frame sized 1366 x 768px."),
dimensions(1366.0, Some(768.0))
);
assert_eq!(
requested_root_dimensions("Canvas artboard: 1600px x 1000px"),
dimensions(1600.0, Some(1000.0))
);
}
#[test]
fn parses_explicit_pixel_width_without_guessing_height() {
assert_eq!(
requested_root_dimensions(
"Make the root exactly 1440px wide and between 2400 and 5200px tall"
),
dimensions(1440.0, None)
);
}
#[test]
fn rejects_nested_hero_image_and_card_dimensions() {
for prompt in [
"Design a page with a hero image sized 1440×900.",
"On the dashboard, make the card 420px wide.",
"Create a screen whose thumbnail image is 640 x 360.",
] {
assert_eq!(
requested_root_dimensions(prompt),
None,
"nested dimensions must not become the root contract: {prompt}"
);
}
}
#[test]
fn later_root_width_wins_over_earlier_card_width() {
assert_eq!(
requested_root_dimensions(
"Use a 420px wide card in the hero, then make the page root 1440px wide."
),
dimensions(1440.0, None)
);
}
#[test]
fn later_page_pair_wins_over_earlier_image_pair() {
assert_eq!(
requested_root_dimensions(
"The hero image is 1200x600. Render the desktop page at 1440x900."
),
dimensions(1440.0, Some(900.0))
);
}
#[test]
fn ignores_aspect_ratios_and_unqualified_numbers() {
assert_eq!(
requested_root_dimensions("Crop the top 900px to a 16:10 preview"),
None
);
assert_eq!(
requested_root_dimensions("Use a 1440x900 image without creating an artboard."),
None
);
}
}

View file

@ -21,7 +21,10 @@
//! 一条独立消费方,不受此影响。
use crate::append::apply_append_context_to_plan;
use crate::cleanup::{descendant_count, finalize_design_with_summary};
use crate::cleanup::{
descendant_count, finalize_design_with_summary, finalize_design_with_summary_and_policy,
CleanupPolicy,
};
use crate::model_profile::resolve_model_profile;
use crate::plan::{build_fallback_plan, OrchestratorPlan};
use crate::plan_normalize::{normalize, NormInfo};

View file

@ -622,7 +622,15 @@ impl Orchestrator {
// Passing them all is still correct scoping for the OTHER
// whole-root cleanup passes (dedup / avatar-repair / etc.).
let root_id_refs: Vec<&str> = root_ids.iter().map(String::as_str).collect();
finalize_design_with_summary(sink, &plan, &root_id_refs, &mut quality);
finalize_design_with_summary_and_policy(
sink,
&plan,
&root_id_refs,
&mut quality,
CleanupPolicy {
preserve_requested_root_height: norm.preserve_requested_root_height,
},
);
}
on_progress(Progress::CleanupDone);
// Turn the cleanup stage's tally into a user-visible credential. Only

View file

@ -47,6 +47,47 @@ fn run_happy_path_applies_scaffold_and_subtasks() {
);
}
#[test]
fn fresh_run_preserves_explicit_root_height_through_cleanup() {
let tall_section = |name: &str| {
format!(
r#"I(null, {{"type":"frame","name":"{name}","width":"fill_container","height":560,"children":[{{"type":"text","content":"{name}","fontSize":18}}]}});"#
)
};
let llm = ScriptedLlm::new(vec![
ScriptResponse::Text(PLAN_JSON.into()),
ScriptResponse::Text(tall_section("Tall Hero")),
ScriptResponse::Text(tall_section("Tall Features")),
]);
let mut sink = VecDocSink::new();
let mut request = req();
request.prompt = "Design a 1440×900 desktop page".into();
request.validation_enabled = false;
futures::executor::block_on(Orchestrator::new().run(
request,
&mut sink,
&llm,
&mut |_| {},
&AbortFlag::new(),
&stub_providers(),
))
.expect("explicit-size run succeeds");
let root = sink
.state
.active_children()
.iter()
.find(|node| node.base().name.as_deref() == Some("Page"))
.expect("generated page root");
assert_eq!(root.width_px(), Some(1440.0));
assert_eq!(
root.height_px(),
Some(900.0),
"fresh-run cleanup must not grow an explicitly requested root height"
);
}
#[test]
fn run_mobile_scaffold_reveals_status_bar() {
let _guard = crate::agent_indicator_test_support::lock();