feat(orchestrator): tree-heuristics, palette-harmonize, post-pass refinements
Port the post-streaming tree-heuristics pass to Rust (tree_heuristics.rs): - fill_card_leading_image_width: a card's leading header image spans the card width instead of leaving a white gap (160px image in a 252px card) - fix_notification_badge_overlay: a badge authored as a flex sibling of an icon becomes a corner-overlay circle, not a square beside the icon - active nav-tab pill, card image corner clip, invisible text-band fill, stacked-overlay-to-absolute, redundant colored wrapper strip Add palette_harmonize.rs: retint the cool-slate neutral ramp toward the page's temperature so a warm design no longer reads as off-palette gray. Plus role-resolver / cleanup / scaffold / run refinements accumulated from the glm-opus design-parity campaign.
This commit is contained in:
parent
c4a9d8b3c6
commit
169e6abed2
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -3253,6 +3253,8 @@ dependencies = [
|
|||
"serde_json",
|
||||
"skia-safe",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"zip",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ serde_stacker = "0.1"
|
|||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# WASM-boundary related (per kickoff §1.2 blacklist = must not appear here).
|
||||
# Intentionally NOT exposing tokio / reqwest / agent / pen-agent-cli /
|
||||
|
|
|
|||
|
|
@ -456,9 +456,15 @@ fn nav_surface_target(child: &PenNode) -> Option<&PenNode> {
|
|||
|
||||
fn is_nav_surface(node: &PenNode) -> bool {
|
||||
let role = node.base().role.as_deref().unwrap_or("").to_lowercase();
|
||||
// Matches tree_heuristics::NAV_ROLES exactly. The TOP header roles
|
||||
// (`navbar` / `top-nav-bar` / `top-app-bar`) are deliberately EXCLUDED: on a
|
||||
// light mobile page the header is transparent (TS references), and re-filling
|
||||
// it with the root surface hex + a drop-shadow is exactly what re-boxed the
|
||||
// mobile header the user flagged. Only bottom navs / floating tab bars — which
|
||||
// float over scrolling content — need a surface to read against the page.
|
||||
if matches!(
|
||||
role.as_str(),
|
||||
"navbar" | "nav" | "tab-bar" | "bottom-tab-bar" | "top-nav-bar" | "top-app-bar" | "tab-row"
|
||||
"nav" | "tab-bar" | "bottom-tab-bar" | "tab-row"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -474,9 +480,6 @@ fn is_nav_surface(node: &PenNode) -> bool {
|
|||
|| hay.contains("tab-bar")
|
||||
|| hay.contains("bottom tab")
|
||||
|| hay.contains("bottom-tab")
|
||||
|| hay.contains("navbar")
|
||||
|| hay.contains("nav bar")
|
||||
|| hay.contains("navigation bar")
|
||||
}
|
||||
|
||||
fn nav_surface_repair(nav: &PenNode, root_surface_hex: Option<&str>) -> Option<NavSurfaceRepair> {
|
||||
|
|
|
|||
|
|
@ -300,6 +300,67 @@ fn cleanup_injects_missing_bottom_nav_surface_on_light_mobile_root() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_leaves_top_navbar_transparent_on_light_mobile_root() {
|
||||
// The top header is transparent on mobile (TS references). A previous
|
||||
// version of `is_nav_surface` matched `role:"navbar"`, so this pass re-filled
|
||||
// the header with the root surface hex + a downward shadow — the "mysterious
|
||||
// background + rounded border" the user flagged. The bottom nav still gets a
|
||||
// surface; the top header must be left untouched.
|
||||
let mut sink = VecDocSink::new();
|
||||
let tree: PenNode = serde_json::from_value(json!({
|
||||
"type": "frame",
|
||||
"id": "root",
|
||||
"name": "Brooklyn Food Delivery",
|
||||
"x": 80, "y": 40, "width": 390, "height": 844,
|
||||
"layout": "vertical",
|
||||
"fill": [{ "type": "solid", "color": "#FFF8F0" }],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame", "id": "header", "name": "Header", "role": "navbar",
|
||||
"width": "fill_container", "height": 56, "children": []
|
||||
},
|
||||
{
|
||||
"type": "frame", "id": "content", "name": "Content",
|
||||
"width": "fill_container", "height": 704, "children": []
|
||||
},
|
||||
{
|
||||
"type": "frame", "id": "bottom-nav", "name": "Bottom Navigation",
|
||||
"role": "bottom-tab-bar", "width": "fill_container", "height": 84, "children": []
|
||||
}
|
||||
]
|
||||
}))
|
||||
.expect("mobile root json");
|
||||
sink.state.apply(EditorCommand::InsertSubtree {
|
||||
nodes: vec![tree],
|
||||
parent_id: NodeId::NONE,
|
||||
page_id: None,
|
||||
});
|
||||
let root_id = sink.state.active_children()[0].id_str().to_string();
|
||||
sink.applied.clear();
|
||||
|
||||
run_cleanup_passes(&mut sink, &plan(), &[&root_id]);
|
||||
|
||||
// The bottom nav is still repaired → a downward (offsetY = -4) shadow exists.
|
||||
assert!(
|
||||
sink.applied.iter().any(|c| matches!(
|
||||
c,
|
||||
EditorCommand::SetEffectParam { field: EffectField::OffsetY, value, .. }
|
||||
if (*value - -4.0).abs() < f32::EPSILON
|
||||
)),
|
||||
"bottom nav should still receive its upward-pointing shadow"
|
||||
);
|
||||
// The top header is NOT repaired → no downward (offsetY = +4) header shadow.
|
||||
assert!(
|
||||
!sink.applied.iter().any(|c| matches!(
|
||||
c,
|
||||
EditorCommand::SetEffectParam { field: EffectField::OffsetY, value, .. }
|
||||
if (*value - 4.0).abs() < f32::EPSILON
|
||||
)),
|
||||
"top navbar must stay transparent — no surface shadow re-boxing it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_recolors_white_bottom_nav_to_tinted_mobile_root_surface() {
|
||||
let mut sink = VecDocSink::new();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ pub mod intent;
|
|||
pub mod manifest;
|
||||
pub mod manifest_hints;
|
||||
pub mod model_profile;
|
||||
pub mod palette_harmonize;
|
||||
pub mod parse;
|
||||
pub mod plan;
|
||||
pub mod plan_normalize;
|
||||
|
|
@ -51,6 +52,7 @@ pub mod run_dashboard;
|
|||
pub mod scaffold;
|
||||
pub mod scaffold_dashboard;
|
||||
pub mod subagent;
|
||||
pub mod tree_heuristics;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
|
|
|||
|
|
@ -44,7 +44,14 @@ static NEXT_SECTION_ID: AtomicU64 = AtomicU64::new(1);
|
|||
/// —— minimax-m3 92% / ark-code 92% / glm-5.1 98% / deepseek 83%。
|
||||
/// 强模型(Claude / GPT / Gemini 等)无基准数据不翻:目录是弱模型的
|
||||
/// 地板,不做强模型的天花板。kimi 同样无数据,待跑分后再进表。
|
||||
const MANIFEST_DEFAULT_FAMILIES: &[&str] = &["minimax", "glm", "deepseek", "ark-code"];
|
||||
///
|
||||
/// glm 家族精确到 `glm-5.1`(不是泛 `glm`):glm-5.2(2026-06-18 定为 Full tier
|
||||
/// 强模型)的 manifest 输出在**完整页面**上反复崩 —— 深 section 嵌套超 depth-2
|
||||
/// 被 clamp、拿 element 当容器被扁平成兄弟 → 全堆顶层重叠(ab-v9 组件级 M3 96%
|
||||
/// 不代表整页布局)。强模型本就该走裸 PenNode JSONL(parse_nodes),它擅长嵌套
|
||||
/// 树(op-smoke 实测 restaurant 卡片嵌套正确)。`glm-5.2` 子串不命中 → 裸 JSONL;
|
||||
/// `glm-5.1` 仍走 manifest 脚手架。
|
||||
const MANIFEST_DEFAULT_FAMILIES: &[&str] = &["minimax", "glm-5.1", "deepseek", "ark-code"];
|
||||
|
||||
/// M2 起的清单协议开关,M4 升级为按模型路由:`OPENPENCIL_MANIFEST`
|
||||
/// 保留为双向 override —— `1/true/on` 强制开(op-smoke 基准的
|
||||
|
|
@ -234,6 +241,37 @@ fn build_element_entry(
|
|||
raw: false,
|
||||
}
|
||||
}
|
||||
// Unknown kind = the model invented a semantic name not in the
|
||||
// catalog. Most often it's a *container* (`card` / `container` /
|
||||
// `box` / `wrapper` / `list` …) the model groups children into via
|
||||
// `in:`. Dropping it loses the whole subtree — children then dangle
|
||||
// and flatten to siblings (the横排 overlap we saw with glm-5.2's
|
||||
// `card`). Coerce to a raw frame instead: layout/style attrs
|
||||
// (cornerRadius / fill / layout / gap / padding) carry over, it
|
||||
// counts as a container (raw Frame, see resolve_in_ref), and
|
||||
// children nest correctly. A *known* kind that fails on args
|
||||
// (BuildFailed) still drops — that's a real builder rejection.
|
||||
Err(op_mcp::element_manifest::ElementBuildError::UnknownKind(_)) => {
|
||||
warnings.push(format!(
|
||||
"W-COERCED-FRAME line {line_no}: unknown element kind {kind:?} → raw frame container"
|
||||
));
|
||||
object.remove("el");
|
||||
object.insert("type".to_string(), Value::String("frame".to_string()));
|
||||
// Frame needs concrete sizing/layout; the model usually gives a
|
||||
// card cornerRadius/fill but not width/height — fill flex-section
|
||||
// defaults so `from_value::<PenNode>` succeeds and the frame lays
|
||||
// out as a normal vertical container.
|
||||
object
|
||||
.entry("width")
|
||||
.or_insert_with(|| Value::String("fill_container".to_string()));
|
||||
object
|
||||
.entry("height")
|
||||
.or_insert_with(|| Value::String("fit_content".to_string()));
|
||||
object
|
||||
.entry("layout")
|
||||
.or_insert_with(|| Value::String("vertical".to_string()));
|
||||
build_raw_entry(line_no, object, entries, warnings)
|
||||
}
|
||||
Err(err) => {
|
||||
warnings.push(format!("W-DROPPED line {line_no} ({kind}): {err}"));
|
||||
Entry::Dropped
|
||||
|
|
@ -599,6 +637,51 @@ Here is the design:
|
|||
.any(|w| w.contains("W-IN-NOT-CONTAINER")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kind_used_as_container_coerces_to_frame() {
|
||||
// glm-5.2 invents `card` (not in the catalog) as a container and
|
||||
// nests children into it via `in:`. Dropping it would dangle the
|
||||
// children → they flatten to siblings → horizontal overflow/overlap
|
||||
// (the崩 layout reported 2026-06-18). It must coerce to a raw frame
|
||||
// that holds the children, keeping the card's cornerRadius/fill.
|
||||
let text = r##"
|
||||
{"el":"section","direction":"vertical","role":"popular"}
|
||||
{"el":"card","in":1,"cornerRadius":16,"fill":[{"type":"solid","color":"#FFFFFF"}]}
|
||||
{"el":"stat_card","in":2,"label":"MRR","value":"$48k"}
|
||||
"##;
|
||||
let outcome = parse_manifest(text).expect("manifest");
|
||||
assert_eq!(outcome.nodes.len(), 1, "one section root");
|
||||
assert!(
|
||||
outcome
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("W-COERCED-FRAME")),
|
||||
"expected a coerce warning, got {:?}",
|
||||
outcome.warnings
|
||||
);
|
||||
assert!(
|
||||
!outcome.warnings.iter().any(|w| w.contains("W-DROPPED")),
|
||||
"card must NOT be dropped: {:?}",
|
||||
outcome.warnings
|
||||
);
|
||||
// section > card(frame) > stat_card — nested, NOT flattened to siblings.
|
||||
let section_kids = frame_children(&outcome.nodes[0]);
|
||||
assert_eq!(
|
||||
section_kids.len(),
|
||||
1,
|
||||
"section holds the coerced card frame"
|
||||
);
|
||||
assert!(
|
||||
matches!(§ion_kids[0], PenNode::Frame(_)),
|
||||
"card coerced to a frame container"
|
||||
);
|
||||
assert_eq!(
|
||||
frame_children(§ion_kids[0]).len(),
|
||||
1,
|
||||
"stat_card nested inside the card, not flattened to a sibling"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forbidden_id_fields_are_stripped_with_warning() {
|
||||
let text = r#"{"el":"badge","label":"New","id":"my-id","parent_id":"root"}"#;
|
||||
|
|
@ -718,29 +801,52 @@ Here is the design:
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn bad_element_line_is_dropped_without_killing_the_batch() {
|
||||
fn unknown_element_line_coerces_to_frame_without_killing_the_batch() {
|
||||
// An invented kind no longer drops — dropping loses container
|
||||
// subtrees (the glm-5.2 `card` regression). It coerces to a frame;
|
||||
// the rest of the batch is unaffected.
|
||||
let text = r#"
|
||||
{"el":"nonexistent_widget","label":"x"}
|
||||
{"el":"badge","label":"Live"}
|
||||
"#;
|
||||
let outcome = parse_manifest(text).expect("manifest");
|
||||
assert_eq!(outcome.dropped_lines, 1);
|
||||
assert_eq!(outcome.element_lines, 1);
|
||||
assert_eq!(outcome.nodes.len(), 1);
|
||||
assert!(outcome.warnings.iter().any(|w| w.contains("W-DROPPED")));
|
||||
assert_eq!(
|
||||
outcome.dropped_lines, 0,
|
||||
"unknown coerces now, no longer drops"
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.element_lines, 1,
|
||||
"badge is the only catalog element"
|
||||
);
|
||||
assert_eq!(outcome.nodes.len(), 2, "coerced frame + badge");
|
||||
assert!(outcome
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| w.contains("W-COERCED-FRAME")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_numbers_count_dropped_lines() {
|
||||
// 行 1 失败被丢,但行号仍占住:行 3 的 `in:2` 指向行 2 的 section。
|
||||
fn line_numbers_survive_a_coerced_line() {
|
||||
// 行 1 是未知 kind(coerce 成 frame、占住行号);行 3 的 `in:2` 仍
|
||||
// 正确指向行 2 的 section,badge 进 section 而非错位到顶层。
|
||||
let text = r#"
|
||||
{"el":"nonexistent_widget"}
|
||||
{"el":"section","role":"body"}
|
||||
{"el":"badge","in":2,"label":"Live"}
|
||||
"#;
|
||||
let outcome = parse_manifest(text).expect("manifest");
|
||||
assert_eq!(outcome.nodes.len(), 1);
|
||||
assert_eq!(frame_children(&outcome.nodes[0]).len(), 1);
|
||||
assert_eq!(
|
||||
outcome.nodes.len(),
|
||||
2,
|
||||
"coerced frame (line 1) + section (line 2)"
|
||||
);
|
||||
// The section is the 2nd root and still holds the badge — proving
|
||||
// the coerced line 1 占住了行号 so `in:2` resolved to the section.
|
||||
assert_eq!(
|
||||
frame_children(&outcome.nodes[1]).len(),
|
||||
1,
|
||||
"badge nested in the section"
|
||||
);
|
||||
}
|
||||
|
||||
// ── M4 按模型路由 ──────────────────────────────────────────────────
|
||||
|
|
@ -777,10 +883,17 @@ Here is the design:
|
|||
"gpt-4o",
|
||||
"gemini-2.5-pro",
|
||||
"kimi-k2.6", // 无基准数据,刻意不进表
|
||||
"glm-5.2", // Full-tier 强模型:manifest 脚手架限制其嵌套→整页崩,走裸 JSONL
|
||||
"", // chat 路径没有模型信息 → 裸 JSONL
|
||||
] {
|
||||
assert!(!manifest_enabled_for_model(id), "{id:?} should route OFF");
|
||||
}
|
||||
// glm-5.1 (the benchmarked weak model) still routes ON — the family
|
||||
// is precise (`glm-5.1`), so 5.2 falls through but 5.1 doesn't.
|
||||
assert!(
|
||||
manifest_enabled_for_model("glm-5.1"),
|
||||
"glm-5.1 keeps the scaffold"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -125,6 +125,19 @@ const MODEL_PROFILES: &[Entry] = &[
|
|||
timeout_multiplier: 2.0,
|
||||
label: "DeepSeek V4 Pro",
|
||||
},
|
||||
// GLM-5.2(及以后更高版本)—— ab-v9 manifest arm 实测很强(M3 96%、composite
|
||||
// 5/5),给 Full tier。**只 5.2 起算强**(用户判定:glm-5 / glm-5.1 不够,不命中
|
||||
// 这条 → 落 default Standard)。关思考与 tier 无关:builtin HTTP 路由按
|
||||
// is_glm_model 对**所有** glm(含 5 / 5.1)强制下发 thinking:{type:disabled},
|
||||
// 因为它们都是推理模型、不关都会烧光 content。GLM 体量大、即便关思考也偏慢,
|
||||
// timeout ×2。未来 glm-5.3 / glm-6 等更高版本同样算强,届时各加一条 matcher。
|
||||
Entry {
|
||||
matcher: Match::Sub("glm-5.2"),
|
||||
tier: ModelTier::Full,
|
||||
thinking_disabled: true,
|
||||
timeout_multiplier: 2.0,
|
||||
label: "GLM-5.2 (Coding Plan)",
|
||||
},
|
||||
e(
|
||||
Match::Sub("deepseek-v4-flash"),
|
||||
ModelTier::Standard,
|
||||
|
|
|
|||
173
crates/op-orchestrator/src/palette_harmonize.rs
Normal file
173
crates/op-orchestrator/src/palette_harmonize.rs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
//! Palette hue harmonization.
|
||||
//!
|
||||
//! The semantic palette ([`crate::semantic_palette`]) ships a FIXED cool-slate
|
||||
//! neutral ramp (`color-surface-2` = `#F1F5F9`, `color-bg-deep` = `#F8FAFC`,
|
||||
//! borders in `#E2E8F0`/`#CBD5E1` …). Those blue-tinted grays clash badly on a
|
||||
//! WARM design — a cream/orange food-delivery app gets a cool-gray search bar,
|
||||
//! cool-gray category chips and a cool-gray avatar circle that read as "not part
|
||||
//! of the palette" (the user flagged exactly this).
|
||||
//!
|
||||
//! The TS design-system generator tints its neutrals toward the brand hue; the
|
||||
//! Rust port used the raw slate table. This module closes that gap: given the
|
||||
//! page background color, it re-tints the neutral tokens to share the page's hue
|
||||
//! while preserving each token's own lightness and chroma magnitude (so the ramp
|
||||
//! stays as subtle as before — only its temperature changes). A neutral page
|
||||
//! (white / true gray) has no hue to harmonize toward, so the slate ramp is left
|
||||
//! untouched; a cool design therefore keeps its cool grays.
|
||||
|
||||
use jian_ops_schema::variable::{VariableDefinition, VariableScalar, VariableValue};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Neutral surface + border tokens that should follow the design temperature.
|
||||
/// `color-surface` (`#FFFFFF`) is intentionally omitted — pure white reads clean
|
||||
/// on any palette, and it has zero chroma to rotate anyway.
|
||||
const NEUTRAL_TINT_TOKENS: &[&str] = &[
|
||||
"color-surface-2",
|
||||
"color-surface-3",
|
||||
"color-bg-deep",
|
||||
"color-border",
|
||||
"color-border-strong",
|
||||
];
|
||||
|
||||
fn parse_rgb(hex: &str) -> Option<(f64, f64, f64)> {
|
||||
let h = hex.strip_prefix('#').unwrap_or(hex);
|
||||
if !h.is_ascii() || h.len() < 6 {
|
||||
return None;
|
||||
}
|
||||
let r = u8::from_str_radix(&h[0..2], 16).ok()? as f64;
|
||||
let g = u8::from_str_radix(&h[2..4], 16).ok()? as f64;
|
||||
let b = u8::from_str_radix(&h[4..6], 16).ok()? as f64;
|
||||
Some((r, g, b))
|
||||
}
|
||||
|
||||
/// The page background's warm/cool direction — each channel's deviation from the
|
||||
/// color's own gray (mean), normalized so the strongest channel is ±1. `None`
|
||||
/// when the bg is effectively neutral (nothing to harmonize toward).
|
||||
fn page_hue_direction(page_bg_hex: &str) -> Option<[f64; 3]> {
|
||||
let (r, g, b) = parse_rgb(page_bg_hex)?;
|
||||
let mean = (r + g + b) / 3.0;
|
||||
let dev = [r - mean, g - mean, b - mean];
|
||||
let max_abs = dev.iter().fold(0.0_f64, |m, &d| m.max(d.abs()));
|
||||
// A delta below ~3/255 is a true gray/white — keep the slate ramp.
|
||||
if max_abs < 3.0 {
|
||||
return None;
|
||||
}
|
||||
Some([dev[0] / max_abs, dev[1] / max_abs, dev[2] / max_abs])
|
||||
}
|
||||
|
||||
/// Re-tint a neutral hex to the page hue `dir`, preserving its lightness (mean)
|
||||
/// and chroma magnitude (max−min). A pure gray (chroma 0) returns unchanged.
|
||||
/// Non-6-digit values (e.g. an `#RRGGBBAA` scrim) are returned untouched.
|
||||
fn retint_neutral(hex: &str, dir: [f64; 3]) -> String {
|
||||
let Some((r, g, b)) = parse_rgb(hex) else {
|
||||
return hex.to_string();
|
||||
};
|
||||
let mean = (r + g + b) / 3.0;
|
||||
let chroma = r.max(g).max(b) - r.min(g).min(b);
|
||||
let half = chroma / 2.0;
|
||||
let ch = |i: usize| (mean + dir[i] * half).round().clamp(0.0, 255.0) as u8;
|
||||
format!("#{:02X}{:02X}{:02X}", ch(0), ch(1), ch(2))
|
||||
}
|
||||
|
||||
fn retint_definition(def: &mut VariableDefinition, dir: [f64; 3]) {
|
||||
match &mut def.value {
|
||||
VariableValue::Themed(vals) => {
|
||||
for tv in vals.iter_mut() {
|
||||
if let VariableScalar::Str(s) = &mut tv.value {
|
||||
*s = retint_neutral(s, dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
VariableValue::Scalar(VariableScalar::Str(s)) => {
|
||||
*s = retint_neutral(s, dir);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-tint the neutral surface + border tokens of `palette` to share
|
||||
/// `page_bg_hex`'s hue. No-op when the page background is neutral.
|
||||
pub fn harmonize_palette_neutrals(
|
||||
palette: &mut BTreeMap<String, VariableDefinition>,
|
||||
page_bg_hex: &str,
|
||||
) {
|
||||
let Some(dir) = page_hue_direction(page_bg_hex) else {
|
||||
return;
|
||||
};
|
||||
for name in NEUTRAL_TINT_TOKENS {
|
||||
if let Some(def) = palette.get_mut(*name) {
|
||||
retint_definition(def, dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn light_hex(def: &VariableDefinition) -> String {
|
||||
match &def.value {
|
||||
VariableValue::Themed(vals) => match &vals[0].value {
|
||||
VariableScalar::Str(s) => s.clone(),
|
||||
_ => unreachable!(),
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn warm(r: u8, _g: u8, b: u8) -> bool {
|
||||
// Warm = red channel above blue channel.
|
||||
r > b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_page_warms_the_cool_neutrals() {
|
||||
let mut palette = crate::semantic_palette::palette_variables();
|
||||
// Cream warm page bg.
|
||||
harmonize_palette_neutrals(&mut palette, "#FFF8F0");
|
||||
// surface-2 was #F1F5F9 (cool: B>R) → now warm (R>B).
|
||||
let s2 = light_hex(palette.get("color-surface-2").unwrap());
|
||||
let (r, g, b) = parse_rgb(&s2).unwrap();
|
||||
assert!(
|
||||
warm(r as u8, g as u8, b as u8),
|
||||
"surface-2 should be warm after harmonizing to a cream page, got {s2}"
|
||||
);
|
||||
// Lightness (mean) preserved within a couple points of the original 245.
|
||||
let mean = (r + g + b) / 3.0;
|
||||
assert!(
|
||||
(mean - 245.0).abs() <= 2.0,
|
||||
"lightness preserved (~245), got mean {mean}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_page_leaves_slate_untouched() {
|
||||
let mut palette = crate::semantic_palette::palette_variables();
|
||||
let before = light_hex(palette.get("color-surface-2").unwrap());
|
||||
// A true-gray page → nothing to harmonize toward.
|
||||
harmonize_palette_neutrals(&mut palette, "#FAFAFA");
|
||||
let after = light_hex(palette.get("color-surface-2").unwrap());
|
||||
assert_eq!(before, after, "neutral page keeps the cool slate ramp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cool_page_keeps_cool_neutrals() {
|
||||
let mut palette = crate::semantic_palette::palette_variables();
|
||||
// A cool blue-gray page → neutrals stay cool (B≥R).
|
||||
harmonize_palette_neutrals(&mut palette, "#EEF2FF");
|
||||
let s2 = light_hex(palette.get("color-surface-2").unwrap());
|
||||
let (r, _g, b) = parse_rgb(&s2).unwrap();
|
||||
assert!(
|
||||
b as u8 >= r as u8,
|
||||
"cool page keeps cool neutrals, got {s2}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_surface_token_is_not_in_the_tint_set() {
|
||||
// color-surface (#FFFFFF) must stay pure white.
|
||||
let mut palette = crate::semantic_palette::palette_variables();
|
||||
harmonize_palette_neutrals(&mut palette, "#FFF8F0");
|
||||
assert_eq!(light_hex(palette.get("color-surface").unwrap()), "#FFFFFF");
|
||||
}
|
||||
}
|
||||
|
|
@ -224,13 +224,23 @@ fn role_defaults(role: &str, node_json: &Value, ctx: &RoleCtx) -> Option<Value>
|
|||
}
|
||||
|
||||
// ── 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)
|
||||
}),
|
||||
"navbar" => {
|
||||
let mut m = 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"
|
||||
});
|
||||
// Mobile app top headers sit transparent on the page background (the
|
||||
// TS references — "Hey, Amanda" / "Good Morning, Daniel" — have no
|
||||
// surface fill or hairline). Only a DESKTOP top-nav gets the white
|
||||
// bar + bottom border; on mobile a fill+border boxes the header.
|
||||
if !mobile {
|
||||
m["fill"] = navbar_fill(theme);
|
||||
m["stroke"] = navbar_bottom_border(theme);
|
||||
}
|
||||
m
|
||||
}
|
||||
"bottom-tab-bar" => json!({
|
||||
"layout": "horizontal", "width": "fill_container",
|
||||
"height": if mobile { 68 } else { 72 },
|
||||
|
|
|
|||
|
|
@ -102,6 +102,15 @@ static NAME_PATTERN_MAP: LazyLock<Vec<(Regex, &'static str, bool)>> = LazyLock::
|
|||
(Regex::new(r"\bform\b").unwrap(), "form-group", false),
|
||||
(Regex::new(r"\bsearch").unwrap(), "search-bar", false),
|
||||
(Regex::new(r"\bnav\s*link").unwrap(), "nav-link", false),
|
||||
// Bottom tab bars: "Bottom Navigation Bar", "Bottom Tab Bar", "Tab
|
||||
// Bar". Must precede the generic `nav` exact-map so it wins the
|
||||
// bottom-specific role (drives the upward lift shadow + nav-surface
|
||||
// inject). skip_containers=false: the trailing "Bar" IS the role here.
|
||||
(
|
||||
Regex::new(r"bottom\s*(tab|nav)|\btab\s*bar\b").unwrap(),
|
||||
"bottom-tab-bar",
|
||||
false,
|
||||
),
|
||||
(Regex::new(r"\bstat").unwrap(), "stat-card", true),
|
||||
(Regex::new(r"\bpricing").unwrap(), "pricing-card", true),
|
||||
(
|
||||
|
|
@ -242,6 +251,34 @@ fn resolve_node_role(node: &mut PenNode, parent_role: Option<&str>) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Section-wrapper guard (BOTH explicit + inferred roles, like the tiny-card
|
||||
// guard above). A card-like / search-bar / input role on a TOP-LEVEL section
|
||||
// (`parent_role` None) that is actually a multi-block layout WRAPPER is a
|
||||
// false positive from loose name matching: "Categories & Featured Banner"
|
||||
// hits `\bfeature` → feature-card, "Location & Search" hits `\bsearch` →
|
||||
// search-bar — but both are section wrappers, not a single card / search
|
||||
// field. Left in place, `apply_role_defaults` injects a card fill + border +
|
||||
// radius that boxes the whole section (the "mysterious background" the user
|
||||
// flagged). Heuristic: a real card groups LEAF content (icon/title/text); a
|
||||
// real search bar holds an icon + the input, not sub-frames. So ≥2 sub-frames
|
||||
// (card) or ≥1 sub-frame (search/input) means it's a wrapper → drop the role.
|
||||
if parent_role.is_none() {
|
||||
let sub_frames = node
|
||||
.children()
|
||||
.map(|c| {
|
||||
c.iter()
|
||||
.filter(|n| matches!(n, PenNode::Frame(_) | PenNode::Group(_)))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let card_like = CARD_LIKE_ROLES.contains(&role_str.as_str());
|
||||
let input_like = role_str == "search-bar" || role_str == "input";
|
||||
if (card_like && sub_frames >= 2) || (input_like && sub_frames >= 1) {
|
||||
node.base_mut().role = None;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
node.base_mut().role = Some(role_str);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -163,6 +163,74 @@ fn card_role_stripped_on_tiny_node() {
|
|||
assert_eq!(role_of(&big).as_deref(), Some("stat-card"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_wrapper_feature_card_role_stripped() {
|
||||
// tt5: "Categories & Featured Banner" hits `\bfeature` → feature-card, but
|
||||
// it's a top-level section WRAPPER holding 2 sub-frames (categories +
|
||||
// banner). Must drop the role so no card fill/border/radius boxes the
|
||||
// section (the "mysterious background" the user flagged).
|
||||
let mut sec = node(serde_json::json!({
|
||||
"type":"frame","id":"sec","name":"Categories & Featured Banner","children":[
|
||||
{"type":"frame","id":"chips","name":"Category Chips","children":[]},
|
||||
{"type":"frame","id":"banner","name":"Promo Banner","children":[]}
|
||||
]
|
||||
}));
|
||||
resolve_tree_roles(&mut sec, &ctx());
|
||||
assert_eq!(
|
||||
role_of(&sec),
|
||||
None,
|
||||
"multi-block section wrapper must not be a feature-card"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_wrapper_search_bar_role_stripped() {
|
||||
// tt5: "Location & Search" hits `\bsearch` → search-bar, but it's a header
|
||||
// wrapper (a Location sub-frame + the real search input). Drop the role so
|
||||
// the bg-deep fill + border + radius don't box the whole header.
|
||||
let mut sec = node(serde_json::json!({
|
||||
"type":"frame","id":"sec","name":"Location & Search","children":[
|
||||
{"type":"frame","id":"loc","name":"Location & Actions","children":[]},
|
||||
{"type":"text_input","id":"si","placeholder":"Search for food"}
|
||||
]
|
||||
}));
|
||||
resolve_tree_roles(&mut sec, &ctx());
|
||||
assert_eq!(
|
||||
role_of(&sec),
|
||||
None,
|
||||
"header wrapper must not be a search-bar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_feature_card_with_leaf_content_keeps_role() {
|
||||
// Negative control: a genuine feature-card groups LEAF content (icon/title/
|
||||
// text), not sub-frames — the guard must leave its role + card fill intact.
|
||||
let mut card = node(serde_json::json!({
|
||||
"type":"frame","id":"fc","name":"Feature Highlight","children":[
|
||||
{"type":"icon_font","id":"ic","iconFontName":"zap"},
|
||||
{"type":"text","id":"t","name":"Title","content":"Fast"},
|
||||
{"type":"text","id":"d","name":"Desc","content":"Delivered hot"}
|
||||
]
|
||||
}));
|
||||
resolve_tree_roles(&mut card, &ctx());
|
||||
assert_eq!(role_of(&card).as_deref(), Some("feature-card"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_search_bar_with_icon_and_input_keeps_role() {
|
||||
// Negative control: a real search bar holds an icon + input (no sub-frame) —
|
||||
// keep search-bar so it still gets its pill fill.
|
||||
let mut bar = node(serde_json::json!({
|
||||
"type":"frame","id":"sb","name":"Search","children":[
|
||||
{"type":"icon_font","id":"m","iconFontName":"search"},
|
||||
{"type":"text_input","id":"si","placeholder":"Search"}
|
||||
]
|
||||
}));
|
||||
resolve_tree_roles(&mut bar, &ctx());
|
||||
assert_eq!(role_of(&bar).as_deref(), Some("search-bar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_tiny_card_role_is_stripped() {
|
||||
// An LLM-emitted role:"stat-card" on a 6×6 frame must be STRIPPED (not just
|
||||
|
|
|
|||
|
|
@ -84,7 +84,16 @@ pub(crate) fn fix_horizontal_overflow(node: &mut Value, canvas_width: f64) {
|
|||
if needed_w > parent_w && needed_w <= canvas_width {
|
||||
node["width"] = json!(needed_w);
|
||||
} else if needed_w > canvas_width * 0.8 {
|
||||
// Content exceeds the viewport — widening can't make the children fit
|
||||
// (their sum already overflows the canvas). Span the viewport and clip
|
||||
// the overflow at the row edge so it reads as a scroll row cut at the
|
||||
// screen, instead of chips spilling off-canvas into the void.
|
||||
// overflow.md mandates a `clipContent` wrapper for scroll rows; weak
|
||||
// models (e.g. glm-5.2) routinely emit a bare horizontal frame without
|
||||
// it, so this is the deterministic floor that keeps off-screen children
|
||||
// from rendering outside the device frame.
|
||||
node["width"] = json!("fill_container");
|
||||
node["clipContent"] = json!(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -376,6 +376,145 @@ fn is_ring_like_decorative(node: &Value) -> bool {
|
|||
corner_radius(node) >= w.min(h) * 0.35
|
||||
}
|
||||
|
||||
/// layout.md:24 — interior section wrappers (Header / Search Section /
|
||||
/// Categories Section …) MUST be transparent; an explicit near-white fill
|
||||
/// makes an unwanted "white card" on the page bg. The model (esp. glm-5.2)
|
||||
/// ignores that rule, and `role_defaults`' AI-explicit-wins won't strip it —
|
||||
/// so force it here: a structural wrapper carrying a near-white / page-tone
|
||||
/// solid fill gets cleared to transparent. CARD_LIKE roles (card, promo
|
||||
/// banner, …) are intentional surfaces and keep their fill; an intentional
|
||||
/// dark / saturated section background is also left alone (only near-white
|
||||
/// fills — luminance ≥ 0.85 — read as the "white card on cream" smell).
|
||||
/// Neutral page/surface design-variable tokens. A structural wrapper carrying
|
||||
/// any of these is repainting the page background and must go transparent.
|
||||
/// Variable refs are NOT resolved at post-pass time (binding runs afterwards),
|
||||
/// so `hex_luminance` can't read them — match the token names directly. Colored
|
||||
/// tokens (`$color-accent`, dark bands, etc.) are deliberate → never listed here.
|
||||
const LIGHT_SURFACE_REFS: &[&str] = &[
|
||||
"$color-bg-deep",
|
||||
"$color-surface",
|
||||
"$color-surface-2",
|
||||
"$color-surface-3",
|
||||
];
|
||||
|
||||
/// True when a fill color reads as a light page/surface tone — either a parsed
|
||||
/// hex with luminance ≥ 0.85, or one of the neutral surface variable refs that
|
||||
/// glm emits before variable binding resolves them.
|
||||
fn is_light_surface_fill(color: &str) -> bool {
|
||||
LIGHT_SURFACE_REFS.contains(&color) || hex_luminance(color).map(|l| l >= 0.85).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// A child paints a colored (non-page-tone) surface: a gradient, or a solid
|
||||
/// that is NOT a light page/surface tone. The signal that a child is a real
|
||||
/// banner/card surface rather than a neutral panel.
|
||||
fn child_has_colored_fill(node: &Value) -> bool {
|
||||
let Some(first) = fill_array(node).and_then(|a| a.first()) else {
|
||||
return false;
|
||||
};
|
||||
match first.get("type").and_then(Value::as_str) {
|
||||
Some("linear_gradient") | Some("radial_gradient") => true,
|
||||
Some("solid") => first
|
||||
.get("color")
|
||||
.and_then(Value::as_str)
|
||||
.map(|c| !is_light_surface_fill(c))
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the node has EXACTLY ONE container child and that child spans the
|
||||
/// width (`fill_container`) painting a colored surface. The node is then a
|
||||
/// redundant frame wrapped around a full-bleed banner/card — its own light
|
||||
/// surface fill is a box showing around the colored child. (Tiny leaf siblings
|
||||
/// like an orphan badge are ignored for the count.)
|
||||
fn is_redundant_colored_wrapper(node: &Value) -> bool {
|
||||
let Some(kids) = node.get("children").and_then(Value::as_array) else {
|
||||
return false;
|
||||
};
|
||||
let containers: Vec<&Value> = kids
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
matches!(
|
||||
c.get("type").and_then(Value::as_str),
|
||||
Some("frame") | Some("group")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if containers.len() != 1 {
|
||||
return false;
|
||||
}
|
||||
let child = containers[0];
|
||||
child.get("width").and_then(Value::as_str) == Some("fill_container")
|
||||
&& child_has_colored_fill(child)
|
||||
}
|
||||
|
||||
fn fix_structural_wrapper_transparency(node: &mut Value) {
|
||||
let Some(role) = role_of(node).map(str::to_string) else {
|
||||
return;
|
||||
};
|
||||
if CARD_LIKE_ALLOWLIST.contains(&role.as_str()) {
|
||||
// A card-like role normally keeps its fill — EXCEPT when it is a
|
||||
// redundant wrapper around a single full-bleed colored child card (glm
|
||||
// wraps an orange promo banner in a `feature-card` whose own
|
||||
// `$color-surface` fill then shows as a light box around / under the
|
||||
// colored child). The colored child IS the banner surface, so strip the
|
||||
// wrapper's light fill + chrome.
|
||||
if is_redundant_colored_wrapper(node) {
|
||||
if let Some(color) = get_first_solid_color(node) {
|
||||
if is_light_surface_fill(&color) {
|
||||
if let Some(obj) = node.as_object_mut() {
|
||||
obj.insert("fill".to_string(), Value::Array(Vec::new()));
|
||||
obj.remove("stroke");
|
||||
obj.remove("effects");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return; // intentional surface — keep its fill
|
||||
}
|
||||
let structural = STRUCTURAL_DENYLIST.contains(&role.as_str())
|
||||
|| SECTION_ROLES.contains(&role.as_str())
|
||||
|| role == "header"
|
||||
|| role == "app-bar"
|
||||
|| role.contains("section");
|
||||
if !structural {
|
||||
return;
|
||||
}
|
||||
let Some(color) = get_first_solid_color(node) else {
|
||||
return;
|
||||
};
|
||||
if is_light_surface_fill(&color) {
|
||||
if let Some(obj) = node.as_object_mut() {
|
||||
obj.insert("fill".to_string(), Value::Array(Vec::new()));
|
||||
// Drop the accompanying hairline border too: a structural wrapper
|
||||
// made transparent keeps no "card/bar chrome" (the user flagged the
|
||||
// navbar's bottom border as unreasonable alongside its background).
|
||||
obj.remove("stroke");
|
||||
// …and no elevation shadow — a now-transparent wrapper would cast it
|
||||
// as a gray ghost box around its content.
|
||||
obj.remove("effects");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True when an immediate child container (frame/group) already paints a
|
||||
/// visible surface. Such a node is a redundant wrapper around the real card —
|
||||
/// it must NOT be whitewashed into its own surface, or the injected drop-shadow
|
||||
/// ghosts out around the colored child as a gray rounded box.
|
||||
fn has_filled_container_child(node: &Value) -> bool {
|
||||
node.get("children")
|
||||
.and_then(Value::as_array)
|
||||
.map(|kids| {
|
||||
kids.iter().any(|c| {
|
||||
matches!(
|
||||
c.get("type").and_then(Value::as_str),
|
||||
Some("frame") | Some("group")
|
||||
) && has_visible_fill(c)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn fix_orphan_container_contrast(node: &mut Value, parent_fill: Option<&Value>) {
|
||||
if parent_fill.is_none() {
|
||||
return; // root has no parent context
|
||||
|
|
@ -397,6 +536,18 @@ fn fix_orphan_container_contrast(node: &mut Value, parent_fill: Option<&Value>)
|
|||
{
|
||||
return;
|
||||
}
|
||||
// A child container already paints the visible surface (e.g. glm wraps an
|
||||
// orange promo banner in a bare `feature-card` frame). Whitewashing this
|
||||
// redundant wrapper into a second card leaks the injected drop-shadow out
|
||||
// around the colored child as a gray "ghost box" — the exact mysterious
|
||||
// background + rounded border the user flagged. Let the child be the card.
|
||||
if has_filled_container_child(node) {
|
||||
return;
|
||||
}
|
||||
// Only rescue containers we POSITIVELY identify as card-like; structural
|
||||
// wrappers (header / section / banner / search bar) and roleless rounded
|
||||
// frames stay transparent — white-washing them is what produced the spurious
|
||||
// white panels behind headers / banners / search rows.
|
||||
let Some(role) = role_of(node) else {
|
||||
return;
|
||||
};
|
||||
|
|
@ -791,6 +942,10 @@ fn post_pass_value(node: &mut Value, parent_fill: Option<Value>, canvas_width: f
|
|||
fix_text_heights(node);
|
||||
}
|
||||
apply_clip_content_for_image(node);
|
||||
// Strip unwanted white-card fills from structural wrappers (layout.md:24)
|
||||
// before the contrast fixes, so they evaluate the corrected transparent
|
||||
// section against the page bg instead of a bogus white surface.
|
||||
fix_structural_wrapper_transparency(node);
|
||||
// I3 contrast fixes (TS order 9-12).
|
||||
fix_button_foreground_contrast(node);
|
||||
fix_section_alternation(node);
|
||||
|
|
@ -812,6 +967,93 @@ fn post_pass_value(node: &mut Value, parent_fill: Option<Value>, canvas_width: f
|
|||
/// Run the contrast post-pass over a forest of sub-agent section roots. Each
|
||||
/// root is round-tripped through JSON; on any (de)serialize failure that root
|
||||
/// is left untouched (a fix can never drop a node).
|
||||
/// Semantic state-feedback tokens. Legit only on status/alert elements; glm
|
||||
/// grabs them as "a light color" for decorative surfaces (a `$color-danger-bg`
|
||||
/// search input renders pink and clashes with the theme).
|
||||
const STATE_BG_REFS: &[&str] = &[
|
||||
"$color-danger-bg",
|
||||
"$color-info-bg",
|
||||
"$color-success-bg",
|
||||
"$color-warning-bg",
|
||||
];
|
||||
|
||||
/// The page-background token. Only the page root paints it; an inner node using
|
||||
/// it just repaints a redundant — or theme-clashing (cool `#F8FAFC` over a warm
|
||||
/// page) — panel.
|
||||
const PAGE_BG_REF: &str = "$color-bg-deep";
|
||||
|
||||
/// A status / feedback element — the ONLY legitimate user of a state-bg token.
|
||||
fn is_status_element(node: &Value) -> bool {
|
||||
if let Some(role) = role_of(node) {
|
||||
if matches!(role, "badge" | "alert" | "toast" | "status") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if let Some(name) = node.get("name").and_then(Value::as_str) {
|
||||
let l = name.to_lowercase();
|
||||
return [
|
||||
"error",
|
||||
"success",
|
||||
"warning",
|
||||
"alert",
|
||||
"danger",
|
||||
"status",
|
||||
"toast",
|
||||
"notification",
|
||||
]
|
||||
.iter()
|
||||
.any(|k| l.contains(k));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Surface-color discipline — a deterministic floor walking EVERY node type
|
||||
/// (incl. `text_input`, which the frame-only `post_pass_value` skips). The TS
|
||||
/// pipeline relies on the prompt for this; weak models (glm-5.2) ignore it, so
|
||||
/// Rust enforces it after the fact:
|
||||
/// 1. A state-bg token misused as a decorative surface → neutral
|
||||
/// `$color-surface-2`. (the pink search input / chips)
|
||||
/// 2. `$color-bg-deep` on any inner node → transparent. (the cool grey panel
|
||||
/// behind the search row / a nav tab repainting the page bg)
|
||||
///
|
||||
/// Refs are still UNRESOLVED here (binding runs later), so match token names.
|
||||
fn node_has_effects(node: &Value) -> bool {
|
||||
node.get("effects")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| !a.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn has_any_stroke(node: &Value) -> bool {
|
||||
node.get("stroke").map(|s| !s.is_null()).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn fix_surface_color_discipline(node: &mut Value, is_root: bool) {
|
||||
if let Some(color) = get_first_solid_color(node) {
|
||||
if STATE_BG_REFS.contains(&color.as_str()) && !is_status_element(node) {
|
||||
node["fill"] = solid_fill("$color-surface-2");
|
||||
} else if !is_root && color == PAGE_BG_REF {
|
||||
node["fill"] = json!([]);
|
||||
}
|
||||
}
|
||||
// An elevation shadow needs a surface to sit on. A frame with no visible
|
||||
// fill and no stroke that still carries a drop-shadow renders the shadow as
|
||||
// a gray "ghost box" floating around its children — strip it. This runs last
|
||||
// (after binding + every fill-stripping pass), so it sees the FINAL fill
|
||||
// state and catches both our own injected card shadows on wrappers that got
|
||||
// emptied and model-authored shadows on bare wrapper frames.
|
||||
if node_has_effects(node) && !has_visible_fill(node) && !has_any_stroke(node) {
|
||||
if let Some(obj) = node.as_object_mut() {
|
||||
obj.remove("effects");
|
||||
}
|
||||
}
|
||||
if let Some(children) = node.get_mut("children").and_then(Value::as_array_mut) {
|
||||
for child in children.iter_mut() {
|
||||
fix_surface_color_discipline(child, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_pass_forest(nodes: &mut [PenNode], canvas_width: f64) {
|
||||
for node in nodes.iter_mut() {
|
||||
let Ok(mut v) = serde_json::to_value(&*node) else {
|
||||
|
|
@ -824,6 +1066,24 @@ pub fn post_pass_forest(nodes: &mut [PenNode], canvas_width: f64) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Surface-color discipline over the whole forest. MUST run AFTER
|
||||
/// `bind_generated_color_variables` — glm emits raw hex (`#FEE2E2`,
|
||||
/// `#F8FAFC`), and binding is what normalizes those to the `$color-danger-bg`
|
||||
/// / `$color-bg-deep` refs this pass matches. Calling it pre-binding (the old
|
||||
/// site inside `post_pass_forest`) only ever saw hex, so it never matched and
|
||||
/// the pink search input / cool-grey panels survived.
|
||||
pub fn enforce_surface_color_discipline(nodes: &mut [PenNode]) {
|
||||
for node in nodes.iter_mut() {
|
||||
let Ok(mut v) = serde_json::to_value(&*node) else {
|
||||
continue;
|
||||
};
|
||||
fix_surface_color_discipline(&mut v, true);
|
||||
if let Ok(new_node) = serde_json::from_value::<PenNode>(v) {
|
||||
*node = new_node;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "role_post_pass_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -121,21 +121,307 @@ fn orphan_skipped_without_corner_radius() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_skips_unroled_layout_carousel_shell() {
|
||||
let mut shell = json!({
|
||||
"type":"frame","name":"Promo Carousel Viewport","cornerRadius":20,
|
||||
"layout":"horizontal","children":[
|
||||
{"type":"frame","role":"banner","children":[{"type":"text","content":"50%"}]}
|
||||
]
|
||||
fn orphan_skipped_for_roleless_container() {
|
||||
// A roleless rounded container is NOT assumed to be a card. Rust role
|
||||
// inference is thinner than TS, so roleless wrappers (header / section /
|
||||
// banner) are common; white-washing them is what produced the spurious
|
||||
// panels behind the header / banner / search row.
|
||||
let mut wrap = json!({
|
||||
"type":"frame","cornerRadius":16,
|
||||
"children":[{"type":"frame","role":"section","children":[{"type":"text"}]}]
|
||||
});
|
||||
fix_orphan_container_contrast(&mut shell, Some(&Value::Null));
|
||||
fix_orphan_container_contrast(&mut wrap, Some(&Value::Null));
|
||||
assert!(
|
||||
shell.get("fill").is_none(),
|
||||
"a rounded unroled layout/carousel frame must not become a white card shell"
|
||||
wrap.get("fill").is_none(),
|
||||
"roleless container is not white-washed into a card"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_skipped_when_child_container_paints_surface() {
|
||||
// glm wraps an orange promo banner in a bare `feature-card` frame. The child
|
||||
// (Promo Card) carries the full-bleed gradient, so the wrapper must NOT be
|
||||
// whitewashed — doing so leaks the injected drop-shadow out as a gray ghost
|
||||
// box around the orange child (the user's "mysterious bg + rounded border").
|
||||
let mut wrap = json!({
|
||||
"type":"frame","role":"feature-card","cornerRadius":12,
|
||||
"children":[{
|
||||
"type":"frame","role":"card",
|
||||
"fill":[{"type":"linear_gradient","angle":135,"stops":[
|
||||
{"offset":0.0,"color":"#FB923C"},{"offset":1.0,"color":"#F97316"}]}],
|
||||
"children":[{"type":"text","content":"50% Off"}]
|
||||
}]
|
||||
});
|
||||
fix_orphan_container_contrast(&mut wrap, Some(&Value::Null));
|
||||
assert!(
|
||||
wrap.get("fill").is_none(),
|
||||
"wrapper around a filled card stays transparent"
|
||||
);
|
||||
assert!(
|
||||
shell.get("effects").is_none(),
|
||||
"a structural shell must not get card shadow"
|
||||
wrap.get("effects").is_none(),
|
||||
"no ghost shadow injected onto the bare wrapper"
|
||||
);
|
||||
}
|
||||
|
||||
// ── orphaned-shadow strip (ghost-box cleanup) ────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn orphaned_shadow_stripped_from_fill_less_frame() {
|
||||
// A drop-shadow with no surface (no fill, no stroke) is a gray ghost box.
|
||||
let mut node = json!({
|
||||
"type":"frame","cornerRadius":12,
|
||||
"effects":[{"type":"shadow","offsetX":0,"offsetY":1,"blur":3,"spread":0,"color":"#0000001A"}],
|
||||
"children":[{"type":"frame","role":"card","fill":[{"type":"solid","color":"#FB923C"}]}]
|
||||
});
|
||||
fix_surface_color_discipline(&mut node, false);
|
||||
assert!(
|
||||
node.get("effects").is_none(),
|
||||
"shadow on a fill-less, stroke-less frame is stripped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_kept_when_node_has_visible_fill() {
|
||||
// A real card (visible fill) keeps its elevation shadow.
|
||||
let mut node = json!({
|
||||
"type":"frame","role":"card","cornerRadius":12,
|
||||
"fill":[{"type":"solid","color":"#FFFFFF"}],
|
||||
"effects":[{"type":"shadow","offsetX":0,"offsetY":1,"blur":3,"spread":0,"color":"#0000001A"}],
|
||||
});
|
||||
fix_surface_color_discipline(&mut node, false);
|
||||
assert!(node["effects"].is_array(), "a filled card keeps its shadow");
|
||||
}
|
||||
|
||||
// ── fixStructuralWrapperTransparency ─────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn structural_wrapper_white_fill_stripped() {
|
||||
// A section wrapper that glm gave an explicit white fill → forced transparent.
|
||||
let mut sect = json!({
|
||||
"type":"frame","role":"section","fill":[{"type":"solid","color":"#FFFFFF"}],
|
||||
"children":[{"type":"text","content":"x"}]
|
||||
});
|
||||
fix_structural_wrapper_transparency(&mut sect);
|
||||
assert_eq!(
|
||||
sect["fill"],
|
||||
json!([]),
|
||||
"white structural wrapper → transparent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_wrapper_keeps_card_fill() {
|
||||
// card is in CARD_LIKE_ALLOWLIST — an intentional surface, fill stays.
|
||||
let mut card = json!({
|
||||
"type":"frame","role":"card","fill":[{"type":"solid","color":"#FFFFFF"}],
|
||||
"children":[{"type":"text","content":"x"}]
|
||||
});
|
||||
fix_structural_wrapper_transparency(&mut card);
|
||||
assert_eq!(
|
||||
card["fill"],
|
||||
json!([{"type":"solid","color":"#FFFFFF"}]),
|
||||
"card keeps its white surface"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redundant_colored_wrapper_strips_light_surface() {
|
||||
// feature-card with a $color-surface fill wrapping a single full-bleed
|
||||
// gradient banner child → its surface is a redundant box, strip it.
|
||||
let mut wrap = json!({
|
||||
"type":"frame","role":"feature-card","cornerRadius":12,
|
||||
"fill":[{"type":"solid","color":"$color-surface"}],
|
||||
"children":[
|
||||
{
|
||||
"type":"frame","role":"card","width":"fill_container",
|
||||
"fill":[{"type":"linear_gradient","angle":135,"stops":[
|
||||
{"offset":0.0,"color":"$color-chart-6"},{"offset":1.0,"color":"#FB923C"}]}],
|
||||
"children":[{"type":"text","content":"50% Off"}]
|
||||
},
|
||||
{"type":"text","content":"-30%"}
|
||||
]
|
||||
});
|
||||
fix_structural_wrapper_transparency(&mut wrap);
|
||||
assert_eq!(
|
||||
wrap["fill"],
|
||||
json!([]),
|
||||
"redundant wrapper around a full-bleed colored card → transparent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_with_small_colored_child_keeps_fill() {
|
||||
// A real card whose colored child is NOT full-bleed (a small icon tile) is
|
||||
// a genuine surface — must keep its fill.
|
||||
let mut card = json!({
|
||||
"type":"frame","role":"card","fill":[{"type":"solid","color":"#FFFFFF"}],
|
||||
"children":[
|
||||
{
|
||||
"type":"frame","role":"icon-tile","width":48,
|
||||
"fill":[{"type":"solid","color":"#FB923C"}],
|
||||
"children":[]
|
||||
},
|
||||
{"type":"text","content":"Title"}
|
||||
]
|
||||
});
|
||||
fix_structural_wrapper_transparency(&mut card);
|
||||
assert_eq!(
|
||||
card["fill"],
|
||||
json!([{"type":"solid","color":"#FFFFFF"}]),
|
||||
"card with a small colored tile keeps its surface"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_wrapper_keeps_dark_fill() {
|
||||
// A dark section fill (luminance well below 0.85) is a deliberate band → kept.
|
||||
let mut sect = json!({
|
||||
"type":"frame","role":"section","fill":[{"type":"solid","color":"#1A1A1A"}],
|
||||
"children":[{"type":"text","content":"x"}]
|
||||
});
|
||||
fix_structural_wrapper_transparency(&mut sect);
|
||||
assert_eq!(
|
||||
sect["fill"],
|
||||
json!([{"type":"solid","color":"#1A1A1A"}]),
|
||||
"dark structural band is intentional, not stripped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_wrapper_section_substring_and_header_stripped() {
|
||||
// role *containing* "section" (e.g. a custom "feature-section") → stripped.
|
||||
let mut feat = json!({
|
||||
"type":"frame","role":"feature-section","fill":[{"type":"solid","color":"#FAFAFA"}],
|
||||
"children":[{"type":"text"}]
|
||||
});
|
||||
fix_structural_wrapper_transparency(&mut feat);
|
||||
assert_eq!(feat["fill"], json!([]), "*-section role → transparent");
|
||||
// header is structural too.
|
||||
let mut header = json!({
|
||||
"type":"frame","role":"header","fill":[{"type":"solid","color":"#FFFFFF"}],
|
||||
"children":[{"type":"text"}]
|
||||
});
|
||||
fix_structural_wrapper_transparency(&mut header);
|
||||
assert_eq!(header["fill"], json!([]), "header → transparent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_wrapper_surface_variable_ref_stripped() {
|
||||
// glm emits UNRESOLVED $color-* refs (variable binding runs after post-pass),
|
||||
// so hex_luminance can't read them — the strip must match neutral surface
|
||||
// tokens by name. This is the actual header-background bug: role navbar +
|
||||
// fill $color-surface-2 was surviving because luminance("$color-surface-2")
|
||||
// returned None.
|
||||
let mut header = json!({
|
||||
"type":"frame","role":"navbar","fill":[{"type":"solid","color":"$color-surface-2"}],
|
||||
"children":[{"type":"text"}]
|
||||
});
|
||||
fix_structural_wrapper_transparency(&mut header);
|
||||
assert_eq!(
|
||||
header["fill"],
|
||||
json!([]),
|
||||
"navbar with $color-surface-2 → transparent"
|
||||
);
|
||||
// A colored token (deliberate accent band) is NOT a neutral surface → kept.
|
||||
let mut band = json!({
|
||||
"type":"frame","role":"section","fill":[{"type":"solid","color":"$color-accent"}],
|
||||
"children":[{"type":"text"}]
|
||||
});
|
||||
fix_structural_wrapper_transparency(&mut band);
|
||||
assert_eq!(
|
||||
band["fill"],
|
||||
json!([{"type":"solid","color":"$color-accent"}]),
|
||||
"deliberate colored band kept"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_wrapper_strips_border_with_fill() {
|
||||
// The mobile header (role navbar) carried a $color-surface fill AND a bottom
|
||||
// hairline stroke — the user flagged BOTH the background and the border.
|
||||
// Stripping the surface fill must drop the accompanying border too; a
|
||||
// transparent structural wrapper keeps no card/bar chrome.
|
||||
let mut header = json!({
|
||||
"type":"frame","role":"navbar","fill":[{"type":"solid","color":"$color-surface"}],
|
||||
"stroke":{"thickness":[0,0,1,0],"fill":[{"type":"solid","color":"$color-border"}]},
|
||||
"children":[{"type":"text"}]
|
||||
});
|
||||
fix_structural_wrapper_transparency(&mut header);
|
||||
assert_eq!(header["fill"], json!([]), "surface fill stripped");
|
||||
assert!(
|
||||
header.get("stroke").is_none(),
|
||||
"bottom border stripped alongside the fill"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structural_wrapper_non_structural_role_left_alone() {
|
||||
// A plain content role that isn't structural and isn't card-like → untouched.
|
||||
let mut text = json!({
|
||||
"type":"text","role":"body","fill":[{"type":"solid","color":"#FFFFFF"}]
|
||||
});
|
||||
fix_structural_wrapper_transparency(&mut text);
|
||||
assert_eq!(
|
||||
text["fill"],
|
||||
json!([{"type":"solid","color":"#FFFFFF"}]),
|
||||
"non-structural role is out of scope"
|
||||
);
|
||||
}
|
||||
|
||||
// ── fixSurfaceColorDiscipline ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn state_bg_token_on_input_recolored_to_neutral() {
|
||||
// The pink-search bug: glm used $color-danger-bg as the input surface.
|
||||
let mut input = json!({
|
||||
"type":"text_input","name":"Search Input",
|
||||
"fill":[{"type":"solid","color":"$color-danger-bg"}]
|
||||
});
|
||||
fix_surface_color_discipline(&mut input, false);
|
||||
assert_eq!(
|
||||
input["fill"],
|
||||
json!([{"type":"solid","color":"$color-surface-2"}]),
|
||||
"danger-bg misused as input surface → neutral surface-2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bg_token_kept_on_status_element() {
|
||||
// A real status element (name says "Error") legitimately uses danger-bg.
|
||||
let mut badge = json!({
|
||||
"type":"frame","role":"badge","name":"Error Badge",
|
||||
"fill":[{"type":"solid","color":"$color-danger-bg"}],
|
||||
"children":[{"type":"text","content":"Failed"}]
|
||||
});
|
||||
fix_surface_color_discipline(&mut badge, false);
|
||||
assert_eq!(
|
||||
badge["fill"],
|
||||
json!([{"type":"solid","color":"$color-danger-bg"}]),
|
||||
"status element keeps its semantic state color"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_bg_token_stripped_from_inner_node_kept_on_root() {
|
||||
// Inner wrapper repainting the page bg (the cool grey panel behind search).
|
||||
let mut root = json!({
|
||||
"type":"frame","name":"Page","fill":[{"type":"solid","color":"$color-bg-deep"}],
|
||||
"children":[
|
||||
{"type":"frame","name":"Search & Categories",
|
||||
"fill":[{"type":"solid","color":"$color-bg-deep"}],
|
||||
"children":[{"type":"text_input","name":"Search"}]}
|
||||
]
|
||||
});
|
||||
fix_surface_color_discipline(&mut root, true);
|
||||
assert_eq!(
|
||||
root["fill"],
|
||||
json!([{"type":"solid","color":"$color-bg-deep"}]),
|
||||
"page root keeps the page-bg token"
|
||||
);
|
||||
assert_eq!(
|
||||
root["children"][0]["fill"],
|
||||
json!([]),
|
||||
"inner wrapper using the page-bg token → transparent"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -200,101 +486,6 @@ fn input_siblings_unified_to_first() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_search_shell_is_flattened_to_neutral_row() {
|
||||
let mut row = json!({
|
||||
"type":"frame","name":"Search Shell","layout":"horizontal","cornerRadius":28,
|
||||
"padding":[12,16],"gap":14,
|
||||
"fill":[{"type":"solid","color":"#DBEAFE"}],
|
||||
"stroke":{"thickness":1,"fill":[{"type":"solid","color":"#BFDBFE"}]},
|
||||
"effects":[{"type":"shadow","offsetY":4,"blur":16,"color":"#00000022"}],
|
||||
"children":[
|
||||
{
|
||||
"type":"frame","role":"form-input","height":48,"cornerRadius":12,
|
||||
"fill":[{"type":"solid","color":"#FADADA"}],
|
||||
"stroke":{"thickness":1,"fill":[{"type":"solid","color":"#E5E7EB"}]},
|
||||
"children":[{"type":"path","name":"SearchIcon","width":20,"height":20}]
|
||||
},
|
||||
{
|
||||
"type":"frame","role":"icon-button","width":52,"height":52,
|
||||
"fill":[{"type":"solid","color":"#FF6B00"}],
|
||||
"children":[{"type":"path","name":"SlidersHorizontalIcon","width":20,"height":20}]
|
||||
}
|
||||
]
|
||||
});
|
||||
normalize_nested_search_shell(&mut row);
|
||||
assert!(
|
||||
row.get("fill").is_none(),
|
||||
"outer search shell fill is visual noise"
|
||||
);
|
||||
assert!(
|
||||
row.get("stroke").is_none(),
|
||||
"outer search shell stroke is visual noise"
|
||||
);
|
||||
assert!(
|
||||
row.get("effects").is_none(),
|
||||
"outer search shell shadow is visual noise"
|
||||
);
|
||||
assert!(
|
||||
row.get("cornerRadius").is_none(),
|
||||
"outer shell should not read as a second input"
|
||||
);
|
||||
assert_eq!(
|
||||
row["children"][0]["fill"],
|
||||
json!([{"type":"solid","color":"#FFFFFF"}]),
|
||||
"nested search input is neutralized instead of keeping a pink fill"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_search_bar_keeps_its_surface() {
|
||||
let mut search = json!({
|
||||
"type":"frame","role":"search-bar","layout":"horizontal","cornerRadius":22,
|
||||
"fill":[{"type":"solid","color":"#F8FAFC"}],
|
||||
"children":[
|
||||
{"type":"path","name":"SearchIcon","width":20,"height":20},
|
||||
{"type":"text","content":"Search"}
|
||||
]
|
||||
});
|
||||
normalize_nested_search_shell(&mut search);
|
||||
assert_eq!(search["fill"], json!([{"type":"solid","color":"#F8FAFC"}]));
|
||||
assert_eq!(search["cornerRadius"], json!(22));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favorite_icon_button_loses_bubble_chrome() {
|
||||
let mut card = json!({
|
||||
"type":"frame","role":"image-card","width":160,"height":240,"children":[
|
||||
{
|
||||
"type":"frame","role":"icon-button","name":"Favorite Button",
|
||||
"width":36,"height":36,"cornerRadius":999,
|
||||
"fill":[{"type":"solid","color":"#FFFFFF"}],
|
||||
"stroke":{"thickness":1,"fill":[{"type":"solid","color":"#E2E8F0"}]},
|
||||
"effects":[{"type":"shadow","offsetY":2,"blur":6,"color":"#0000001A"}],
|
||||
"children":[{"type":"path","name":"HeartIcon","width":18,"height":18}]
|
||||
}
|
||||
]
|
||||
});
|
||||
normalize_favorite_icon_buttons(&mut card);
|
||||
let button = &card["children"][0];
|
||||
assert!(
|
||||
button.get("fill").is_none(),
|
||||
"heart should not sit in a white bubble"
|
||||
);
|
||||
assert!(
|
||||
button.get("stroke").is_none(),
|
||||
"heart bubble border is visually noisy"
|
||||
);
|
||||
assert!(
|
||||
button.get("effects").is_none(),
|
||||
"heart bubble shadow is visually noisy"
|
||||
);
|
||||
assert!(
|
||||
button.get("cornerRadius").is_none(),
|
||||
"heart should not force a circular badge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_color_does_not_panic() {
|
||||
// A malformed multi-byte color string must not panic the byte-slicer.
|
||||
|
|
@ -409,6 +600,31 @@ fn horizontal_overflow_uses_fill_when_needed_width_nears_canvas() {
|
|||
assert_eq!(row["width"], json!("fill_container"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_overflow_beyond_viewport_clips_instead_of_spilling() {
|
||||
// The food-app category bug: 6 chips that physically can't fit a 375px phone.
|
||||
// Widening is futile (children sum > canvas), so the row spans the viewport and
|
||||
// clips at the edge instead of letting chips spill off-canvas into the void.
|
||||
let mut row = json!({
|
||||
"type":"frame","layout":"horizontal","width":327,"gap":12,
|
||||
"children":[
|
||||
{"type":"frame","width":46,"height":34},
|
||||
{"type":"frame","width":84,"height":34},
|
||||
{"type":"frame","width":85,"height":34},
|
||||
{"type":"frame","width":91,"height":34},
|
||||
{"type":"frame","width":89,"height":34},
|
||||
{"type":"frame","width":96,"height":34}
|
||||
]
|
||||
});
|
||||
fix_horizontal_overflow(&mut row, 375.0);
|
||||
assert_eq!(row["width"], json!("fill_container"));
|
||||
assert_eq!(
|
||||
row["clipContent"],
|
||||
json!(true),
|
||||
"an over-viewport horizontal row clips at the edge (scroll-row floor)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_heights_removed_unless_fixed_width_height() {
|
||||
let mut root = json!({
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ use crate::plan_repair::parse_orchestrator_response;
|
|||
use crate::prompt::build_orchestrator_prompt;
|
||||
use crate::retry::{attempt_modes, is_non_retryable};
|
||||
use crate::run_dashboard::run_dashboard_path;
|
||||
use crate::scaffold::{build_scaffold, build_scaffold_concurrent_mobile};
|
||||
use crate::scaffold::{build_scaffold, build_scaffold_concurrent_mobile, build_scaffold_reusing};
|
||||
use crate::subagent::{apply_command_with_reveal, reveal_now_millis, run_subtask_with_reveal_at};
|
||||
use crate::types::{
|
||||
AbortFlag, DesignRequest, DocSink, LlmChunk, LlmClient, OrchestratorError, Progress,
|
||||
|
|
@ -40,7 +40,44 @@ use crate::types::{
|
|||
use crate::validation::run_post_generation_validation;
|
||||
use crate::variables::{rollback, seed_commands, snapshot_plan_vars};
|
||||
use futures::StreamExt;
|
||||
use op_editor_core::{EditorCommand, NodeId, PenNodeExt};
|
||||
use op_editor_core::{EditorCommand, EditorState, NodeId, PenNodeExt};
|
||||
|
||||
/// TS `replaceEmptyFrame` parity: detect a single EMPTY top-level frame (the
|
||||
/// fresh-canvas starter) that can be REUSED as the design root instead of
|
||||
/// inserting a brand-new root. Returns its id when the active page holds
|
||||
/// exactly one empty container; `None` otherwise (multi-node canvas, filled
|
||||
/// frame, or non-container) so the normal insert path runs.
|
||||
fn detect_reusable_empty_frame(state: &EditorState) -> Option<String> {
|
||||
let kids = state.active_children();
|
||||
if kids.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let node = &kids[0];
|
||||
if node.is_container() && node.children().map(|c| c.is_empty()).unwrap_or(true) {
|
||||
Some(node.id_str().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the fresh-canvas starter frame (a single empty top-level
|
||||
/// container) so a path that builds its OWN root(s) doesn't leave it
|
||||
/// orphaned beside the design. The sequential path REUSES it instead
|
||||
/// (see [`detect_reusable_empty_frame`] + `build_scaffold_reusing`); the
|
||||
/// concurrent (N roots) + dashboard (bespoke sidebar+main root) paths
|
||||
/// can't reuse a single starter, so they clear it here. This mirrors the
|
||||
/// host's former `clear_fresh_starter_frame_for_design` — now owned
|
||||
/// orchestrator-side so the host can leave the starter in place for the
|
||||
/// reuse path. No-op on a headless / empty canvas (op-smoke), where
|
||||
/// there is no starter to detect.
|
||||
fn clear_reusable_empty_frame(sink: &mut dyn DocSink) {
|
||||
if let Some(id) = detect_reusable_empty_frame(sink.state()) {
|
||||
sink.apply(EditorCommand::DeleteNode {
|
||||
node_id: NodeId::new(id),
|
||||
page_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 设计编排器。
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
|
|
@ -94,6 +131,17 @@ impl Orchestrator {
|
|||
let append_result =
|
||||
apply_append_context_to_plan(&mut plan, request.append_context.as_ref());
|
||||
|
||||
// Surface the FULL planned task list upfront (TS parity) so the UI can
|
||||
// render the complete checklist immediately, rather than revealing
|
||||
// subtasks one-by-one as each starts.
|
||||
on_progress(Progress::Planned {
|
||||
subtasks: plan
|
||||
.subtasks
|
||||
.iter()
|
||||
.map(|s| (s.id.clone(), s.label.clone()))
|
||||
.collect(),
|
||||
});
|
||||
|
||||
// -- S3b-2 Task C2: concurrency branch decision --
|
||||
// Port of `orchestrator.ts:780-810`.
|
||||
let screen_groups = group_subtasks_by_screen(&plan.subtasks);
|
||||
|
|
@ -108,6 +156,9 @@ impl Orchestrator {
|
|||
};
|
||||
|
||||
if effective > 1 {
|
||||
// Concurrent builds N screen roots — can't reuse the single
|
||||
// fresh-canvas starter, so clear it (host no longer does).
|
||||
clear_reusable_empty_frame(sink);
|
||||
return run_concurrent_path(
|
||||
request,
|
||||
plan,
|
||||
|
|
@ -136,6 +187,14 @@ impl Orchestrator {
|
|||
for cmd in seed_commands(&plan, &var_snapshot) {
|
||||
sink.apply(cmd);
|
||||
}
|
||||
// The dashboard path builds a bespoke sidebar+main root that can't
|
||||
// reuse the fresh-canvas starter; clear it BEFORE indexing so the
|
||||
// dashboard root lands at index 0 (not orphaned beside the starter).
|
||||
// The sequential path below keeps the starter and REUSES it via
|
||||
// ReplaceSubtree, so it must NOT be cleared here.
|
||||
if !append_result.skip_root_insertion && use_dashboard {
|
||||
clear_reusable_empty_frame(sink);
|
||||
}
|
||||
let scaffold_root_index = sink.state().active_children().len();
|
||||
let scaffold_root_ids_before: Vec<String> = sink
|
||||
.state()
|
||||
|
|
@ -187,7 +246,17 @@ impl Orchestrator {
|
|||
(target_id, baseline)
|
||||
} else {
|
||||
let effective_is_mobile = norm.is_mobile && !append_result.skip_status_bar;
|
||||
match build_scaffold(&plan, effective_is_mobile) {
|
||||
// TS `replaceEmptyFrame` parity: when the canvas is a single empty
|
||||
// top-level frame (the fresh-canvas starter), REUSE it as the design
|
||||
// root (ReplaceSubtree in place) instead of inserting a brand-new
|
||||
// root — which the host would otherwise clear + re-add, the visible
|
||||
// "delete then re-draw" flash the user flagged.
|
||||
let reuse_id = detect_reusable_empty_frame(sink.state());
|
||||
let scaffold_cmds = match reuse_id.as_deref() {
|
||||
Some(id) => build_scaffold_reusing(&plan, effective_is_mobile, id),
|
||||
None => build_scaffold(&plan, effective_is_mobile),
|
||||
};
|
||||
match scaffold_cmds {
|
||||
Ok(cmds) => {
|
||||
for cmd in cmds {
|
||||
if !apply_command_with_reveal(
|
||||
|
|
@ -380,13 +449,18 @@ impl Orchestrator {
|
|||
break;
|
||||
}
|
||||
if zero {
|
||||
// 零节点失败(非 abort,全部 3 次皆失败)—— 停止后续 subtask。
|
||||
// 零节点失败(非 abort,全部 3 次皆失败)。**不 break** —— 一个
|
||||
// section 失败不该放弃后续所有 subtask。各 subtask 独立
|
||||
// InsertSubtree 到 root、互不依赖;break 会把失败点之后的必要
|
||||
// 内容(bottom nav 等)全丢掉(用户报的"管线丢内容")。跳过这个、
|
||||
// 继续后面的;`zero_node_failure` 仍标记"至少一个失败",最终若
|
||||
// **全部**零内容(zero_content)才删 scaffold root。
|
||||
on_progress(Progress::SubtaskFailed {
|
||||
id: subtask.id.clone(),
|
||||
error: err_msg.unwrap_or_default(),
|
||||
});
|
||||
zero_node_failure = true;
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
on_progress(Progress::SubtaskDone {
|
||||
id: subtask.id.clone(),
|
||||
|
|
|
|||
|
|
@ -218,6 +218,23 @@ fn mobile_status_bar_json(root_id: &str, fill_hex: &str, width: f64) -> serde_js
|
|||
///
|
||||
/// 内部辅助函数,被 `build_scaffold`(单屏)和
|
||||
/// `build_scaffold_concurrent`(多屏)共用。
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Canonical inter-section spacing for the page-root vertical stack (Pencil's
|
||||
/// reverse-engineered demo uses `gap: 20` between sections). The LLM frequently
|
||||
/// omits the page gap, leaving it `0` so every section touches the next — the
|
||||
/// cramped, no-breathing-room look that reads nothing like the TS references.
|
||||
const SECTION_STACK_GAP: f64 = 20.0;
|
||||
|
||||
/// Resolve the page-root section gap: honor an explicit positive plan gap,
|
||||
/// otherwise fall back to [`SECTION_STACK_GAP`]. Mirrors the dashboard
|
||||
/// main-column gap fallback (`root.gap > 0 ? root.gap : 20`).
|
||||
fn resolve_section_gap(plan_gap: Option<f64>) -> f64 {
|
||||
match plan_gap {
|
||||
Some(g) if g > 0.0 => g,
|
||||
_ => SECTION_STACK_GAP,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_root_frame_node(
|
||||
id: &str,
|
||||
|
|
@ -279,30 +296,60 @@ pub fn build_scaffold(
|
|||
plan: &OrchestratorPlan,
|
||||
is_mobile: bool,
|
||||
) -> Result<Vec<EditorCommand>, String> {
|
||||
let node = build_scaffold_root_node(plan, is_mobile, &plan.root_frame.id)?;
|
||||
Ok(vec![EditorCommand::InsertSubtree {
|
||||
nodes: vec![node],
|
||||
parent_id: NodeId::NONE,
|
||||
page_id: None,
|
||||
}])
|
||||
}
|
||||
|
||||
/// Reuse an existing EMPTY top-level frame (the fresh-canvas starter) as the
|
||||
/// design root, mirroring TS `replaceEmptyFrame`: instead of clearing the
|
||||
/// starter + inserting a brand-new root (the visible "delete + re-add" the
|
||||
/// user flagged), REPLACE the starter frame's subtree in place. The scaffold
|
||||
/// root takes the reused id, so its slot/identity is preserved and the canvas
|
||||
/// fills smoothly rather than flashing empty.
|
||||
pub fn build_scaffold_reusing(
|
||||
plan: &OrchestratorPlan,
|
||||
is_mobile: bool,
|
||||
reuse_id: &str,
|
||||
) -> Result<Vec<EditorCommand>, String> {
|
||||
let node = build_scaffold_root_node(plan, is_mobile, reuse_id)?;
|
||||
Ok(vec![EditorCommand::ReplaceSubtree {
|
||||
node_id: NodeId::new(reuse_id.to_string()),
|
||||
node: Box::new(node),
|
||||
drop_children: true,
|
||||
page_id: None,
|
||||
}])
|
||||
}
|
||||
|
||||
/// Build the root-frame node (status-bar child injected when `is_mobile`),
|
||||
/// stamping `root_id` as its id so the caller can either insert it fresh or
|
||||
/// replace an existing frame's slot with it.
|
||||
fn build_scaffold_root_node(
|
||||
plan: &OrchestratorPlan,
|
||||
is_mobile: bool,
|
||||
root_id: &str,
|
||||
) -> Result<PenNode, String> {
|
||||
let rf = &plan.root_frame;
|
||||
let layout = rf.layout.as_deref().unwrap_or("vertical");
|
||||
let fill_hex = rf
|
||||
.first_solid_hex()
|
||||
.unwrap_or_else(|| "#FFFFFF".to_string());
|
||||
|
||||
let node = build_root_frame_node(
|
||||
&rf.id,
|
||||
build_root_frame_node(
|
||||
root_id,
|
||||
&rf.name,
|
||||
SAFE_CANVAS_X,
|
||||
SAFE_CANVAS_Y,
|
||||
rf.width,
|
||||
rf.height,
|
||||
layout,
|
||||
rf.gap.unwrap_or(0.0),
|
||||
resolve_section_gap(rf.gap),
|
||||
&fill_hex,
|
||||
is_mobile,
|
||||
)?;
|
||||
|
||||
Ok(vec![EditorCommand::InsertSubtree {
|
||||
nodes: vec![node],
|
||||
parent_id: NodeId::NONE,
|
||||
page_id: None,
|
||||
}])
|
||||
)
|
||||
}
|
||||
|
||||
/// 构建 N-root-frame 并发画布搭建命令(多屏并发路径,S3b-2 Task A2)。
|
||||
|
|
@ -401,7 +448,7 @@ fn build_scaffold_concurrent_inner(
|
|||
rf.width,
|
||||
frame_height,
|
||||
layout,
|
||||
rf.gap.unwrap_or(0.0),
|
||||
resolve_section_gap(rf.gap),
|
||||
&fill_hex,
|
||||
is_mobile,
|
||||
)?;
|
||||
|
|
|
|||
|
|
@ -87,6 +87,62 @@ fn build_scaffold_mobile_injects_status_bar() {
|
|||
}
|
||||
}
|
||||
|
||||
// ── Section-stack gap default (breathing room between sections) ─────────────
|
||||
|
||||
#[test]
|
||||
fn resolve_section_gap_defaults_to_20_when_unset_or_zero() {
|
||||
// The LLM frequently omits the page gap (None) or emits 0 → sections touch.
|
||||
// Both fall back to the canonical 20 px so the page breathes like the TS refs.
|
||||
assert_eq!(resolve_section_gap(None), 20.0);
|
||||
assert_eq!(resolve_section_gap(Some(0.0)), 20.0);
|
||||
// An explicit positive gap is the design's intent — honor it.
|
||||
assert_eq!(resolve_section_gap(Some(12.0)), 12.0);
|
||||
assert_eq!(resolve_section_gap(Some(32.0)), 32.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_scaffold_root_gap_breathes_when_plan_gap_is_zero() {
|
||||
// plan() authors gap: Some(0.0); the scaffold root must still carry 20.
|
||||
let cmds = build_scaffold(&plan(), true).expect("scaffold");
|
||||
match &cmds[0] {
|
||||
EditorCommand::InsertSubtree { nodes, .. } => {
|
||||
let json = serde_json::to_value(&nodes[0]).expect("root json");
|
||||
assert_eq!(json["gap"], 20.0, "zero plan gap must breathe at 20px");
|
||||
}
|
||||
other => panic!("expected InsertSubtree, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── TS `replaceEmptyFrame` parity: reuse the starter in place ───────────────
|
||||
|
||||
#[test]
|
||||
fn build_scaffold_reusing_emits_replace_subtree_with_reused_id() {
|
||||
// The fresh-canvas starter has a different id ("n10") than the planned
|
||||
// root ("root"); reuse must stamp the built root with the STARTER's id
|
||||
// (so the slot is preserved in place) and replace its subtree — never
|
||||
// insert a brand-new root beside it.
|
||||
let cmds = build_scaffold_reusing(&plan(), false, "n10").expect("reuse scaffold");
|
||||
assert_eq!(cmds.len(), 1);
|
||||
match &cmds[0] {
|
||||
EditorCommand::ReplaceSubtree {
|
||||
node_id,
|
||||
node,
|
||||
drop_children,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(node_id.as_str(), "n10");
|
||||
assert!(
|
||||
*drop_children,
|
||||
"must drop the empty starter's (absent) children"
|
||||
);
|
||||
// The built root carries the reused id, not the plan's "root".
|
||||
assert_eq!(node.id_str(), "n10");
|
||||
assert!(node.children().map(|c| c.is_empty()).unwrap_or(true));
|
||||
}
|
||||
other => panic!("expected ReplaceSubtree, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_scaffold_mobile_status_bar_uses_fixed_icon_positions() {
|
||||
let cmds = build_scaffold(&plan(), true).expect("scaffold");
|
||||
|
|
|
|||
|
|
@ -82,12 +82,18 @@ pub(crate) async fn run_subtask_with_reveal_at(
|
|||
reduced_complexity,
|
||||
minimal_skills,
|
||||
);
|
||||
tracing::debug!(subtask = %subtask.id, model = req.model.as_deref().unwrap_or(""), "subagent LLM call");
|
||||
let mut stream = llm.call(call_req);
|
||||
let mut text = String::new();
|
||||
let mut thinking_len = 0usize;
|
||||
while let Some(item) = stream.next().await {
|
||||
match item {
|
||||
Ok(LlmChunk::Text(t)) => text.push_str(&t),
|
||||
Ok(LlmChunk::Thinking(_)) => {}
|
||||
// Thinking models (glm-5.2 etc.) stream reasoning here. We don't
|
||||
// parse it, but track its size: a large reasoning blob with an
|
||||
// empty `text` is the classic "no JSON found" failure mode — the
|
||||
// model spent its budget thinking and never emitted the answer.
|
||||
Ok(LlmChunk::Thinking(t)) => thinking_len += t.len(),
|
||||
Err(e) => {
|
||||
return fail(if e.aborted {
|
||||
"aborted".into()
|
||||
|
|
@ -97,6 +103,12 @@ pub(crate) async fn run_subtask_with_reveal_at(
|
|||
}
|
||||
}
|
||||
}
|
||||
tracing::debug!(
|
||||
subtask = %subtask.id,
|
||||
text_len = text.len(),
|
||||
thinking_len,
|
||||
"subagent text collected"
|
||||
);
|
||||
|
||||
// 解析成 PenNode 树。manifest 模式(按模型路由 + `OPENPENCIL_MANIFEST`
|
||||
// override)先按元素清单解析;文本里没有清单行(如重试梯度回落到裸
|
||||
|
|
@ -116,13 +128,31 @@ pub(crate) async fn run_subtask_with_reveal_at(
|
|||
}
|
||||
None => match parse_nodes(&text) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return fail(e.to_string()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
subtask = %subtask.id,
|
||||
text_len = text.len(),
|
||||
thinking_len,
|
||||
raw = %text,
|
||||
"subagent parse failed (manifest + JSONL both missed)"
|
||||
);
|
||||
return fail(e.to_string());
|
||||
}
|
||||
},
|
||||
}
|
||||
} else {
|
||||
match parse_nodes(&text) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return fail(e.to_string()),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
subtask = %subtask.id,
|
||||
text_len = text.len(),
|
||||
thinking_len,
|
||||
raw = %text,
|
||||
"subagent parse failed"
|
||||
);
|
||||
return fail(e.to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
if is_blank_container_forest(&nodes) {
|
||||
|
|
@ -147,11 +177,48 @@ pub(crate) async fn run_subtask_with_reveal_at(
|
|||
.filter(|c| !c.is_empty());
|
||||
crate::role_defaults::detect_theme_from_fill(first_solid)
|
||||
};
|
||||
// Weak-model deterministic floor (runs BEFORE role resolution so roles land
|
||||
// on the cleaned forest): a subtask is ONE section, but weak models split it
|
||||
// into sibling pieces — an empty wrapper + the content/leaves that belong
|
||||
// inside it. Reparent those back so they don't normalize into separate
|
||||
// full-width page bands (empty banner + floating invisible text, etc.).
|
||||
coalesce_subtask_section(&mut nodes);
|
||||
crate::role_infer::resolve_forest_roles(&mut nodes, canvas_width, theme);
|
||||
// Cross-node contrast post-pass (I3) runs AFTER role resolution (it keys off
|
||||
// the roles I1/I2 set) and before the fallback sizing normalize.
|
||||
crate::role_post_pass::post_pass_forest(&mut nodes, canvas_width);
|
||||
// Post-streaming tree heuristics (TS applyPostStreamingTreeHeuristics parity):
|
||||
// nav-surface anchor, redundant section-fill strip, nested-card decoration
|
||||
// strip. Runs BEFORE binding — the section-fill strip matches literal hedge
|
||||
// HEX that binding would convert to refs. Each forest root is a page-root
|
||||
// child (a section).
|
||||
let page_bg = plan.root_frame.first_solid_hex();
|
||||
// Dominant brand accent from the already-assembled page (prior subtasks live
|
||||
// in the sink) — drives the invisible-band fill so it matches the screen's
|
||||
// real accent (often a chart token) instead of a clashing palette default.
|
||||
let prior_accent = {
|
||||
let st = sink.state();
|
||||
let roots = st
|
||||
.doc
|
||||
.pages
|
||||
.as_ref()
|
||||
.and_then(|p| p.get(st.ui.active_page_index))
|
||||
.map(|pg| pg.children.as_slice())
|
||||
.unwrap_or(st.doc.children.as_slice());
|
||||
crate::tree_heuristics::dominant_design_accent(roots)
|
||||
};
|
||||
crate::tree_heuristics::apply_tree_heuristics(
|
||||
&mut nodes,
|
||||
page_bg.as_deref(),
|
||||
theme == crate::role_defaults::Theme::Light,
|
||||
prior_accent.as_deref(),
|
||||
);
|
||||
crate::variable_binding::bind_generated_color_variables(&mut nodes, sink.state());
|
||||
// Surface-color discipline runs AFTER binding: glm emits raw hex, and binding
|
||||
// is what turns it into the `$color-danger-bg` / `$color-bg-deep` refs this
|
||||
// pass matches (recolor misused state-bg surfaces → neutral, strip the
|
||||
// page-bg token off inner wrappers). Pre-binding it only saw hex and missed.
|
||||
crate::role_post_pass::enforce_surface_color_discipline(&mut nodes);
|
||||
normalize_section_roots_for_parent_layout(&mut nodes);
|
||||
let node_count = nodes.len();
|
||||
|
||||
|
|
@ -369,6 +436,113 @@ fn has_content_node(node: &PenNode) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
/// The content slot a split-out section piece should be reparented into:
|
||||
/// `primary` itself if it is an empty wrapper, else its FIRST empty direct-child
|
||||
/// container (the "Dish Row" / "Card List" the model emitted but left empty).
|
||||
/// Depth is capped at one on purpose — a deeply-nested incidental empty frame
|
||||
/// (an await-input box, a spacer) must not swallow whole sections.
|
||||
fn section_content_slot(primary: &mut PenNode) -> Option<&mut Vec<PenNode>> {
|
||||
let primary_empty = primary.children().map(|c| c.is_empty()).unwrap_or(true);
|
||||
if primary_empty {
|
||||
return primary.children_mut();
|
||||
}
|
||||
primary
|
||||
.children_mut()?
|
||||
.iter_mut()
|
||||
.find(|c| {
|
||||
matches!(c, PenNode::Frame(_) | PenNode::Group(_))
|
||||
&& c.children().map(|cc| cc.is_empty()).unwrap_or(true)
|
||||
})
|
||||
.and_then(|c| c.children_mut())
|
||||
}
|
||||
|
||||
/// Weak-model deterministic floor (TS `applyPostStreamingTreeHeuristics`
|
||||
/// spirit): a subtask maps to ONE section, but glm-class models routinely split
|
||||
/// a section into sibling pieces — an (often empty) wrapper plus the content
|
||||
/// roots that belong inside it (e.g. an empty `Promo Banner` next to a
|
||||
/// `Promo Content` text block and a `Promo Food Image`; an empty `Dish Row` next
|
||||
/// to the dish cards). Each forest root is later normalized into a full-width
|
||||
/// page band ([`normalize_section_roots_for_parent_layout`]), so the wrapper
|
||||
/// renders as a blank gap while its content floats with no background — invisible
|
||||
/// white-on-cream text, the broken Featured/Promo screens the user flagged.
|
||||
///
|
||||
/// Resolution, keyed off the first section container (Frame/Group) as the
|
||||
/// "primary" section:
|
||||
/// - **Wrapper slot present** — the primary is empty, or has an empty direct
|
||||
/// child container waiting for content: reparent every orphan root into that
|
||||
/// slot (forest order preserved). Fixes the empty-banner / empty-row splits.
|
||||
/// - **No slot, orphans all leaves or EMPTY containers** — stray decorations a
|
||||
/// header / section spilled outside itself (a bell icon, a heading, an empty
|
||||
/// notification-badge frame): leaf/heading orphans fold into the primary
|
||||
/// (leading prepended, trailing appended) and childless containers are dropped
|
||||
/// (they would only normalize into a blank full-width band).
|
||||
/// - **No slot, an orphan is a POPULATED container (≥1 child)** — a legitimate
|
||||
/// multi-section forest with nowhere to nest: left untouched so real sections
|
||||
/// are never collapsed into one another.
|
||||
fn coalesce_subtask_section(nodes: &mut Vec<PenNode>) {
|
||||
if nodes.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let Some(primary_idx) = nodes
|
||||
.iter()
|
||||
.position(|n| matches!(n, PenNode::Frame(_) | PenNode::Group(_)))
|
||||
else {
|
||||
return; // no section container to reparent into.
|
||||
};
|
||||
// An orphan is a stray DECORATION to fold (not a real sibling section) when
|
||||
// it's a non-container leaf (icon / badge text), OR an EMPTY container — a
|
||||
// childless `Notification Badge` frame the model hung OUTSIDE the header that
|
||||
// would otherwise normalize into a blank full-width band. A container that
|
||||
// actually holds content (≥1 child) is a genuine section → bail (never
|
||||
// collapse real sibling sections into one another).
|
||||
let kid_count = |n: &PenNode| n.children().map(|c| c.len()).unwrap_or(0);
|
||||
let orphans_all_foldable = nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != primary_idx)
|
||||
.all(|(_, n)| !n.is_container() || kid_count(n) == 0);
|
||||
|
||||
// Split into before / primary / after, preserving forest order. Empty
|
||||
// containers (0 children — junk like a stray empty badge that would
|
||||
// normalize into a blank full-width band) are dropped rather than folded.
|
||||
let taken = std::mem::take(nodes);
|
||||
let mut before: Vec<PenNode> = Vec::new();
|
||||
let mut after: Vec<PenNode> = Vec::new();
|
||||
let mut primary: Option<PenNode> = None;
|
||||
let keep = |n: &PenNode| !n.is_container() || kid_count(n) > 0;
|
||||
for (i, node) in taken.into_iter().enumerate() {
|
||||
match i.cmp(&primary_idx) {
|
||||
std::cmp::Ordering::Less if keep(&node) => before.push(node),
|
||||
std::cmp::Ordering::Equal => primary = Some(node),
|
||||
std::cmp::Ordering::Greater if keep(&node) => after.push(node),
|
||||
_ => {} // dropped empty-container orphan
|
||||
}
|
||||
}
|
||||
let mut primary = primary.expect("primary index was computed from nodes");
|
||||
|
||||
if section_content_slot(&mut primary).is_some() {
|
||||
// Wrapper slot: drop the split-out pieces into it, forest order kept.
|
||||
let slot = section_content_slot(&mut primary).expect("slot present");
|
||||
slot.extend(before);
|
||||
slot.extend(after);
|
||||
} else if orphans_all_foldable {
|
||||
// Stray decorations: heading-before prepends, badge/icon-after appends.
|
||||
if let Some(kids) = primary.children_mut() {
|
||||
let existing = std::mem::take(kids);
|
||||
kids.extend(before);
|
||||
kids.extend(existing);
|
||||
kids.extend(after);
|
||||
}
|
||||
} else {
|
||||
// Legitimate multi-section forest — restore original order, change nothing.
|
||||
nodes.extend(before);
|
||||
nodes.push(primary);
|
||||
nodes.extend(after);
|
||||
return;
|
||||
}
|
||||
nodes.push(primary);
|
||||
}
|
||||
|
||||
fn normalize_section_roots_for_parent_layout(nodes: &mut [PenNode]) {
|
||||
for node in nodes {
|
||||
node.base_mut().x = None;
|
||||
|
|
@ -450,6 +624,155 @@ mod tests {
|
|||
|
||||
const NODE_JSON: &str = r#"[{"type":"frame","id":"hero-1","name":"Card","x":0,"y":0,"width":1200,"height":200,"children":[{"type":"text","id":"hero-title","content":"Hero","fontSize":18}]}]"#;
|
||||
|
||||
#[test]
|
||||
fn coalesce_folds_trailing_badge_leaf_into_lone_section() {
|
||||
// glm shape: a populated Top Bar section frame + a stray cart-count
|
||||
// badge "3" emitted as a SIBLING. No empty slot exists, the orphan is a
|
||||
// leaf → it appends into the section, not survive as a floating "3" band.
|
||||
let json = r#"[
|
||||
{"type":"frame","id":"s1","name":"Top Bar","width":"fill_container","height":"fit_content","layout":"horizontal","children":[
|
||||
{"type":"text","id":"loc","content":"Home"}
|
||||
]},
|
||||
{"type":"text","id":"badge","content":"3"}
|
||||
]"#;
|
||||
let mut nodes: Vec<PenNode> = serde_json::from_str(json).expect("parse forest");
|
||||
coalesce_subtask_section(&mut nodes);
|
||||
assert_eq!(nodes.len(), 1, "badge must fold into the lone section");
|
||||
let kids = nodes[0].children().expect("section children");
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert_eq!(kids[0].id_str(), "loc");
|
||||
assert_eq!(
|
||||
kids[1].id_str(),
|
||||
"badge",
|
||||
"trailing badge nests as last child"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesce_prepends_leading_leaf_as_section_heading() {
|
||||
// A heading text emitted BEFORE a populated content frame folds in as the
|
||||
// FIRST child (preserving the intended heading-above-content order).
|
||||
let json = r#"[
|
||||
{"type":"text","id":"head","content":"Featured"},
|
||||
{"type":"frame","id":"sec","name":"List","children":[{"type":"text","id":"item","content":"x"}]}
|
||||
]"#;
|
||||
let mut nodes: Vec<PenNode> = serde_json::from_str(json).expect("parse forest");
|
||||
coalesce_subtask_section(&mut nodes);
|
||||
assert_eq!(nodes.len(), 1);
|
||||
let kids = nodes[0].children().expect("section children");
|
||||
assert_eq!(
|
||||
kids[0].id_str(),
|
||||
"head",
|
||||
"leading leaf prepended as heading"
|
||||
);
|
||||
assert_eq!(kids[1].id_str(), "item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesce_fills_empty_wrapper_with_split_pieces() {
|
||||
// tt5 Promo shape: an EMPTY `Promo Banner` wrapper emitted first, with
|
||||
// its `Promo Content` (text) + `Promo Food Image` hung as sibling roots.
|
||||
// They must reparent INTO the empty banner (forest order kept) so the
|
||||
// banner stops rendering as a blank gap with floating invisible text.
|
||||
let json = r#"[
|
||||
{"type":"frame","id":"banner","name":"Promo Banner","layout":"vertical","children":[]},
|
||||
{"type":"frame","id":"content","name":"Promo Content","children":[{"type":"text","id":"title","content":"Get 30% off"}]},
|
||||
{"type":"image","id":"img","name":"Promo Food Image","src":""}
|
||||
]"#;
|
||||
let mut nodes: Vec<PenNode> = serde_json::from_str(json).expect("parse forest");
|
||||
coalesce_subtask_section(&mut nodes);
|
||||
assert_eq!(nodes.len(), 1, "split pieces fold into the empty wrapper");
|
||||
assert_eq!(nodes[0].id_str(), "banner");
|
||||
let kids = nodes[0].children().expect("banner children");
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert_eq!(kids[0].id_str(), "content");
|
||||
assert_eq!(kids[1].id_str(), "img");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesce_fills_empty_direct_child_row_with_orphan_cards() {
|
||||
// tt5 Popular Dishes shape: section with a Header + an EMPTY `Dish Row`
|
||||
// direct child, and the dish cards hung as sibling roots. The cards must
|
||||
// land inside the empty row, not flatten into separate page bands.
|
||||
let json = r#"[
|
||||
{"type":"frame","id":"sec","name":"Popular Dishes","children":[
|
||||
{"type":"frame","id":"header","name":"Header","children":[{"type":"text","id":"t","content":"Popular Dishes"}]},
|
||||
{"type":"frame","id":"row","name":"Dish Row","children":[]}
|
||||
]},
|
||||
{"type":"image","id":"pizza","name":"Margherita Pizza","src":""},
|
||||
{"type":"frame","id":"pinfo","name":"Info","children":[{"type":"text","id":"pn","content":"Margherita"}]}
|
||||
]"#;
|
||||
let mut nodes: Vec<PenNode> = serde_json::from_str(json).expect("parse forest");
|
||||
coalesce_subtask_section(&mut nodes);
|
||||
assert_eq!(
|
||||
nodes.len(),
|
||||
1,
|
||||
"cards fold into the section, not the page root"
|
||||
);
|
||||
let sec_kids = nodes[0].children().expect("section children");
|
||||
assert_eq!(sec_kids.len(), 2, "Header + Dish Row preserved");
|
||||
let row = &sec_kids[1];
|
||||
assert_eq!(row.id_str(), "row");
|
||||
let row_kids = row.children().expect("dish row children");
|
||||
assert_eq!(
|
||||
row_kids.len(),
|
||||
2,
|
||||
"both orphan cards landed in the empty row"
|
||||
);
|
||||
assert_eq!(row_kids[0].id_str(), "pizza");
|
||||
assert_eq!(row_kids[1].id_str(), "pinfo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesce_leaves_populated_multi_section_forest_untouched() {
|
||||
// Two POPULATED section containers with no empty slot → a legitimate
|
||||
// multi-section forest; never collapse real sections into one another.
|
||||
let json = r#"[
|
||||
{"type":"frame","id":"a","name":"A","children":[{"type":"text","id":"ta","content":"A"}]},
|
||||
{"type":"frame","id":"b","name":"B","children":[{"type":"text","id":"tb","content":"B"}]}
|
||||
]"#;
|
||||
let mut nodes: Vec<PenNode> = serde_json::from_str(json).expect("parse forest");
|
||||
coalesce_subtask_section(&mut nodes);
|
||||
assert_eq!(nodes.len(), 2, "two populated sections must be left as-is");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesce_folds_stray_icon_and_drops_empty_badge() {
|
||||
// tt5 header: the Bell icon (leaf) + an EMPTY Notification Badge frame
|
||||
// were emitted as top-level SIBLINGS of the header (bell floated below
|
||||
// the search; the empty badge would normalize into a blank full-width
|
||||
// band). The icon folds into the header; the empty badge is dropped —
|
||||
// neither survives as a floating page section.
|
||||
let json = r#"[
|
||||
{"type":"frame","id":"hdr","name":"Header & Search","children":[
|
||||
{"type":"frame","id":"loc","name":"Location & Actions","children":[{"type":"text","id":"l","content":"NYC"}]},
|
||||
{"type":"frame","id":"sb","name":"Search Bar","children":[{"type":"text_input","id":"si","placeholder":"Search"}]}
|
||||
]},
|
||||
{"type":"icon_font","id":"bell","iconFontName":"bell"},
|
||||
{"type":"frame","id":"badge","name":"Notification Badge","children":[]}
|
||||
]"#;
|
||||
let mut nodes: Vec<PenNode> = serde_json::from_str(json).expect("parse forest");
|
||||
coalesce_subtask_section(&mut nodes);
|
||||
assert_eq!(
|
||||
nodes.len(),
|
||||
1,
|
||||
"only the header section survives at top level"
|
||||
);
|
||||
let kids = nodes[0].children().expect("header children");
|
||||
assert!(
|
||||
kids.iter().any(|k| matches!(k, PenNode::IconFont(_))),
|
||||
"stray bell icon folded into the header"
|
||||
);
|
||||
let names: Vec<&str> = kids
|
||||
.iter()
|
||||
.filter_map(|k| k.base().name.as_deref())
|
||||
.collect();
|
||||
assert!(
|
||||
!names.contains(&"Notification Badge"),
|
||||
"empty badge dropped, not folded as a blank band"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_subtask_ok_applies_insert_subtree() {
|
||||
let llm = ScriptedLlm::new(vec![ScriptResponse::Text(NODE_JSON.into())]);
|
||||
|
|
|
|||
1020
crates/op-orchestrator/src/tree_heuristics.rs
Normal file
1020
crates/op-orchestrator/src/tree_heuristics.rs
Normal file
File diff suppressed because it is too large
Load diff
728
crates/op-orchestrator/src/tree_heuristics_tests.rs
Normal file
728
crates/op-orchestrator/src/tree_heuristics_tests.rs
Normal file
|
|
@ -0,0 +1,728 @@
|
|||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── inject_missing_nav_surface_fill ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn nav_without_fill_gets_surface_and_upward_shadow() {
|
||||
let mut nav = json!({
|
||||
"type":"frame","role":"bottom-tab-bar",
|
||||
"children":[{"type":"frame","role":"button","name":"Home"}]
|
||||
});
|
||||
inject_nav_surface_for_section(&mut nav);
|
||||
assert_eq!(
|
||||
nav["fill"],
|
||||
json!([{"type":"solid","color":"$color-surface"}]),
|
||||
"transparent bottom nav anchored with surface fill"
|
||||
);
|
||||
// bottom nav → shadow points up (offsetY < 0)
|
||||
assert_eq!(nav["effects"][0]["offsetY"], json!(-4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_with_existing_fill_left_alone() {
|
||||
let mut nav = json!({
|
||||
"type":"frame","role":"bottom-tab-bar","fill":[{"type":"solid","color":"#222222"}],
|
||||
"children":[{"type":"frame","role":"button"}]
|
||||
});
|
||||
inject_nav_surface_for_section(&mut nav);
|
||||
assert_eq!(
|
||||
nav["fill"],
|
||||
json!([{"type":"solid","color":"#222222"}]),
|
||||
"explicit nav fill preserved (intent signal)"
|
||||
);
|
||||
assert!(
|
||||
nav.get("effects").is_none(),
|
||||
"no shadow stamped over intent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_wrapped_in_single_child_section_reached_one_hop() {
|
||||
let mut wrap = json!({
|
||||
"type":"frame","role":"section",
|
||||
"children":[{"type":"frame","role":"bottom-tab-bar","children":[{"type":"frame"}]}]
|
||||
});
|
||||
inject_nav_surface_for_section(&mut wrap);
|
||||
assert_eq!(
|
||||
wrap["children"][0]["fill"],
|
||||
json!([{"type":"solid","color":"$color-surface"}]),
|
||||
"nav nested one hop under a wrapper section still anchored"
|
||||
);
|
||||
}
|
||||
|
||||
// ── round_active_nav_tab ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn active_nav_tab_square_rounded_to_pill() {
|
||||
// tt5: the active tab is a solid-filled frame with NO cornerRadius → a sharp
|
||||
// square poking out of the rounded nav pill. Round it to a pill; leave the
|
||||
// inactive (fill-less) tab and the already-rounded bar container alone.
|
||||
let mut nav = json!({
|
||||
"type":"frame","role":"bottom-tab-bar","cornerRadius":100,
|
||||
"fill":[{"type":"solid","color":"$color-surface"}],
|
||||
"children":[
|
||||
{"type":"frame","name":"Home Tab","fill":[{"type":"solid","color":"$color-accent"}],
|
||||
"children":[{"type":"icon_font","iconFontName":"home"},{"type":"text","content":"Home"}]},
|
||||
{"type":"frame","name":"Search Tab",
|
||||
"children":[{"type":"icon_font","iconFontName":"search"},{"type":"text","content":"Search"}]}
|
||||
]
|
||||
});
|
||||
round_active_nav_tab(&mut nav, "$color-chart-6");
|
||||
assert_eq!(
|
||||
nav["children"][0]["cornerRadius"],
|
||||
json!(999.0),
|
||||
"active filled tab rounded to a pill"
|
||||
);
|
||||
assert!(
|
||||
nav["children"][1].get("cornerRadius").is_none(),
|
||||
"inactive fill-less tab untouched"
|
||||
);
|
||||
assert_eq!(
|
||||
nav["cornerRadius"],
|
||||
json!(100),
|
||||
"the bar container's own radius is preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rounded_nav_bar_gets_clip_content() {
|
||||
// A full-height sharp active block can be TALLER than the bar — rounding it
|
||||
// alone can't stop the vertical overflow. A rounded-pill bar must clip its
|
||||
// children to the pill silhouette (image 52: the orange HOME square pokes
|
||||
// above + below the white pill).
|
||||
let mut nav = json!({
|
||||
"type":"frame","role":"bottom-tab-bar","cornerRadius":32,
|
||||
"fill":[{"type":"solid","color":"$color-surface"}],
|
||||
"children":[
|
||||
{"type":"frame","name":"Home Tab","height":"fill_container",
|
||||
"fill":[{"type":"solid","color":"$color-accent"}],
|
||||
"children":[{"type":"text","content":"Home"}]}
|
||||
]
|
||||
});
|
||||
round_active_nav_tab(&mut nav, "$color-chart-6");
|
||||
assert_eq!(
|
||||
nav["clipContent"],
|
||||
json!(true),
|
||||
"rounded bar clips children so a tall active block can't overflow"
|
||||
);
|
||||
assert_eq!(
|
||||
nav["children"][0]["cornerRadius"],
|
||||
json!(999.0),
|
||||
"the filled active tab is still rounded to a pill"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_pipeline_rounds_nested_active_tab_in_pill() {
|
||||
// tt8 (image 53) EXACT structure: bottom-tab-bar > Nav Pill(cr=100) >
|
||||
// Home Tab(fill orange, no radius, fill_container/vertical). Run the FULL
|
||||
// apply_tree_heuristics (incl. the PenNode JSON round-trip) and confirm the
|
||||
// active Home Tab actually comes out rounded to a pill — a square here is
|
||||
// the sharp block overflowing the rounded pill the user keeps seeing.
|
||||
let mut forest: Vec<PenNode> = serde_json::from_value(json!([{
|
||||
"type":"frame","id":"bar","role":"bottom-tab-bar","name":"Bottom Navigation",
|
||||
"width":"fill_container","fill":[{"type":"solid","color":"$color-surface"}],
|
||||
"children":[{
|
||||
"type":"frame","id":"pill","name":"Nav Pill","width":"fill_container","layout":"horizontal",
|
||||
"cornerRadius":100,"fill":[{"type":"solid","color":"$color-surface"}],
|
||||
"children":[
|
||||
{"type":"frame","id":"home","name":"Home Tab","width":"fill_container","height":"fit_content",
|
||||
"layout":"vertical","fill":[{"type":"solid","color":"$color-chart-6"}],
|
||||
"children":[
|
||||
{"type":"icon_font","id":"hi","name":"Home Icon","iconFontName":"home","width":20,"height":20},
|
||||
{"type":"text","id":"hl","name":"Home Label","content":"HOME"}
|
||||
]},
|
||||
{"type":"frame","id":"search","name":"Search Tab","width":"fill_container","height":"fit_content",
|
||||
"layout":"vertical","children":[
|
||||
{"type":"icon_font","id":"si","name":"Search Icon","iconFontName":"search","width":20,"height":20}
|
||||
]}
|
||||
]
|
||||
}]
|
||||
}]))
|
||||
.expect("nav forest json");
|
||||
apply_tree_heuristics(&mut forest, Some("#FFF8F0"), true, Some("$color-chart-6"));
|
||||
let out = serde_json::to_value(&forest[0]).expect("serialize");
|
||||
let home = &out["children"][0]["children"][0];
|
||||
assert_eq!(home["name"], json!("Home Tab"));
|
||||
assert_eq!(
|
||||
home["cornerRadius"],
|
||||
json!(999.0),
|
||||
"filled active Home Tab must round to a pill end-to-end (got {:?})",
|
||||
home.get("cornerRadius")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_pipeline_rounds_manifest_nav_item_active() {
|
||||
// glm-5.1 / manifest (element-builder) nav: bottom-tab-bar > nav-item-active
|
||||
// tab (role="nav-item-active", width=fit_content) carrying an accent fill
|
||||
// (the orange active block). This is the structure the USER actually gets
|
||||
// (their model is glm-5.1, the manifest path) — distinct from glm-5.2's
|
||||
// `bottom-tab-bar > Nav Pill > Home Tab`. The active tab must round to a pill.
|
||||
let mut forest: Vec<PenNode> = serde_json::from_value(json!([{
|
||||
"type":"frame","id":"bar","role":"bottom-tab-bar","name":"Bottom Tab Bar",
|
||||
"width":"fill_container","cornerRadius":100,
|
||||
"fill":[{"type":"solid","color":"$color-surface"}],
|
||||
"children":[
|
||||
{"type":"frame","id":"home","role":"nav-item-active","name":"Tab (Home)",
|
||||
"width":"fit_content","height":"fit_content","layout":"vertical","padding":[4,12],
|
||||
"fill":[{"type":"solid","color":"$color-chart-6"}],
|
||||
"children":[
|
||||
{"type":"icon_font","id":"hi","name":"Icon","iconFontName":"house","width":24,"height":24},
|
||||
{"type":"text","id":"hl","name":"Label","content":"Home"}
|
||||
]},
|
||||
{"type":"frame","id":"search","role":"nav-item","name":"Tab (Search)",
|
||||
"width":"fit_content","height":"fit_content","layout":"vertical","padding":[4,12],
|
||||
"children":[
|
||||
{"type":"icon_font","id":"si","name":"Icon","iconFontName":"search","width":24,"height":24}
|
||||
]}
|
||||
]
|
||||
}]))
|
||||
.expect("manifest nav json");
|
||||
apply_tree_heuristics(&mut forest, Some("#FFF8F0"), true, Some("$color-chart-6"));
|
||||
let out = serde_json::to_value(&forest[0]).expect("serialize");
|
||||
let active = &out["children"][0];
|
||||
assert_eq!(active["role"], json!("nav-item-active"));
|
||||
assert_eq!(
|
||||
active["cornerRadius"],
|
||||
json!(999.0),
|
||||
"manifest nav-item-active tab must round to a pill (got {:?})",
|
||||
active.get("cornerRadius")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_pipeline_rounds_user_tt5_exact_nav() {
|
||||
// The USER's actual tt5.op (glm-5.2): bottom-tab-bar(vertical) > Tab
|
||||
// Pill(role=None, cr=100, horizontal) > Home Tab(role=None, fill_container,
|
||||
// vertical, cornerRadius=0.0, $color-chart-6 fill, white icon/label). This is
|
||||
// the EXACT square the user keeps seeing. Run the full pipeline; the active
|
||||
// Home Tab must come out rounded (cornerRadius=999).
|
||||
let src = r##"[{
|
||||
"type":"frame","id":"bar","role":"bottom-tab-bar","name":"Bottom Nav",
|
||||
"width":"fill_container","height":"fit_content","layout":"vertical",
|
||||
"fill":[{"type":"solid","color":"$color-surface"}],
|
||||
"children":[{
|
||||
"type":"frame","id":"pill","name":"Tab Pill",
|
||||
"width":"fill_container","height":"fit_content","layout":"horizontal","cornerRadius":100,
|
||||
"fill":[{"type":"solid","color":"$color-surface"}],
|
||||
"children":[
|
||||
{"type":"frame","id":"home","name":"Home Tab",
|
||||
"width":"fill_container","height":"fit_content","layout":"vertical","cornerRadius":0.0,
|
||||
"fill":[{"type":"solid","color":"$color-chart-6"}],
|
||||
"children":[
|
||||
{"type":"icon_font","id":"hi","name":"Home Icon","iconFontName":"home","width":18,"height":18,
|
||||
"fill":[{"type":"solid","color":"$color-surface"}]},
|
||||
{"type":"text","id":"hl","name":"Home Label","content":"HOME",
|
||||
"fill":[{"type":"solid","color":"$color-surface"}]}
|
||||
]},
|
||||
{"type":"frame","id":"sr","name":"Search Tab","role":"search-bar",
|
||||
"width":"fill_container","height":"fit_content","layout":"vertical","cornerRadius":26,
|
||||
"children":[
|
||||
{"type":"icon_font","id":"si","name":"Search Icon","iconFontName":"search","width":18,"height":18}
|
||||
]}
|
||||
]
|
||||
}]
|
||||
}]"##;
|
||||
let mut forest: Vec<PenNode> = serde_json::from_str(src).expect("user nav json");
|
||||
apply_tree_heuristics(&mut forest, Some("#FFF8F0"), true, Some("$color-chart-6"));
|
||||
let out = serde_json::to_value(&forest[0]).expect("serialize");
|
||||
let home = &out["children"][0]["children"][0];
|
||||
assert_eq!(home["name"], json!("Home Tab"));
|
||||
assert_eq!(
|
||||
home["cornerRadius"],
|
||||
json!(999.0),
|
||||
"user's exact square Home Tab must round to a pill (got {:?})",
|
||||
home.get("cornerRadius")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_highlight_rect_in_nav_rounded() {
|
||||
// The active highlight is sometimes a bare filled rect (no icon/label
|
||||
// children) sitting behind the tab content. It must still round to a pill.
|
||||
let mut nav = json!({
|
||||
"type":"frame","role":"bottom-tab-bar","cornerRadius":28,
|
||||
"children":[
|
||||
{"type":"frame","name":"Tab Row","width":"fill_container","layout":"horizontal",
|
||||
"children":[
|
||||
{"type":"frame","name":"Active Highlight",
|
||||
"fill":[{"type":"solid","color":"$color-accent"}]}
|
||||
]}
|
||||
]
|
||||
});
|
||||
round_active_nav_tab(&mut nav, "$color-chart-6");
|
||||
assert_eq!(
|
||||
nav["children"][0]["children"][0]["cornerRadius"],
|
||||
json!(999.0),
|
||||
"bare filled highlight rect rounded to a pill"
|
||||
);
|
||||
assert!(
|
||||
nav["children"][0].get("cornerRadius").is_none(),
|
||||
"the full-width horizontal tab ROW is not rounded"
|
||||
);
|
||||
}
|
||||
|
||||
// ── strip_redundant_section_fill ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn section_safe_light_hedge_fill_stripped_with_chrome() {
|
||||
// Weak-model "search section" wrapper: white hedge fill + pill chrome.
|
||||
let mut sect = json!({
|
||||
"type":"frame","role":"section","fill":[{"type":"solid","color":"#FFFFFF"}],
|
||||
"stroke":{"thickness":1,"fill":[{"type":"solid","color":"#E2E8F0"}]},
|
||||
"cornerRadius":16,
|
||||
"children":[{"type":"text","content":"x"}]
|
||||
});
|
||||
strip_redundant_section_fill(&mut sect, None);
|
||||
assert!(sect.get("fill").is_none(), "hedge fill stripped");
|
||||
assert!(sect.get("stroke").is_none(), "wrapper stroke stripped");
|
||||
assert!(
|
||||
sect.get("cornerRadius").is_none(),
|
||||
"wrapper radius stripped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_dark_hedge_fill_stripped() {
|
||||
let mut sect = json!({
|
||||
"type":"frame","role":"section","fill":[{"type":"solid","color":"#0A0A0A"}],
|
||||
"children":[{"type":"text"}]
|
||||
});
|
||||
strip_redundant_section_fill(&mut sect, None);
|
||||
assert!(sect.get("fill").is_none(), "safe-dark hedge stripped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_fill_protected_from_strip() {
|
||||
let mut card = json!({
|
||||
"type":"frame","role":"card","fill":[{"type":"solid","color":"#FFFFFF"}],
|
||||
"children":[{"type":"text","content":"x"}]
|
||||
});
|
||||
strip_redundant_section_fill(&mut card, None);
|
||||
assert_eq!(
|
||||
card["fill"],
|
||||
json!([{"type":"solid","color":"#FFFFFF"}]),
|
||||
"card is a protected surface, not a section"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn misrolled_search_bar_wrapper_stripped() {
|
||||
// sub-agent emits Search Bar(role=search-bar) > Search Input(role=input,fill)
|
||||
// — the outer "search-bar" is a section wrapper, strip its hedge fill.
|
||||
let mut wrap = json!({
|
||||
"type":"frame","role":"search-bar","fill":[{"type":"solid","color":"#F8FAFC"}],
|
||||
"children":[{"type":"text_input","role":"input","fill":[{"type":"solid","color":"#FFFFFF"}]}]
|
||||
});
|
||||
strip_redundant_section_fill(&mut wrap, None);
|
||||
assert!(
|
||||
wrap.get("fill").is_none(),
|
||||
"misrolled search-bar wrapper around a real input → fill stripped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_fill_matching_page_bg_stripped() {
|
||||
let mut sect = json!({
|
||||
"type":"frame","role":"section","fill":[{"type":"solid","color":"#FFF8F0"}],
|
||||
"children":[{"type":"text"}]
|
||||
});
|
||||
strip_redundant_section_fill(&mut sect, Some("#FFF8F0"));
|
||||
assert!(
|
||||
sect.get("fill").is_none(),
|
||||
"section repainting the page bg is redundant → stripped"
|
||||
);
|
||||
}
|
||||
|
||||
// ── clip_card_image_corners ──────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn card_image_corners_clipped() {
|
||||
let mut card = json!({
|
||||
"type":"frame","role":"card","cornerRadius":12,"children":[
|
||||
{"type":"image","name":"Photo","cornerRadius":12},
|
||||
{"type":"text","content":"Title"}
|
||||
]
|
||||
});
|
||||
clip_card_image_corners(&mut card);
|
||||
assert_eq!(
|
||||
card["clipContent"],
|
||||
json!(true),
|
||||
"card clips to its outer radius"
|
||||
);
|
||||
assert!(
|
||||
card["children"][0].get("cornerRadius").is_none(),
|
||||
"image's own radius removed (card clips instead)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_image_not_clipped() {
|
||||
// single image (no title sibling) → not a card-image pattern, left alone.
|
||||
let mut frame = json!({
|
||||
"type":"frame","cornerRadius":12,"children":[
|
||||
{"type":"image","cornerRadius":12}
|
||||
]
|
||||
});
|
||||
clip_card_image_corners(&mut frame);
|
||||
assert!(
|
||||
frame.get("clipContent").is_none(),
|
||||
"1-child image frame untouched"
|
||||
);
|
||||
}
|
||||
|
||||
// ── fill_card_leading_image_width ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dish_card_leading_image_fills_width() {
|
||||
// 160 px image inside a 252 px vertical dish card → white gap on the
|
||||
// right. The header image should span the card width.
|
||||
let mut card = json!({
|
||||
"type":"frame","layout":"vertical","width":252.0,"children":[
|
||||
{"type":"image","name":"Photo","width":160.0,"height":120.0},
|
||||
{"type":"frame","name":"Info","children":[{"type":"text","content":"Truffle Burger"}]}
|
||||
]
|
||||
});
|
||||
fill_card_leading_image_width(&mut card);
|
||||
assert_eq!(
|
||||
card["children"][0]["width"],
|
||||
json!("fill_container"),
|
||||
"leading header image spans the card width"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_leading_avatar_not_widened() {
|
||||
// a centered leading image is an avatar / logo, not a full-bleed header.
|
||||
let mut card = json!({
|
||||
"type":"frame","layout":"vertical","alignItems":"center","children":[
|
||||
{"type":"image","name":"Avatar","width":96.0,"height":96.0,"cornerRadius":48.0},
|
||||
{"type":"text","content":"Jane Doe"}
|
||||
]
|
||||
});
|
||||
fill_card_leading_image_width(&mut card);
|
||||
assert_eq!(
|
||||
card["children"][0]["width"],
|
||||
json!(96.0),
|
||||
"centered avatar keeps its fixed width"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_card_side_image_not_widened() {
|
||||
// image-left / text-right row → the leading image is a side thumbnail.
|
||||
let mut row = json!({
|
||||
"type":"frame","layout":"horizontal","children":[
|
||||
{"type":"image","name":"Thumb","width":120.0,"height":120.0},
|
||||
{"type":"text","content":"Order #1"}
|
||||
]
|
||||
});
|
||||
fill_card_leading_image_width(&mut row);
|
||||
assert_eq!(
|
||||
row["children"][0]["width"],
|
||||
json!(120.0),
|
||||
"horizontal-row side image keeps its fixed width"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_leading_icon_not_widened() {
|
||||
// a small (< 80 px) leading image is an inline icon/badge, left alone.
|
||||
let mut card = json!({
|
||||
"type":"frame","layout":"vertical","children":[
|
||||
{"type":"image","name":"Icon","width":48.0,"height":48.0},
|
||||
{"type":"text","content":"Label"}
|
||||
]
|
||||
});
|
||||
fill_card_leading_image_width(&mut card);
|
||||
assert_eq!(
|
||||
card["children"][0]["width"],
|
||||
json!(48.0),
|
||||
"small leading icon keeps its fixed width"
|
||||
);
|
||||
}
|
||||
|
||||
// ── fix_notification_badge_overlay ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn notification_badge_becomes_corner_circle() {
|
||||
// live structure: 44×44 horizontal button > [8×8 filled dot, 22×22 bell].
|
||||
let mut button = json!({
|
||||
"type":"frame","name":"Notification Button","width":44.0,"height":44.0,
|
||||
"layout":"horizontal","gap":8.0,"cornerRadius":12.0,
|
||||
"children":[
|
||||
{"type":"frame","name":"Badge","width":8.0,"height":8.0,
|
||||
"fill":[{"type":"solid","color":"$color-chart-6"}],"children":[]},
|
||||
{"type":"icon_font","name":"Bell","iconFontName":"bell","width":22.0,"height":22.0}
|
||||
]
|
||||
});
|
||||
fix_notification_badge_overlay(&mut button);
|
||||
assert_eq!(
|
||||
button["layout"],
|
||||
json!("none"),
|
||||
"button switches to absolute"
|
||||
);
|
||||
assert!(button.get("gap").is_none(), "flex gap dropped");
|
||||
// badge: rounded to a circle, pinned to the bell's top-right corner.
|
||||
let badge = &button["children"][0];
|
||||
assert_eq!(
|
||||
badge["cornerRadius"],
|
||||
json!(4.0),
|
||||
"square dot rounded to a circle"
|
||||
);
|
||||
assert_eq!(badge["x"], json!(29.0), "badge x at bell top-right");
|
||||
assert_eq!(badge["y"], json!(7.0), "badge y at bell top-right");
|
||||
// bell: centered in the 44×44 button.
|
||||
let bell = &button["children"][1];
|
||||
assert_eq!(bell["x"], json!(11.0), "bell centered x");
|
||||
assert_eq!(bell["y"], json!(11.0), "bell centered y");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_like_frame_not_treated_as_badge() {
|
||||
// a non-square pill (44×24) is a switch/toggle, not an icon button.
|
||||
let mut sw = json!({
|
||||
"type":"frame","width":44.0,"height":24.0,"layout":"horizontal","cornerRadius":12.0,
|
||||
"children":[
|
||||
{"type":"ellipse","width":20.0,"height":20.0,"fill":[{"type":"solid","color":"#fff"}]},
|
||||
{"type":"icon_font","iconFontName":"check","width":12.0,"height":12.0}
|
||||
]
|
||||
});
|
||||
let before = sw.clone();
|
||||
fix_notification_badge_overlay(&mut sw);
|
||||
assert_eq!(sw, before, "non-square pill left untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_button_without_dot_unchanged() {
|
||||
// a plain icon button (icon + text label) is not a badge pattern.
|
||||
let mut btn = json!({
|
||||
"type":"frame","width":44.0,"height":44.0,"layout":"horizontal",
|
||||
"children":[
|
||||
{"type":"icon_font","iconFontName":"heart","width":22.0,"height":22.0},
|
||||
{"type":"text","content":"12"}
|
||||
]
|
||||
});
|
||||
let before = btn.clone();
|
||||
fix_notification_badge_overlay(&mut btn);
|
||||
assert_eq!(btn, before, "icon+text button left untouched");
|
||||
}
|
||||
|
||||
// ── convert_stacked_overlay_to_absolute ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn stacked_overlay_switches_to_absolute() {
|
||||
// full-bleed image + gradient overlay (both full-height) in a vertical
|
||||
// fixed-height frame → layered hero → layout:none (fixes the collapsed
|
||||
// "Featured Restaurants" promo card: image h=0 + content overflow).
|
||||
let mut hero = json!({
|
||||
"type":"frame","layout":"vertical","height":180.0,"children":[
|
||||
{"type":"image","height":180.0},
|
||||
{"type":"frame","name":"Gradient","height":"fill_container"},
|
||||
{"type":"frame","name":"Content","children":[{"type":"text","content":"30% Off"}]}
|
||||
]
|
||||
});
|
||||
convert_stacked_overlay_to_absolute(&mut hero);
|
||||
assert_eq!(
|
||||
hero["layout"],
|
||||
json!("none"),
|
||||
"layered hero (2+ full-height bg children) → absolute stacking"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_vertical_stack_not_converted() {
|
||||
// a normal content stack (1 image, rest text) → stays vertical.
|
||||
let mut col = json!({
|
||||
"type":"frame","layout":"vertical","height":200.0,"children":[
|
||||
{"type":"image","height":120.0},
|
||||
{"type":"text","content":"Title"},
|
||||
{"type":"text","content":"Body"}
|
||||
]
|
||||
});
|
||||
convert_stacked_overlay_to_absolute(&mut col);
|
||||
assert_eq!(
|
||||
col["layout"],
|
||||
json!("vertical"),
|
||||
"only 1 bg-like child → not a hero overlay, left alone"
|
||||
);
|
||||
}
|
||||
|
||||
// ── dominant_design_accent ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn dominant_accent_prefers_most_used_chart_token() {
|
||||
// glm uses $color-chart-6 as the de-facto accent (3×) over $color-accent (1×).
|
||||
let nodes: Vec<PenNode> = vec![serde_json::from_value(json!({
|
||||
"type":"frame","id":"r","name":"Page","children":[
|
||||
{"type":"frame","id":"b","name":"Btn","role":"button","fill":[{"type":"solid","color":"$color-chart-6"}]},
|
||||
{"type":"frame","id":"c","name":"Chip","role":"chip","fill":[{"type":"solid","color":"$color-chart-6"}]},
|
||||
{"type":"text","id":"t","name":"T","content":"x","fill":[{"type":"solid","color":"$color-chart-6"}]},
|
||||
{"type":"frame","id":"a","name":"A","fill":[{"type":"solid","color":"$color-accent"}]}
|
||||
]
|
||||
}))
|
||||
.unwrap()];
|
||||
assert_eq!(
|
||||
dominant_design_accent(&nodes).as_deref(),
|
||||
Some("$color-chart-6"),
|
||||
"the most-used accent token wins over the palette default"
|
||||
);
|
||||
}
|
||||
|
||||
// ── strip_nested_card_decoration ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn nested_card_redundant_shadow_and_radius_stripped() {
|
||||
let mut outer = json!({
|
||||
"type":"frame","role":"card","cornerRadius":12,
|
||||
"effects":[{"type":"shadow","offsetX":0,"offsetY":1,"blur":3,"color":"#0000001A"}],
|
||||
"children":[{
|
||||
"type":"frame","role":"card","cornerRadius":10,
|
||||
"effects":[{"type":"shadow","offsetX":0,"offsetY":1,"blur":2,"color":"#0000000F"}],
|
||||
"children":[{"type":"text","content":"hi"}]
|
||||
}]
|
||||
});
|
||||
strip_nested_card_decoration(&mut outer, DecoFlags::default());
|
||||
let inner = &outer["children"][0];
|
||||
assert!(
|
||||
inner.get("effects").is_none(),
|
||||
"inner card's redundant shadow stripped (kills box-in-box)"
|
||||
);
|
||||
assert!(
|
||||
inner.get("cornerRadius").is_none(),
|
||||
"inner card's redundant radius stripped"
|
||||
);
|
||||
// outer keeps its decoration
|
||||
assert_eq!(outer["cornerRadius"], json!(12));
|
||||
}
|
||||
|
||||
// ── fix_invisible_text_band (glm banner-fill floor) ──────────────────────
|
||||
|
||||
#[test]
|
||||
fn invisible_white_text_band_gets_accent_on_light_page() {
|
||||
// glm's transparent promo banner: white copy, no fill → invisible on cream.
|
||||
let mut band = json!({"type":"frame","role":"section","children":[
|
||||
{"type":"text","content":"50% OFF","fill":[{"type":"solid","color":"$color-surface"}]},
|
||||
{"type":"text","content":"first order","fill":[{"type":"solid","color":"#FFFFFF"}]}
|
||||
]});
|
||||
fix_invisible_text_band(&mut band, true, "$color-accent");
|
||||
assert_eq!(
|
||||
band["fill"],
|
||||
json!([{"type":"solid","color":"$color-accent"}]),
|
||||
"all-white-text band on a light page → accent fill so copy is readable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn banner_with_filled_button_still_filled() {
|
||||
// white headline + an "Order Now" button (own white fill, accent text). The
|
||||
// button's accent text sits on its OWN surface → must not count → the
|
||||
// all-light headline alone triggers the banner fill. (gen9 Promo Content.)
|
||||
let mut band = json!({"type":"frame","role":"section","children":[
|
||||
{"type":"text","content":"50% OFF","fill":[{"type":"solid","color":"$color-surface"}]},
|
||||
{"type":"text","content":"Code: X","fill":[{"type":"solid","color":"$color-surface-2"}]},
|
||||
{"type":"frame","role":"button","fill":[{"type":"solid","color":"$color-surface"}],"children":[
|
||||
{"type":"text","content":"Order Now","fill":[{"type":"solid","color":"$color-accent"}]}
|
||||
]}
|
||||
]});
|
||||
fix_invisible_text_band(&mut band, true, "$color-accent");
|
||||
assert_eq!(
|
||||
band["fill"],
|
||||
json!([{"type":"solid","color":"$color-accent"}]),
|
||||
"button's accent text excluded (own surface) → light headline → filled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_surface_filled_banner_repainted_accent() {
|
||||
// tt5: a feature-card carrying a `$color-surface` (light) fill with white
|
||||
// headline + subtext + a translucent-white badge + a dark CTA button. The
|
||||
// light surface makes the white headline INVISIBLE. The light-surface fill
|
||||
// must be treated like no fill → repaint with the design accent.
|
||||
let mut band = json!({
|
||||
"type":"frame","role":"feature-card","cornerRadius":12,
|
||||
"fill":[{"type":"solid","color":"$color-surface"}],
|
||||
"children":[
|
||||
{"type":"frame","name":"Promo Content","children":[
|
||||
{"type":"frame","role":"badge","fill":[{"type":"solid","color":"#FFFFFF30"}],"children":[
|
||||
{"type":"text","content":"FLASH","fill":[{"type":"solid","color":"$color-surface"}]}
|
||||
]},
|
||||
{"type":"text","content":"Get 30% off","fill":[{"type":"solid","color":"$color-surface"}]},
|
||||
{"type":"text","content":"first order","fill":[{"type":"solid","color":"#FFFFFFCC"}]},
|
||||
{"type":"frame","role":"button","fill":[{"type":"solid","color":"#1C1410"}],"children":[
|
||||
{"type":"text","content":"Order Now","fill":[{"type":"solid","color":"$color-surface"}]}
|
||||
]}
|
||||
]}
|
||||
]
|
||||
});
|
||||
fix_invisible_text_band(&mut band, true, "$color-chart-6");
|
||||
assert_eq!(
|
||||
band["fill"],
|
||||
json!([{"type":"solid","color":"$color-chart-6"}]),
|
||||
"light-surface banner with all-white copy → repainted with the accent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_filled_banner_left_alone() {
|
||||
// A banner that already paints a real colored surface (a gradient) reads its
|
||||
// white text fine — must NOT be repainted.
|
||||
let mut band = json!({
|
||||
"type":"frame","role":"feature-card",
|
||||
"fill":[{"type":"linear_gradient","angle":135,"stops":[
|
||||
{"offset":0.0,"color":"$color-chart-6"},{"offset":1.0,"color":"#FB923C"}]}],
|
||||
"children":[
|
||||
{"type":"text","content":"Get 30% off","fill":[{"type":"solid","color":"$color-surface"}]}
|
||||
]
|
||||
});
|
||||
let before = band["fill"].clone();
|
||||
fix_invisible_text_band(&mut band, true, "$color-accent");
|
||||
assert_eq!(
|
||||
band["fill"], before,
|
||||
"gradient banner keeps its real surface"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_text_band_untouched_on_dark_page() {
|
||||
let mut band = json!({"type":"frame","children":[
|
||||
{"type":"text","fill":[{"type":"solid","color":"#FFFFFF"}]}
|
||||
]});
|
||||
fix_invisible_text_band(&mut band, false, "$color-accent");
|
||||
assert!(
|
||||
band.get("fill").is_none(),
|
||||
"white text on a dark page is fine — no fill stamped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_text_band_not_filled() {
|
||||
// a dark text present → not an all-white invisible band → leave alone.
|
||||
let mut band = json!({"type":"frame","children":[
|
||||
{"type":"text","fill":[{"type":"solid","color":"#FFFFFF"}]},
|
||||
{"type":"text","fill":[{"type":"solid","color":"#1C1410"}]}
|
||||
]});
|
||||
fix_invisible_text_band(&mut band, true, "$color-accent");
|
||||
assert!(band.get("fill").is_none(), "mixed text → not a hidden band");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_clipper_radius_preserved_under_decorated_card() {
|
||||
// A card with cornerRadius wrapping an image-placeholder: the placeholder's
|
||||
// own cornerRadius rounds the photo, not card decoration → keep it.
|
||||
let mut outer = json!({
|
||||
"type":"frame","role":"card","cornerRadius":12,
|
||||
"children":[{
|
||||
"type":"frame","role":"image-card","cornerRadius":12,"clipContent":true,
|
||||
"children":[{"type":"image","name":"photo"}]
|
||||
}]
|
||||
});
|
||||
strip_nested_card_decoration(&mut outer, DecoFlags::default());
|
||||
assert_eq!(
|
||||
outer["children"][0]["cornerRadius"],
|
||||
json!(12),
|
||||
"media-clipper radius preserved (rounds the photo)"
|
||||
);
|
||||
}
|
||||
|
|
@ -227,6 +227,13 @@ pub enum Intent {
|
|||
#[derive(Debug, Clone)]
|
||||
pub enum Progress {
|
||||
Planning,
|
||||
/// Planning produced the FULL subtask list — emitted ONCE right after
|
||||
/// planning so the UI can show the complete task checklist upfront (TS
|
||||
/// parity), instead of revealing tasks one-by-one as each starts. Pairs
|
||||
/// `id` with `label` so the UI can mark each row done on `SubtaskDone`.
|
||||
Planned {
|
||||
subtasks: Vec<(String, String)>,
|
||||
},
|
||||
ScaffoldDone,
|
||||
SubtaskStarted {
|
||||
id: String,
|
||||
|
|
|
|||
|
|
@ -52,11 +52,17 @@ pub fn snapshot_plan_vars(sink: &dyn DocSink, _plan: &OrchestratorPlan) -> VarSn
|
|||
|
||||
/// 缺失的调色板 token / 轴 → 一条 `MergeThemePreset`。全部已存在时
|
||||
/// 返回空(完全播种过的文档零命令)。
|
||||
pub fn seed_commands(_plan: &OrchestratorPlan, snap: &VarSnapshot) -> Vec<EditorCommand> {
|
||||
pub fn seed_commands(plan: &OrchestratorPlan, snap: &VarSnapshot) -> Vec<EditorCommand> {
|
||||
if snap.created.is_empty() && snap.created_axes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let palette = semantic_palette::palette_variables();
|
||||
let mut palette = semantic_palette::palette_variables();
|
||||
// Harmonize the cool-slate neutral ramp to the design's temperature: a warm
|
||||
// page (cream/orange) gets warm grays so the search bar / chips / avatar
|
||||
// circle no longer read as off-palette cool gray. Neutral pages keep slate.
|
||||
if let Some(page_bg) = plan.root_frame.first_solid_hex() {
|
||||
crate::palette_harmonize::harmonize_palette_neutrals(&mut palette, &page_bg);
|
||||
}
|
||||
let variables = snap
|
||||
.created
|
||||
.iter()
|
||||
|
|
|
|||
Loading…
Reference in a new issue