feat(ai): inject role defaults (role-resolver I2)
Second increment of the role-resolver port. New `role_defaults` module ports all 43 `registerRole` rules from role-definitions/index.ts + `applyDefaults` + `detectThemeFromNode`. `resolve_tree_roles` now, after inferring a node's role (I1), fills in that role's default visual/layout properties — theme-aware (card/input/navbar/divider fills), parent-aware (cards stretch in a horizontal row), and canvas-width-aware (mobile vs desktop padding). Defaults are applied via a JSON round-trip merge that sets ONLY keys the model left unset (AI-explicit always wins), mirroring the TS `record[key] === undefined` check; jian's camelCase + flattened container/text props let the merge map straight onto the schema with no per-field hand-mapping. On any (de)serialize failure the node is left untouched, so defaults can never drop a node. Theme + canvas width are derived from the plan's root frame in run_subtask and threaded down the walk. The tiny-card guard now applies to EXPLICIT roles too (not just inferred): an LLM-emitted role:"card"/"stat-card" on a node too small to be a card is stripped before the defaults pass, so it can't inflate a 6×6 dot into a padded, shadowed card (Codex review). 29 tests (theme detection, set-if-absent, AI-explicit-wins, theme/parent/ canvas-width variants, CJK typography, divider orientation, explicit+inferred tiny-card strip, unknown role). Verified on a MiniMax-M3 run: 106 nodes, search-bars/cards received role defaults, an explicit cornerRadius preserved.
This commit is contained in:
parent
9af99ba62e
commit
ff72774385
|
|
@ -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;
|
||||
|
|
|
|||
497
crates/op-orchestrator/src/role_defaults.rs
Normal file
497
crates/op-orchestrator/src/role_defaults.rs
Normal file
|
|
@ -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<String>,
|
||||
pub parent_layout: Option<String>,
|
||||
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::<String>(),
|
||||
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<f64> {
|
||||
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<Value> {
|
||||
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::<PenNode>(v) {
|
||||
*node = new_node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layout string a node declares (for the child context's `parentLayout`).
|
||||
pub fn node_layout_string(node: &PenNode) -> Option<String> {
|
||||
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;
|
||||
201
crates/op-orchestrator/src/role_defaults_tests.rs
Normal file
201
crates/op-orchestrator/src/role_defaults_tests.rs
Normal file
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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<String> = 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue