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:
Fini 2026-06-06 22:15:08 +08:00
parent 9af99ba62e
commit ff72774385
6 changed files with 808 additions and 35 deletions

View file

@ -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;

View 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;

View 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"));
}

View file

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

View file

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

View file

@ -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();