diff --git a/crates/op-orchestrator/src/lib.rs b/crates/op-orchestrator/src/lib.rs index 27e37a440..e34573b2e 100644 --- a/crates/op-orchestrator/src/lib.rs +++ b/crates/op-orchestrator/src/lib.rs @@ -36,6 +36,7 @@ pub mod cleanup; pub(crate) mod cleanup_typography; pub mod concurrent; pub mod prompt; +pub mod role_defaults; pub mod role_infer; pub mod run; pub mod run_dashboard; diff --git a/crates/op-orchestrator/src/role_defaults.rs b/crates/op-orchestrator/src/role_defaults.rs new file mode 100644 index 000000000..e7cce69bc --- /dev/null +++ b/crates/op-orchestrator/src/role_defaults.rs @@ -0,0 +1,497 @@ +//! Role-default injection — P2 increment **I2**. +//! +//! Port of the 43 `registerRole` rules in `role-definitions/index.ts` plus +//! `applyDefaults` and `detectThemeFromNode` from `role-resolver.ts`. Given a +//! node's resolved `role` (from [`crate::role_infer`]) this fills in the +//! role's default visual/layout properties — but ONLY fields the model left +//! unset (AI-explicit values always win), exactly like the TS +//! `record[key] === undefined` check. +//! +//! Implementation note: defaults are applied by a JSON round-trip merge +//! (serialize the node → insert absent keys → deserialize). jian's PenNode is +//! `rename_all = "camelCase"` with `#[serde(flatten)]` container/text props, so +//! camelCase keys map straight onto the canonical schema, and "insert if +//! absent" reproduces the TS semantics without hand-mapping every typed field. + +use jian_ops_schema::node::PenNode; +use serde_json::{json, Value}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Theme { + Light, + Dark, +} + +/// Context threaded down the tree walk (port of `RoleContext`). +#[derive(Clone)] +pub struct RoleCtx { + pub parent_role: Option, + pub parent_layout: Option, + pub canvas_width: f64, + pub theme: Theme, +} + +impl RoleCtx { + /// Entry context for a forest of section roots. + pub fn root(canvas_width: f64, theme: Theme) -> Self { + RoleCtx { + parent_role: None, + parent_layout: None, + canvas_width, + theme, + } + } + + fn is_mobile(&self) -> bool { + self.canvas_width <= 480.0 + } +} + +// ── theme-aware style fragments (port of the helpers atop index.ts) ───────── + +fn solid(color: &str) -> Value { + json!([{ "type": "solid", "color": color }]) +} + +fn card_fill(theme: Theme) -> Value { + solid(if theme == Theme::Dark { + "#1A1A1A" + } else { + "#FFFFFF" + }) +} + +fn card_shadow() -> Value { + json!([ + { "type": "shadow", "offsetX": 0, "offsetY": 1, "blur": 3, "spread": 0, "color": "#0000001A" }, + { "type": "shadow", "offsetX": 0, "offsetY": 1, "blur": 2, "spread": -1, "color": "#0000000F" } + ]) +} + +fn input_fill(theme: Theme) -> Value { + solid(if theme == Theme::Dark { + "#1A1A1A" + } else { + "#F8FAFC" + }) +} + +fn input_stroke(theme: Theme) -> Value { + json!({ "thickness": 1, "fill": solid(if theme == Theme::Dark { "#2A2A2A" } else { "#E2E8F0" }) }) +} + +fn navbar_fill(theme: Theme) -> Value { + solid(if theme == Theme::Dark { + "#111111" + } else { + "#FFFFFF" + }) +} + +fn navbar_bottom_border(theme: Theme) -> Value { + json!({ "thickness": [0, 0, 1, 0], "fill": solid(if theme == Theme::Dark { "#1F1F1F" } else { "#E2E8F0" }) }) +} + +fn divider_fill(theme: Theme) -> Value { + solid(if theme == Theme::Dark { + "#2A2A2A" + } else { + "#E2E8F0" + }) +} + +/// `[vertical, horizontal]` padding tuple as a JSON array. +fn pad2(v: f64, h: f64) -> Value { + json!([v, h]) +} + +// ── theme detection (port of detectThemeFromNode) ─────────────────────────── + +/// Detect light/dark from a node's first solid fill (luminance < 0.3 = dark). +/// Pass the PAGE ROOT — a dark page's card has no fill of its own. +pub fn detect_theme_from_fill(fill: Option<&str>) -> Theme { + let Some(color) = fill else { + return Theme::Light; + }; + let c = color.trim(); + if c.starts_with('$') { + return Theme::Light; // unresolved ref — can't tell + } + let Some((r, g, b)) = parse_hex_rgb(c) else { + return Theme::Light; + }; + // sRGB → WCAG relative luminance. + let lin = |v: u8| { + let s = v as f64 / 255.0; + if s <= 0.03928 { + s / 12.92 + } else { + ((s + 0.055) / 1.055).powf(2.4) + } + }; + let y = 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); + if y < 0.3 { + Theme::Dark + } else { + Theme::Light + } +} + +fn parse_hex_rgb(hex: &str) -> Option<(u8, u8, u8)> { + let h = hex.strip_prefix('#')?; + let h = match h.len() { + 3 => h.chars().flat_map(|c| [c, c]).collect::(), + 6 | 8 => h.to_string(), + _ => return None, + }; + let r = u8::from_str_radix(&h[0..2], 16).ok()?; + let g = u8::from_str_radix(&h[2..4], 16).ok()?; + let b = u8::from_str_radix(&h[4..6], 16).ok()?; + Some((r, g, b)) +} + +/// CJK detection for typography roles (Han / Kana / Hangul ranges). +fn has_cjk(text: &str) -> bool { + text.chars().any(|c| { + matches!(c as u32, + 0x4E00..=0x9FFF // CJK Unified Ideographs + | 0x3400..=0x4DBF // CJK Extension A + | 0x3040..=0x30FF // Hiragana + Katakana + | 0xAC00..=0xD7AF // Hangul syllables + | 0xF900..=0xFAFF // CJK Compatibility Ideographs + ) + }) +} + +// ── the 43 role rules (port of registerRole(...) in index.ts) ─────────────── + +fn node_number(node_json: &Value, key: &str) -> Option { + node_json.get(key).and_then(Value::as_f64) +} + +fn node_layout(node_json: &Value) -> Option<&str> { + node_json.get("layout").and_then(Value::as_str) +} + +fn node_text(node_json: &Value) -> String { + node_json + .get("content") + .and_then(Value::as_str) + .unwrap_or("") + .to_string() +} + +/// Return the role's default property object, or `None` for an unknown role. +fn role_defaults(role: &str, node_json: &Value, ctx: &RoleCtx) -> Option { + let mobile = ctx.is_mobile(); + let parent_layout = ctx.parent_layout.as_deref(); + let parent_role = ctx.parent_role.as_deref(); + let theme = ctx.theme; + + let v = match role { + // ── Layout ── + "section" => json!({ + "layout": "vertical", "width": "fill_container", "height": "fit_content", + "gap": 24, "padding": if mobile { pad2(40.0, 16.0) } else { pad2(60.0, 80.0) }, + "alignItems": "center" + }), + "row" => { + json!({ "layout": "horizontal", "width": "fill_container", "gap": 16, "alignItems": "center" }) + } + "column" => json!({ "layout": "vertical", "width": "fill_container", "gap": 16 }), + "centered-content" => json!({ + "layout": "vertical", "width": if mobile { json!("fill_container") } else { json!(1080) }, + "gap": 24, "alignItems": "center" + }), + "form-group" => json!({ "layout": "vertical", "width": "fill_container", "gap": 16 }), + "spacer" => json!({ "width": "fill_container", "height": 40 }), + "divider" => { + let vertical = node_json + .get("name") + .and_then(Value::as_str) + .map(|n| n.to_lowercase().contains("vertical")) + .unwrap_or(false); + if vertical { + json!({ "width": 1, "height": "fill_container", "layout": "none", "fill": divider_fill(theme) }) + } else { + json!({ "width": "fill_container", "height": 1, "layout": "none", "fill": divider_fill(theme) }) + } + } + + // ── Navigation ── + "navbar" => json!({ + "layout": "horizontal", "width": "fill_container", + "height": if mobile { 56 } else { 72 }, + "padding": if mobile { pad2(0.0, 16.0) } else { pad2(0.0, 80.0) }, + "alignItems": "center", "justifyContent": "space_between", + "fill": navbar_fill(theme), "stroke": navbar_bottom_border(theme) + }), + "nav-links" => json!({ "layout": "horizontal", "gap": 24, "alignItems": "center" }), + "nav-link" => json!({ "textGrowth": "auto", "lineHeight": 1.2 }), + + // ── Interactive ── + "button" => button_defaults(node_json, parent_role), + "icon-button" => json!({ + "width": 44, "height": 44, "layout": "horizontal", + "justifyContent": "center", "alignItems": "center", "cornerRadius": 8 + }), + "badge" => json!({ + "layout": "horizontal", "padding": json!([6, 12]), "gap": 4, + "alignItems": "center", "justifyContent": "center", "cornerRadius": 999, + "fill": solid("#DBEAFE") + }), + "tag" => json!({ + "layout": "horizontal", "padding": json!([4, 10]), "gap": 4, + "alignItems": "center", "justifyContent": "center", "cornerRadius": 6 + }), + "pill" => json!({ + "layout": "horizontal", "padding": json!([6, 14]), "gap": 6, + "alignItems": "center", "justifyContent": "center", "cornerRadius": 999 + }), + "input" => { + let mut m = json!({ + "height": 48, "layout": "horizontal", "padding": json!([12, 16]), + "alignItems": "center", "cornerRadius": 8, + "fill": input_fill(theme), "stroke": input_stroke(theme) + }); + if parent_layout == Some("vertical") { + m["width"] = json!("fill_container"); + } + m + } + "form-input" => json!({ + "width": "fill_container", "height": 48, "layout": "horizontal", + "padding": json!([12, 16]), "alignItems": "center", "cornerRadius": 8, + "fill": input_fill(theme), "stroke": input_stroke(theme) + }), + "search-bar" => { + if matches!( + parent_role, + Some("bottom-tab-bar") | Some("tab-bar") | Some("tab-row") + ) { + json!({}) + } else { + json!({ + "layout": "horizontal", "height": 44, "padding": json!([10, 16]), "gap": 8, + "alignItems": "center", "cornerRadius": 22, + "fill": input_fill(theme), "stroke": input_stroke(theme) + }) + } + } + + // ── Display / cards ── + "card" => card_like( + theme, + parent_layout, + json!({ "gap": 12, "cornerRadius": 12, "clipContent": true }), + ), + "stat-card" => card_like( + theme, + parent_layout, + json!({ "gap": 8, "padding": json!([24, 24]), "cornerRadius": 12 }), + ), + "pricing-card" => card_like( + theme, + parent_layout, + json!({ "gap": 16, "padding": json!([32, 24]), "cornerRadius": 16, "clipContent": true }), + ), + "image-card" => json!({ + "layout": "vertical", "gap": 0, "cornerRadius": 12, "clipContent": true, "effects": card_shadow() + }), + "feature-card" => card_like( + theme, + parent_layout, + json!({ "gap": 12, "padding": json!([24, 24]), "cornerRadius": 12 }), + ), + "testimonial" => json!({ + "layout": "vertical", "gap": 16, "padding": json!([24, 24]), "cornerRadius": 12, + "fill": card_fill(theme), "effects": card_shadow() + }), + + // ── Content ── + "hero" => json!({ + "layout": "vertical", "width": "fill_container", "height": "fit_content", + "padding": if mobile { pad2(40.0, 16.0) } else { pad2(80.0, 80.0) }, + "gap": 24, "alignItems": "center" + }), + "feature-grid" => { + json!({ "layout": "horizontal", "width": "fill_container", "gap": 24, "alignItems": "start" }) + } + "cta-section" => json!({ + "layout": "vertical", "width": "fill_container", "height": "fit_content", + "padding": if mobile { pad2(40.0, 16.0) } else { pad2(60.0, 80.0) }, + "gap": 20, "alignItems": "center" + }), + "footer" => json!({ + "layout": "vertical", "width": "fill_container", "height": "fit_content", + "padding": if mobile { pad2(32.0, 16.0) } else { pad2(48.0, 80.0) }, "gap": 24 + }), + "stats-section" => json!({ + "layout": "horizontal", "width": "fill_container", "height": "fit_content", + "padding": if mobile { pad2(32.0, 16.0) } else { pad2(48.0, 80.0) }, + "gap": 32, "justifyContent": "center", "alignItems": "center" + }), + + // ── Media ── + "phone-mockup" => { + json!({ "width": 280, "height": 560, "cornerRadius": 32, "layout": "none" }) + } + "screenshot-frame" => json!({ "cornerRadius": 12, "clipContent": true }), + "avatar" => { + let size = node_number(node_json, "width") + .filter(|w| *w > 0.0) + .unwrap_or(48.0); + json!({ "width": size, "height": size, "cornerRadius": (size / 2.0).round(), "clipContent": true }) + } + "icon" => { + if matches!(node_json.get("type").and_then(Value::as_str), Some("frame")) { + json!({ "width": 24, "height": 24, "layout": "horizontal", "alignItems": "center", "justifyContent": "center" }) + } else { + json!({ "width": 24, "height": 24 }) + } + } + + // ── Typography (CJK-aware) ── + "heading" => { + let cjk = has_cjk(&node_text(node_json)); + let mut m = json!({ + "lineHeight": if cjk { 1.35 } else { 1.2 }, + "letterSpacing": if cjk { 0.0 } else { -0.5 }, + "textGrowth": if parent_layout == Some("vertical") { "fixed-width" } else { "auto" } + }); + if parent_layout == Some("vertical") { + m["width"] = json!("fill_container"); + } + m + } + "subheading" => { + let cjk = has_cjk(&node_text(node_json)); + json!({ "lineHeight": if cjk { 1.4 } else { 1.3 }, "textGrowth": "fixed-width", "width": "fill_container" }) + } + "body-text" => { + let cjk = has_cjk(&node_text(node_json)); + json!({ "lineHeight": if cjk { 1.6 } else { 1.5 }, "textGrowth": "fixed-width", "width": "fill_container" }) + } + "caption" => { + let cjk = has_cjk(&node_text(node_json)); + json!({ "lineHeight": if cjk { 1.4 } else { 1.3 }, "textGrowth": "auto" }) + } + "label" => { + json!({ "lineHeight": 1.2, "textGrowth": "auto", "textAlignVertical": "middle" }) + } + + // ── Table ── + "table" => { + json!({ "layout": "vertical", "width": "fill_container", "gap": 0, "clipContent": true }) + } + "table-row" => { + json!({ "layout": "horizontal", "width": "fill_container", "alignItems": "center", "padding": json!([12, 16]) }) + } + "table-header" => json!({ + "layout": "horizontal", "width": "fill_container", "alignItems": "center", + "padding": json!([12, 16]), "fill": input_fill(theme) + }), + "table-cell" => json!({ "width": "fill_container" }), + + _ => return None, + }; + Some(v) +} + +/// Card-family default builder. `parent_layout == "horizontal"` adds +/// fill/fill sizing so cards in a row stretch evenly. Port of the shared +/// branch in card/stat-card/pricing-card/feature-card. +fn card_like(theme: Theme, parent_layout: Option<&str>, extra: Value) -> Value { + let mut m = json!({ + "layout": "vertical", "fill": card_fill(theme), "effects": card_shadow() + }); + if parent_layout == Some("horizontal") { + m["width"] = json!("fill_container"); + m["height"] = json!("fill_container"); + } + if let (Some(obj), Some(ex)) = (m.as_object_mut(), extra.as_object()) { + for (k, val) in ex { + obj.insert(k.clone(), val.clone()); + } + } + m +} + +/// `button` role — branches by parent role and the node's own size. Port of +/// the `registerRole('button', ...)` body. +fn button_defaults(node_json: &Value, parent_role: Option<&str>) -> Value { + if parent_role == Some("navbar") { + return json!({ + "padding": json!([8, 16]), "height": 36, "layout": "horizontal", "gap": 8, + "alignItems": "center", "justifyContent": "center", "cornerRadius": 8 + }); + } + if parent_role == Some("form-group") { + return json!({ + "width": "fill_container", "height": 48, "layout": "horizontal", "gap": 8, + "padding": json!([12, 24]), "alignItems": "center", "justifyContent": "center", "cornerRadius": 10 + }); + } + // Bottom-tab cell: tight padding, no fill/cornerRadius. + if matches!( + parent_role, + Some("bottom-tab-bar") | Some("tab-bar") | Some("tab-row") + ) && node_layout(node_json) == Some("vertical") + { + return json!({ + "gap": 4, "padding": json!([6, 4]), "alignItems": "center", "justifyContent": "center" + }); + } + // Avatar / icon-button shape: small square frame. + let w = node_number(node_json, "width"); + let h = node_number(node_json, "height"); + if let (Some(w), Some(h)) = (w, h) { + if w <= 60.0 && h <= 60.0 && w < 48.0 { + return json!({ + "layout": "horizontal", "gap": 8, "alignItems": "center", "justifyContent": "center" + }); + } + } + json!({ + "padding": json!([12, 24]), "height": 44, "layout": "horizontal", "gap": 8, + "alignItems": "center", "justifyContent": "center", "cornerRadius": 8 + }) +} + +/// Apply a role's defaults to `node`, setting only keys the node doesn't +/// already have (AI-explicit wins). JSON round-trip; on any (de)serialize +/// failure the node is left untouched. +pub fn apply_role_defaults(node: &mut PenNode, role: &str, ctx: &RoleCtx) { + let Ok(mut v) = serde_json::to_value(&*node) else { + return; + }; + let Some(defaults) = role_defaults(role, &v, ctx) else { + return; + }; + let (Some(obj), Some(def_obj)) = (v.as_object_mut(), defaults.as_object()) else { + return; + }; + let mut changed = false; + for (k, val) in def_obj { + let absent = obj.get(k).map(Value::is_null).unwrap_or(true); + if absent { + obj.insert(k.clone(), val.clone()); + changed = true; + } + } + if changed { + if let Ok(new_node) = serde_json::from_value::(v) { + *node = new_node; + } + } +} + +/// The layout string a node declares (for the child context's `parentLayout`). +pub fn node_layout_string(node: &PenNode) -> Option { + let v = serde_json::to_value(node).ok()?; + v.get("layout").and_then(Value::as_str).map(str::to_string) +} + +#[cfg(test)] +#[path = "role_defaults_tests.rs"] +mod tests; diff --git a/crates/op-orchestrator/src/role_defaults_tests.rs b/crates/op-orchestrator/src/role_defaults_tests.rs new file mode 100644 index 000000000..2f8c7bc45 --- /dev/null +++ b/crates/op-orchestrator/src/role_defaults_tests.rs @@ -0,0 +1,201 @@ +use super::*; + +fn node(value: serde_json::Value) -> PenNode { + serde_json::from_value(value).expect("valid PenNode") +} + +/// Apply a role's defaults and return the resulting node as JSON for inspection. +fn applied(role: &str, ctx: &RoleCtx, value: serde_json::Value) -> Value { + let mut n = node(value); + apply_role_defaults(&mut n, role, ctx); + serde_json::to_value(&n).expect("serialize") +} + +fn light() -> RoleCtx { + RoleCtx::root(1200.0, Theme::Light) +} + +// ── theme detection ─────────────────────────────────────────────────────── + +#[test] +fn theme_from_fill_luminance() { + assert_eq!(detect_theme_from_fill(Some("#0F172A")), Theme::Dark); + assert_eq!(detect_theme_from_fill(Some("#18181B")), Theme::Dark); + assert_eq!(detect_theme_from_fill(Some("#FFFFFF")), Theme::Light); + assert_eq!(detect_theme_from_fill(Some("#F8FAFC")), Theme::Light); + // 3-digit hex. + assert_eq!(detect_theme_from_fill(Some("#000")), Theme::Dark); + // Unresolved ref / missing → default light. + assert_eq!(detect_theme_from_fill(Some("$color-bg")), Theme::Light); + assert_eq!(detect_theme_from_fill(None), Theme::Light); +} + +// ── apply_role_defaults: set-if-absent ──────────────────────────────────── + +#[test] +fn navbar_defaults_injected_light() { + let v = applied( + "navbar", + &light(), + serde_json::json!({"type":"frame","id":"n","name":"Nav","children":[]}), + ); + assert_eq!(v["layout"], serde_json::json!("horizontal")); + assert_eq!(v["height"], serde_json::json!(72.0)); // desktop + assert_eq!( + v["fill"], + serde_json::json!([{"type":"solid","color":"#FFFFFF"}]) + ); + assert_eq!(v["justifyContent"], serde_json::json!("space_between")); + assert!(v["stroke"].is_object(), "navbar gets a bottom border"); +} + +#[test] +fn navbar_defaults_dark_theme() { + let dark = RoleCtx::root(1200.0, Theme::Dark); + let v = applied( + "navbar", + &dark, + serde_json::json!({"type":"frame","id":"n","name":"Nav","children":[]}), + ); + assert_eq!( + v["fill"], + serde_json::json!([{"type":"solid","color":"#111111"}]) + ); +} + +#[test] +fn navbar_mobile_height_and_padding() { + let mobile = RoleCtx::root(390.0, Theme::Light); + let v = applied( + "navbar", + &mobile, + serde_json::json!({"type":"frame","id":"n","name":"Nav","children":[]}), + ); + assert_eq!(v["height"], serde_json::json!(56.0)); // mobile + assert_eq!(v["padding"], serde_json::json!([0.0, 16.0])); +} + +#[test] +fn ai_explicit_value_is_never_overwritten() { + // The node already has a fill + height — role defaults must NOT replace them. + let v = applied( + "navbar", + &light(), + serde_json::json!({ + "type":"frame","id":"n","name":"Nav", + "fill":[{"type":"solid","color":"#FF0000"}],"height":99,"children":[] + }), + ); + assert_eq!( + v["fill"], + serde_json::json!([{"type":"solid","color":"#FF0000"}]) + ); + assert_eq!(v["height"], serde_json::json!(99.0)); + // …but absent fields still get filled. + assert_eq!(v["justifyContent"], serde_json::json!("space_between")); +} + +#[test] +fn card_in_horizontal_parent_stretches() { + let mut ctx = light(); + ctx.parent_layout = Some("horizontal".into()); + let v = applied( + "card", + &ctx, + serde_json::json!({"type":"frame","id":"c","name":"Card","children":[]}), + ); + assert_eq!(v["width"], serde_json::json!("fill_container")); + assert_eq!(v["height"], serde_json::json!("fill_container")); + assert_eq!(v["cornerRadius"], serde_json::json!(12.0)); + assert!(v["effects"].is_array(), "card gets a shadow"); +} + +#[test] +fn card_in_vertical_parent_no_forced_width() { + let v = applied( + "card", + &light(), + serde_json::json!({"type":"frame","id":"c","name":"Card","children":[]}), + ); + // No parent horizontal → width/height not forced. + assert!(v.get("width").map(Value::is_null).unwrap_or(true)); + assert_eq!(v["cornerRadius"], serde_json::json!(12.0)); + assert_eq!(v["clipContent"], serde_json::json!(true)); +} + +#[test] +fn input_fill_and_stroke_themed() { + let v = applied( + "input", + &light(), + serde_json::json!({"type":"frame","id":"i","name":"Input","children":[]}), + ); + assert_eq!( + v["fill"], + serde_json::json!([{"type":"solid","color":"#F8FAFC"}]) + ); + assert_eq!(v["height"], serde_json::json!(48.0)); +} + +#[test] +fn section_padding_scales_with_canvas_width() { + let desktop = applied( + "section", + &light(), + serde_json::json!({"type":"frame","id":"s","name":"Section","children":[]}), + ); + assert_eq!(desktop["padding"], serde_json::json!([60.0, 80.0])); + let mobile = applied( + "section", + &RoleCtx::root(390.0, Theme::Light), + serde_json::json!({"type":"frame","id":"s","name":"Section","children":[]}), + ); + assert_eq!(mobile["padding"], serde_json::json!([40.0, 16.0])); +} + +#[test] +fn heading_is_cjk_aware() { + let ascii = applied( + "heading", + &light(), + serde_json::json!({"type":"text","id":"h","content":"Welcome"}), + ); + assert_eq!(ascii["lineHeight"], serde_json::json!(1.2)); + assert_eq!(ascii["letterSpacing"], serde_json::json!(-0.5)); + let cjk = applied( + "heading", + &light(), + serde_json::json!({"type":"text","id":"h","content":"欢迎光临"}), + ); + assert_eq!(cjk["lineHeight"], serde_json::json!(1.35)); + assert_eq!(cjk["letterSpacing"], serde_json::json!(0.0)); +} + +#[test] +fn unknown_role_injects_nothing() { + let v = applied( + "totally-unknown-role", + &light(), + serde_json::json!({"type":"frame","id":"x","name":"X","children":[]}), + ); + assert!(v.get("layout").map(Value::is_null).unwrap_or(true)); + assert!(v.get("fill").map(Value::is_null).unwrap_or(true)); +} + +#[test] +fn divider_orientation_from_name() { + let horizontal = applied( + "divider", + &light(), + serde_json::json!({"type":"frame","id":"d","name":"Divider","children":[]}), + ); + assert_eq!(horizontal["height"], serde_json::json!(1.0)); + assert_eq!(horizontal["width"], serde_json::json!("fill_container")); + let vertical = applied( + "divider", + &light(), + serde_json::json!({"type":"frame","id":"d","name":"Vertical Divider","children":[]}), + ); + assert_eq!(vertical["width"], serde_json::json!(1.0)); + assert_eq!(vertical["height"], serde_json::json!("fill_container")); +} diff --git a/crates/op-orchestrator/src/role_infer.rs b/crates/op-orchestrator/src/role_infer.rs index 4964b718b..70a59b630 100644 --- a/crates/op-orchestrator/src/role_infer.rs +++ b/crates/op-orchestrator/src/role_infer.rs @@ -19,6 +19,8 @@ use jian_ops_schema::sizing::SizingBehavior; use op_editor_core::PenNodeExt; use regex::Regex; +use crate::role_defaults::{apply_role_defaults, node_layout_string, RoleCtx, Theme}; + /// Exact (case-insensitive) name → role. Port of `NAME_EXACT_MAP`. fn exact_role(lower: &str) -> Option<&'static str> { Some(match lower { @@ -193,43 +195,72 @@ fn is_absurdly_tiny_for_card_role(node: &PenNode) -> bool { /// - a card-family role on an absurdly tiny node ("Status Dot" → stat-card) is /// dropped. fn resolve_node_role(node: &mut PenNode, parent_role: Option<&str>) { - if node.base().role.is_some() { - return; // AI-explicit role wins + // Effective role: an explicit (AI-emitted) role wins; otherwise infer one + // from the node name. + let mut role: Option = node.base().role.clone(); + if role.is_none() { + if let Some(inferred) = infer_role_from_name(node) { + // The page-chrome-in-card guard applies ONLY to INFERRED roles (TS + // resolveNodeRole runs it inside the `if (!role)` branch): a card's + // inner "Header" must not be inferred as a navbar. + let drop_page_chrome = PAGE_CHROME_ROLES.contains(&inferred) + && parent_role + .map(|p| CARD_LIKE_ROLES.contains(&p)) + .unwrap_or(false); + if !drop_page_chrome { + role = Some(inferred.to_string()); + } + } } - let Some(role) = infer_role_from_name(node) else { + + let Some(role_str) = role else { return; }; - if PAGE_CHROME_ROLES.contains(&role) - && parent_role - .map(|p| CARD_LIKE_ROLES.contains(&p)) - .unwrap_or(false) - { + + // Tiny-card guard — applies to BOTH explicit and inferred roles, matching + // the post-inference guard in TS resolveNodeRole (which `delete`s the role). + // A card-family role on a node too small to be a card (a name-inferred + // "Status Dot" → stat-card, OR an LLM-emitted `role:"card"` on a 6×6 frame) + // is STRIPPED so the I2 defaults pass injects no card padding/shadow onto a + // dot. (Codex review 2026-06-06.) + if CARD_LIKE_ROLES.contains(&role_str.as_str()) && is_absurdly_tiny_for_card_role(node) { + node.base_mut().role = None; return; } - if CARD_LIKE_ROLES.contains(&role) && is_absurdly_tiny_for_card_role(node) { - return; - } - node.base_mut().role = Some(role.to_string()); + + node.base_mut().role = Some(role_str); } -/// Walk the tree depth-first, inferring + writing roles. Passes the resolved -/// parent role down so the page-chrome-in-card guard sees it. Port of -/// `resolveTreeRoles` (I1 subset: inference only, no `applyDefaults`). -pub fn resolve_tree_roles(node: &mut PenNode, parent_role: Option<&str>) { - resolve_node_role(node, parent_role); - let this_role = node.base().role.clone(); +/// Walk the tree depth-first: infer + write the role (I1), then inject the +/// role's defaults (I2). Threads the [`RoleCtx`] down so the page-chrome-in-card +/// guard and the parent-aware / theme-aware / canvas-width-aware defaults see +/// the right context. Port of `resolveTreeRoles`. +pub fn resolve_tree_roles(node: &mut PenNode, ctx: &RoleCtx) { + resolve_node_role(node, ctx.parent_role.as_deref()); + if let Some(role) = node.base().role.clone() { + apply_role_defaults(node, &role, ctx); + } + // The defaults pass may have just set this node's layout, so read it AFTER. + let child_ctx = RoleCtx { + parent_role: node.base().role.clone(), + parent_layout: node_layout_string(node), + canvas_width: ctx.canvas_width, + theme: ctx.theme, + }; if let Some(children) = node.children_mut() { for child in children.iter_mut() { - resolve_tree_roles(child, this_role.as_deref()); + resolve_tree_roles(child, &child_ctx); } } } -/// Convenience for a forest of section roots (a sub-agent subtree). Each root's -/// parent is the page, whose role we don't track here, so `parent_role = None`. -pub fn resolve_forest_roles(nodes: &mut [PenNode]) { +/// Resolve a forest of section roots (a sub-agent subtree). Each root's parent +/// is the page, whose role we don't track here, so `parent_role = None`. +/// `canvas_width` + `theme` come from the plan's root frame. +pub fn resolve_forest_roles(nodes: &mut [PenNode], canvas_width: f64, theme: Theme) { + let ctx = RoleCtx::root(canvas_width, theme); for node in nodes.iter_mut() { - resolve_tree_roles(node, None); + resolve_tree_roles(node, &ctx); } } diff --git a/crates/op-orchestrator/src/role_infer_tests.rs b/crates/op-orchestrator/src/role_infer_tests.rs index 07a78d75a..4b29661d5 100644 --- a/crates/op-orchestrator/src/role_infer_tests.rs +++ b/crates/op-orchestrator/src/role_infer_tests.rs @@ -1,6 +1,12 @@ use super::*; +use crate::role_defaults::{RoleCtx, Theme}; use op_editor_core::PenNodeExt; +/// Root context for the tree-walk tests (desktop width, light theme). +fn ctx() -> RoleCtx { + RoleCtx::root(1200.0, Theme::Light) +} + /// Build a frame `PenNode` from a JSON spec (easiest faithful construction). fn node(value: serde_json::Value) -> PenNode { serde_json::from_value(value).expect("valid PenNode") @@ -102,14 +108,14 @@ fn explicit_role_is_never_overwritten() { let mut n = node(serde_json::json!({ "type":"frame","id":"x","name":"Header","role":"custom-thing","children":[] })); - resolve_tree_roles(&mut n, None); + resolve_tree_roles(&mut n, &ctx()); assert_eq!(role_of(&n).as_deref(), Some("custom-thing")); } #[test] fn inferred_role_is_written_back() { let mut n = frame("Navbar"); - resolve_tree_roles(&mut n, None); + resolve_tree_roles(&mut n, &ctx()); assert_eq!(role_of(&n).as_deref(), Some("navbar")); } @@ -122,7 +128,7 @@ fn page_chrome_role_stripped_inside_card_parent() { {"type":"frame","id":"hd","name":"Header","children":[]} ] })); - resolve_tree_roles(&mut card, None); + resolve_tree_roles(&mut card, &ctx()); // "Pricing Card" → `card` (the `\bcard\b` pattern wins over `pricing`). assert_eq!(role_of(&card).as_deref(), Some("card")); let inner = &card.children().unwrap()[0]; @@ -133,7 +139,7 @@ fn page_chrome_role_stripped_inside_card_parent() { fn page_chrome_role_kept_outside_card_parent() { // The same "Header" at the top level (no card parent) DOES become navbar. let mut n = frame("Header"); - resolve_tree_roles(&mut n, None); + resolve_tree_roles(&mut n, &ctx()); assert_eq!(role_of(&n).as_deref(), Some("navbar")); } @@ -143,21 +149,43 @@ fn card_role_stripped_on_tiny_node() { let mut tiny = node(serde_json::json!({ "type":"frame","id":"d","name":"Stat Dot","width":6,"height":6,"children":[] })); - resolve_tree_roles(&mut tiny, None); + resolve_tree_roles(&mut tiny, &ctx()); assert_eq!(role_of(&tiny), None); // A normally-sized stat card keeps the role. ("Stat Panel", not "Stat // Card", so the `stat` pattern wins — `\bcard\b` would override to `card`.) let mut big = node(serde_json::json!({ "type":"frame","id":"c","name":"Stat Panel","width":200,"height":120,"children":[] })); - resolve_tree_roles(&mut big, None); + resolve_tree_roles(&mut big, &ctx()); assert_eq!(role_of(&big).as_deref(), Some("stat-card")); } +#[test] +fn explicit_tiny_card_role_is_stripped() { + // An LLM-emitted role:"stat-card" on a 6×6 frame must be STRIPPED (not just + // inferred ones), so the I2 defaults pass never inflates a dot into a card. + let mut tiny = node(serde_json::json!({ + "type":"frame","id":"d","name":"Indicator","role":"stat-card", + "width":6,"height":6,"children":[] + })); + resolve_tree_roles(&mut tiny, &ctx()); + assert_eq!( + role_of(&tiny), + None, + "explicit tiny card role must be stripped" + ); + // …but an explicit non-card role on a tiny node is left alone. + let mut dot = node(serde_json::json!({ + "type":"frame","id":"x","name":"Dot","role":"badge","width":6,"height":6,"children":[] + })); + resolve_tree_roles(&mut dot, &ctx()); + assert_eq!(role_of(&dot).as_deref(), Some("badge")); +} + #[test] fn forest_helper_resolves_each_root() { let mut forest = vec![frame("Navbar"), frame("Footer")]; - resolve_forest_roles(&mut forest); + resolve_forest_roles(&mut forest, 1200.0, Theme::Light); assert_eq!(role_of(&forest[0]).as_deref(), Some("navbar")); assert_eq!(role_of(&forest[1]).as_deref(), Some("footer")); } diff --git a/crates/op-orchestrator/src/subagent.rs b/crates/op-orchestrator/src/subagent.rs index 40b6cf20f..afeb02de2 100644 --- a/crates/op-orchestrator/src/subagent.rs +++ b/crates/op-orchestrator/src/subagent.rs @@ -76,11 +76,26 @@ pub async fn run_subtask( if is_blank_container_forest(&nodes) { return fail("blank container root produced no content nodes".into()); } - // Semantic role inference (P2 I1) runs BEFORE the fallback sizing - // normalize, so the existing role-aware cleanup passes (nav-surface repair, - // section logic) see inferred roles. Honors the semantic-before-fallback - // ordering (memory feedback_post_processing_order). - crate::role_infer::resolve_forest_roles(&mut nodes); + // Semantic role inference + role-default injection (P2 I1/I2) on the parsed + // subtree, BEFORE the fallback sizing normalize (semantic-before-fallback, + // memory feedback_post_processing_order). Canvas width + theme come from the + // plan's root frame — the page background drives light/dark default colors. + let canvas_width = plan.root_frame.width; + let theme = { + let first_solid = plan + .root_frame + .fill + .as_ref() + .and_then(|fills| { + fills + .iter() + .find(|f| f.kind == "solid" || f.kind.is_empty()) + }) + .map(|f| f.color.as_str()) + .filter(|c| !c.is_empty()); + crate::role_defaults::detect_theme_from_fill(first_solid) + }; + crate::role_infer::resolve_forest_roles(&mut nodes, canvas_width, theme); normalize_section_roots_for_parent_layout(&mut nodes); let node_count = nodes.len();