From b7deb369dcd7e3e8a9e5b458e942fa063cbb28b4 Mon Sep 17 00:00:00 2001 From: Fini Date: Thu, 2 Jul 2026 21:21:37 +0800 Subject: [PATCH] feat(orchestrator): app-shell content gutter + sidebar footer sink --- crates/op-orchestrator/src/app_shell.rs | 64 ++++++++++++++++ crates/op-orchestrator/src/app_shell_tests.rs | 75 +++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/crates/op-orchestrator/src/app_shell.rs b/crates/op-orchestrator/src/app_shell.rs index 67a5df5e2..94e1ea09b 100644 --- a/crates/op-orchestrator/src/app_shell.rs +++ b/crates/op-orchestrator/src/app_shell.rs @@ -34,6 +34,9 @@ const SIDEBAR_WIDTH: f64 = 260.0; const DESKTOP_MIN_WIDTH: f64 = 900.0; /// A real sidebar is full-height; a 64–96px full-width band is a header. const MIN_SIDEBAR_HEIGHT: f64 = 200.0; +/// Outer gutter for the content column `[vertical, horizontal]`. Mirrors +/// Pencil's app-shell content `padding:[32,40]`. +const CONTENT_PADDING: [i64; 2] = [32, 40]; /// Restructure a flat-vertical desktop dashboard whose first child is a /// full-width sidebar into a horizontal `[sidebar(fixed) | content(fill)]` @@ -226,6 +229,62 @@ fn detect(v: &Value) -> bool { // ── Restructure ── +/// Drop the sidebar's footer (user/profile card) to the bottom of the now +/// full-height column. Pencil splits the sidebar into a Top + Bottom group with +/// `justifyContent: space_between`; weak models emit a flat column with a +/// FIXED-height spacer (e.g. 120px) that no longer reaches the bottom once the +/// sidebar stretches to the content height — so the footer floats mid-column. +/// Stretch an existing spacer to `fill_container`; if there is none, inject a +/// flexible spacer just before a footer-like last child. +fn sink_sidebar_footer(sidebar: &mut Value) { + let sidebar_id = sidebar + .get("id") + .and_then(Value::as_str) + .unwrap_or("sidebar") + .to_string(); + let Some(kids) = sidebar.get_mut("children").and_then(Value::as_array_mut) else { + return; + }; + if kids.len() < 2 { + return; + } + let mut stretched = false; + for c in kids.iter_mut() { + if ident_text(c).contains("spacer") { + if let Some(o) = c.as_object_mut() { + o.insert("height".into(), json!("fill_container")); + stretched = true; + } + } + } + if stretched { + return; + } + let last_is_footer = kids + .last() + .map(ident_text) + .map(|t| { + ["user", "profile", "account", "avatar", "footer", "member"] + .iter() + .any(|k| t.contains(k)) + }) + .unwrap_or(false); + if last_is_footer { + let pos = kids.len() - 1; + kids.insert( + pos, + json!({ + "type": "frame", + "id": format!("{sidebar_id}-spacer"), + "name": "Sidebar Spacer", + "width": "fill_container", + "height": "fill_container", + "children": [], + }), + ); + } +} + /// Recursively retarget any descendant whose width was sized to (about) the OLD /// full root width down to `fill_container`, so full-width sections/dividers /// fill their new narrower column instead of overflowing it (taffy never @@ -285,6 +344,7 @@ fn restructure(v: &mut Value) -> bool { s.insert("clipContent".into(), json!(true)); } fill_full_width_descendants(&mut sidebar, root_w); + sink_sidebar_footer(&mut sidebar); // Content sections → fill the new column instead of the old full root width. for section in content_sections.iter_mut() { @@ -299,6 +359,10 @@ fn restructure(v: &mut Value) -> bool { "height": "fit_content", "layout": "vertical", "gap": content_gap, + // Outer page gutter (Pencil's app-shell content carries padding:[32,40] + // so sections don't run edge-to-edge into the viewport). Without it the + // new fill_container column lets the stat cards touch the right edge. + "padding": CONTENT_PADDING, "children": content_sections, }); diff --git a/crates/op-orchestrator/src/app_shell_tests.rs b/crates/op-orchestrator/src/app_shell_tests.rs index 5783914c5..ce49225e5 100644 --- a/crates/op-orchestrator/src/app_shell_tests.rs +++ b/crates/op-orchestrator/src/app_shell_tests.rs @@ -335,3 +335,78 @@ fn negative_multiscreen_wrapper_untouched() { "multiple standalone screens, not dashboard sections", ); } + +/// A vertical 1200-root with a custom sidebar + the two dashboard sections that +/// pass the gate; lets the sidebar-footer tests vary only the sidebar. +fn wrapper_with_sidebar(sidebar_children: Value) -> PenNode { + node(json!({ + "type": "frame", "id": "root", "name": "Dashboard", + "width": 1200, "height": 1400, "layout": "vertical", "gap": 24, + "children": [ + { "type": "frame", "id": "sb", "name": "Sidebar Navigation", + "width": 1200, "height": 700, "layout": "vertical", "children": sidebar_children }, + section("Key Metrics", json!(1200), json!(117)), + section("Client Table", json!(1200), json!(400)), + section("Upcoming", json!(1200), json!(300)), + ] + })) +} + +#[test] +fn content_column_gets_outer_padding() { + let mut w = bug_wrapper(); + reshape_sidebar_to_app_shell(&mut w); + let v = val(&w); + // The Main Content column carries the Pencil app-shell gutter so the + // sections don't run edge-to-edge into the viewport (serialized as f64). + let pad: Vec = v["children"][1]["padding"] + .as_array() + .unwrap() + .iter() + .map(|n| n.as_f64().unwrap()) + .collect(); + assert_eq!(pad, [32.0, 40.0]); +} + +#[test] +fn sidebar_fixed_spacer_stretched_to_sink_footer() { + let mut w = wrapper_with_sidebar(json!([ + { "type": "frame", "id": "logo", "name": "Brand Logo", "height": 29, "children": [] }, + { "type": "frame", "id": "nav", "name": "Nav Section", "height": 263, "children": [] }, + { "type": "frame", "id": "sp", "name": "Spacer", "height": 120, "children": [] }, + { "type": "frame", "id": "usr", "name": "User Profile Card", "height": 72, "children": [] } + ])); + assert!(reshape_sidebar_to_app_shell(&mut w)); + let v = val(&w); + let sb_kids = v["children"][0]["children"].as_array().unwrap(); + let spacer = sb_kids + .iter() + .find(|c| c["name"] == json!("Spacer")) + .unwrap(); + assert_eq!( + spacer["height"], + json!("fill_container"), + "fixed spacer stretched" + ); +} + +#[test] +fn sidebar_without_spacer_gets_one_injected_before_footer() { + let mut w = wrapper_with_sidebar(json!([ + { "type": "frame", "id": "logo", "name": "Brand Logo", "height": 29, "children": [] }, + { "type": "frame", "id": "nav", "name": "Nav Section", "height": 263, "children": [] }, + { "type": "frame", "id": "usr", "name": "User Profile Card", "height": 72, "children": [] } + ])); + assert!(reshape_sidebar_to_app_shell(&mut w)); + let v = val(&w); + let sb_kids = v["children"][0]["children"].as_array().unwrap(); + // A flexible spacer was inserted right before the footer card. + assert_eq!(sb_kids.len(), 4, "spacer injected"); + assert_eq!(sb_kids[2]["name"], json!("Sidebar Spacer")); + assert_eq!(sb_kids[2]["height"], json!("fill_container")); + assert_eq!( + sb_kids[3]["name"], + json!("User Profile Card"), + "footer stays last" + ); +}