feat(orchestrator): app-shell content gutter + sidebar footer sink

This commit is contained in:
Fini 2026-07-02 21:21:37 +08:00
parent 7956595853
commit b7deb369dc
2 changed files with 139 additions and 0 deletions

View file

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

View file

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