fix(ai): harden node parser for weak-model output

Weak models emit dirty node JSON that previously failed whole subtasks.
Re-nest flat `_parent` output into a tree (port of TS parseJsonlToTree;
horizontal containers were collapsing to vertical), strip reasoning
`<think>` blocks (including truncated ones) so drafts aren't parsed as
nodes, resolve documented numeric design tokens (`$type`/`$spacing`/
`$radius`) and wrap bare `fill` color strings that canonical PenNode
rejects, and restore per-node tolerance — re-nesting had collapsed the
flat array so one bad deep node failed the entire root.

Split the test module into sibling parse_tests.rs to keep parse.rs
under the 800-line cap; ark_metrics.jsonl is the captured regression.
This commit is contained in:
Fini 2026-06-06 15:43:42 +08:00
parent 55a69586a7
commit 70ecfc6f6a
3 changed files with 643 additions and 171 deletions

View file

@ -27,6 +27,11 @@ impl std::fmt::Display for ParseError {
/// children 2),于是无需任何 colon/label 启发式,带标签的真数组也不会被误跳。
/// 全部候选失败 / 无候选视为错误。
pub fn parse_nodes(text: &str) -> Result<Vec<PenNode>, ParseError> {
// 推理模型(MiniMax-M3 / DeepSeek-R 系等)先吐 `<think>…</think>` 再给真正
// 答案,且 think 里常含**未完成的草稿 JSON**(扁平、带 `//` 注释)。先剥到
// 最后一个 `</think>` 之后,避免把草稿误当输出(实测 M3 否则被扫出扁平
// depth-2 残骸)。无闭合标签(在 think 中被 max_tokens 截断)则用原文。
let text = strip_reasoning(text);
let mut best: Option<Vec<PenNode>> = None;
let mut best_total = 0usize;
let mut last_err: Option<ParseError> = None;
@ -56,6 +61,33 @@ pub fn parse_nodes(text: &str) -> Result<Vec<PenNode>, ParseError> {
})
}
/// 剥掉推理模型的 `<think>…</think>`,返回真正答案部分。两步:
/// 1. **跳过所有已闭合 think 块**:取最后一个 `</think>` / `</thinking>` 之后的文本。
/// 2. 在其余文本里,**截掉任何仍未闭合的 `<think>` 块**(被 `max_tokens` 截断的
/// 末尾 think):返回该开标签**之前**的部分。
///
/// 这样无论 think 在开头、还是"闭合块 + 内容 + 末尾被截断的第二个 think 块"
/// (Codex review 指出的二段式),都不会把未完成的 think 草稿当真节点解析。
/// 无 think 标签 → 原样返回(非推理模型安全);末尾截断 → 通常返回空 → 解析失败重试。
fn strip_reasoning(text: &str) -> &str {
// 1) 跳过所有闭合块。
let after_closed = ["</think>", "</thinking>"]
.iter()
.filter_map(|tag| text.rfind(tag).map(|i| i + tag.len()))
.max()
.map(|i| &text[i..])
.unwrap_or(text);
// 2) 截掉残留的未闭合 think 块。
match ["<think>", "<thinking>"]
.iter()
.filter_map(|tag| after_closed.find(tag))
.min()
{
Some(start) => &after_closed[..start],
None => after_closed,
}
}
/// 递归总节点数(含后代),复用 cleanup 的后代计数。用于在候选间取"最完整"
/// 的解析结果。
fn total_nodes(nodes: &[PenNode]) -> usize {
@ -66,45 +98,189 @@ fn total_nodes(nodes: &[PenNode]) -> usize {
.sum::<usize>()
}
/// JSONL / bare-object 收集:把每个顶层 `{...}` 解析为一个 `PenNode`(非节点
/// 对象——如 `{"nodes":[…]}` 这种无 `type` 的包裹——被跳过)。返回全部成功
/// 解析的顶层节点;由 [`parse_nodes`] 与数组形态按总节点数比较择优。
/// JSONL / bare-object 收集:取所有顶层 `{...}`,按 `_parent` 重组成树(扁平
/// `_parent` 格式——TS prompt 要求、DeepSeek 用的 JSONL 形态),再逐根反序列化。
/// 非节点对象(`{"nodes":[…]}` 包裹、散落的 `{type:solid}` 等)在重组阶段被
/// [`is_node_object`] 滤掉。由 [`parse_nodes`] 与数组形态按总节点数比较择优。
fn collect_bare_objects(text: &str) -> Vec<PenNode> {
let mut nodes = Vec::new();
for obj in balanced_spans(text, b'{', b'}') {
let Ok(mut value) = serde_json::from_str::<serde_json::Value>(obj) else {
continue;
};
normalize_generated_node_json(&mut value);
if let Ok(node) = serde_json::from_value::<PenNode>(value) {
nodes.push(node);
}
}
nodes
let items: Vec<serde_json::Value> = balanced_spans(text, b'{', b'}')
.into_iter()
.filter_map(|obj| serde_json::from_str(obj).ok())
.collect();
deserialize_roots(renest_by_parent(items)).unwrap_or_default()
}
/// 走完整 parse 管线解析单个候选 `[...]`:serde `Value` → normalize →
/// **逐元素**反序列化为 `Vec<PenNode>`。
///
/// 弱模型常在节点数组里混入个别坏元素(例如把 fill 的
/// `{"type":"solid"}` 当成节点写进数组)。老的整组
/// `from_value::<Vec<PenNode>>` 只要一个元素坏就丢掉**整段子任务**
/// (实测 Dashboard charts-row 因此 0 节点、后续 table-section 也被
/// 中断)。这里改为逐元素反序列化:保留有效节点、跳过并记录坏的。
/// 全有效时结果与老逻辑一致(严格不回退);全坏 / 空数组返回 `Err`
/// 以便上层试下一个候选。
/// 解析单个候选 `[...]`:serde `Value::Array` → 按 `_parent` 重组成树 →
/// 逐根反序列化为 `Vec<PenNode>`。
fn try_parse_candidate(json: &str) -> Result<Vec<PenNode>, ParseError> {
let mut value: serde_json::Value =
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| ParseError(format!("json: {e}")))?;
normalize_generated_node_json(&mut value);
let serde_json::Value::Array(items) = value else {
return Err(ParseError("candidate is not a JSON array".into()));
};
let total = items.len();
deserialize_roots(renest_by_parent(items))
}
/// canonical `PenNode` 的 `type` 标签全集。重组阶段据此过滤掉非节点对象
/// (散落的 `{type:solid}` 字段对象、`{"nodes":[…]}` 包裹等)——既是弱模型
/// 容错(坏元素不毁整段),也避免把它们当节点重组进树。
const NODE_KINDS: &[&str] = &[
"frame",
"group",
"rectangle",
"ellipse",
"line",
"polygon",
"path",
"text",
"text_input",
"image",
"icon_font",
"ref",
];
fn is_node_object(value: &serde_json::Value) -> bool {
value
.get("type")
.and_then(serde_json::Value::as_str)
.is_some_and(|ty| NODE_KINDS.contains(&ty))
}
/// 把扁平节点列表按 `_parent` 重组成嵌套树(移植 TS
/// `design-parser.ts::parseJsonlToTree`)。
///
/// TS 的 sub-agent prompt 要求**扁平 `_parent` 格式**(每节点一行、`_parent`
/// 指父 id、root 为 `null`),TS 解析时据此建树。Rust 此前漏了这步——扁平
/// 节点被直接插成兄弟,横向容器的子项全跑到 root 下 → 布局竖排破损。M2.7 /
/// DeepSeek 都用这个格式;方舟用嵌套 children。
///
/// 行为:先用 [`is_node_object`] 滤掉非节点对象;若没有任何 `_parent` 字段,
/// 原样返回(嵌套 / 纯 root 形态);否则按出现顺序建 id→对象 + 父→子表,从
/// root(`_parent:null` 或父不存在的孤儿)递归装配。已取走的 id 不再装配(防环)。
fn renest_by_parent(items: Vec<serde_json::Value>) -> Vec<serde_json::Value> {
use std::collections::HashMap;
let mut by_id: HashMap<String, serde_json::Value> = HashMap::new();
let mut order: Vec<String> = Vec::new();
let mut parent_of: HashMap<String, Option<String>> = HashMap::new();
let mut seen_parent = false;
let mut dropped = 0usize;
for mut item in items {
if !is_node_object(&item) {
continue;
}
let Some(obj) = item.as_object_mut() else {
continue;
};
let id = match obj.get("id").and_then(serde_json::Value::as_str) {
Some(s) if !s.is_empty() => s.to_string(),
_ => continue,
};
let parent_id = match obj.remove("_parent") {
Some(serde_json::Value::String(p)) if !p.is_empty() => {
seen_parent = true;
Some(p)
}
Some(_) => {
seen_parent = true; // null / anything else → root
None
}
None => None,
};
if by_id.contains_key(&id) {
continue; // duplicate id — keep the first
}
// Per-node tolerance (restored after re-nesting): a node whose OWN
// fields still don't deserialize after normalize would otherwise fail
// its whole assembled root (re-nesting collapsed the flat array, so the
// per-element tolerance the array path used to have was lost). Probe
// each as a childless leaf and drop the unsalvageable ones, keeping the
// rest of the section instead of losing the entire subtask.
let mut probe = item.clone();
if let Some(po) = probe.as_object_mut() {
po.remove("children");
}
normalize_generated_node_json(&mut probe);
if serde_json::from_value::<PenNode>(probe).is_err() {
dropped += 1;
continue;
}
order.push(id.clone());
parent_of.insert(id.clone(), parent_id);
by_id.insert(id, item);
}
if dropped > 0 {
eprintln!("[parse] renest dropped {dropped} node(s) failing schema validation");
}
if !seen_parent {
// 没有 `_parent`:已是嵌套 / 纯 root 形态,原样返回(保持顺序)。
return order
.into_iter()
.filter_map(|id| by_id.remove(&id))
.collect();
}
let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
let mut roots: Vec<String> = Vec::new();
for id in &order {
match parent_of.get(id).cloned().flatten() {
Some(parent) if parent != *id && by_id.contains_key(&parent) => {
children_of.entry(parent).or_default().push(id.clone());
}
_ => roots.push(id.clone()), // root / 孤儿 / 自指
}
}
let mut out: Vec<serde_json::Value> = roots
.into_iter()
.filter_map(|id| assemble(&id, &mut by_id, &children_of))
.collect();
// 无丢失保证:若有节点不可从任何 root 到达(纯 `_parent` 环),按原始顺序
// 把剩下的提升为 root 装配出来,不静默丢节点(环会被 assemble 的 take 打断)。
for id in &order {
if by_id.contains_key(id) {
if let Some(node) = assemble(id, &mut by_id, &children_of) {
out.push(node);
}
}
}
out
}
/// 递归装配 `id` 的子树:从 `by_id` 取出,按 `children_of` 把子节点装进
/// `children`(保留任何已有的内联 children)。已取走的 id 返回 `None`(防环)。
fn assemble(
id: &str,
by_id: &mut std::collections::HashMap<String, serde_json::Value>,
children_of: &std::collections::HashMap<String, Vec<String>>,
) -> Option<serde_json::Value> {
let mut node = by_id.remove(id)?;
if let Some(child_ids) = children_of.get(id) {
let mut children: Vec<serde_json::Value> = Vec::new();
if let Some(existing) = node
.get_mut("children")
.and_then(serde_json::Value::as_array_mut)
{
children.append(existing);
}
for cid in child_ids {
if let Some(child) = assemble(cid, by_id, children_of) {
children.push(child);
}
}
if let Some(obj) = node.as_object_mut() {
obj.insert("children".to_string(), serde_json::Value::Array(children));
}
}
Some(node)
}
/// 逐根反序列化为 `Vec<PenNode>`(逐根容错:坏根跳过并记录;全有效时与直接
/// 反序列化一致)。空 / 全坏返回 `Err` 以便上层试下一个候选。
fn deserialize_roots(roots: Vec<serde_json::Value>) -> Result<Vec<PenNode>, ParseError> {
let total = roots.len();
let mut nodes = Vec::with_capacity(total);
let mut last_err: Option<String> = None;
for item in items {
match serde_json::from_value::<PenNode>(item) {
for mut value in roots {
normalize_generated_node_json(&mut value);
match serde_json::from_value::<PenNode>(value) {
Ok(node) => nodes.push(node),
Err(e) => last_err = Some(e.to_string()),
}
@ -112,12 +288,12 @@ fn try_parse_candidate(json: &str) -> Result<Vec<PenNode>, ParseError> {
if nodes.is_empty() {
let detail = last_err.map(|e| format!("; last: {e}")).unwrap_or_default();
return Err(ParseError(format!(
"deserialize: 0/{total} nodes valid{detail}"
"deserialize: 0/{total} roots valid{detail}"
)));
}
if nodes.len() < total {
eprintln!(
"[parse] tolerated {} malformed node(s) of {total}, kept {}",
"[parse] tolerated {} malformed root(s) of {total}, kept {}",
total - nodes.len(),
nodes.len()
);
@ -142,14 +318,87 @@ fn normalize_generated_node_json(value: &mut serde_json::Value) {
object.insert("src".into(), serde_json::Value::String(String::new()));
}
// `fill` given as a bare color string/ref → wrap into the canonical
// `[{ "type":"solid", "color": <string> }]` sequence. PenNode's fill
// is a sequence; models commonly shorthand it as a plain color
// (实测方舟 `"fill":"$color-success-text"` 否则整 root 报废)。
if let Some(serde_json::Value::String(color)) = object.get("fill") {
let color = color.clone();
object.insert(
"fill".into(),
serde_json::json!([{ "type": "solid", "color": color }]),
);
}
for child in object.values_mut() {
normalize_generated_node_json(child);
}
}
serde_json::Value::String(s) => {
// 数值型设计 token(`$type-*-size` 等)就地解析成数字 —— canonical
// PenNode 的 fontSize/lineHeight/gap/… 是 f64,模型却按 prompt 用这些
// `$ref` 字符串,否则反序列化失败、整个 root 报废(实测方舟 metrics)。
if let Some(n) = resolve_numeric_design_token(s) {
*value = serde_json::json!(n);
}
}
_ => {}
}
}
/// 把**数值型**设计 token 字符串解析成 prompt 文档化的默认数值:
/// `$type-{tier}-{size|line-height|letter-spacing}`、`$spacing-{1..5}`、
/// `$radius-{sm|md|lg}`。颜色 / weight 等非纯数值 token 返回 `None`(保持字符串
/// —— 颜色 `$color-*` 在 fill 里合法,weight 是字符串型按字符串反序列化)。
fn resolve_numeric_design_token(s: &str) -> Option<f64> {
if let Some(rest) = s.strip_prefix("$type-") {
if let Some(t) = rest.strip_suffix("-letter-spacing") {
return match t {
"display" => Some(-0.5),
"uppercase-label" => Some(1.5),
_ => None,
};
}
let (tier, prop) = if let Some(t) = rest.strip_suffix("-line-height") {
(t, "lh")
} else if let Some(t) = rest.strip_suffix("-size") {
(t, "size")
} else {
return None;
};
// (size, line-height) per the prompt's typography scale.
let (size, lh) = match tier {
"display" => (64.0, 1.0),
"h1" => (24.0, 1.2),
"h2" => (20.0, 1.25),
"h3" => (16.0, 1.3),
"body" => (14.0, 1.5),
"caption" => (12.0, 1.4),
_ => return None,
};
return Some(if prop == "size" { size } else { lh });
}
if let Some(n) = s.strip_prefix("$spacing-") {
return match n {
"1" => Some(4.0),
"2" => Some(8.0),
"3" => Some(12.0),
"4" => Some(16.0),
"5" => Some(24.0),
_ => None,
};
}
if let Some(r) = s.strip_prefix("$radius-") {
return match r {
"sm" => Some(4.0),
"md" => Some(8.0),
"lg" => Some(12.0),
_ => None,
};
}
None
}
/// 收集文本里可作为候选的 `open..close` 子串(`[...]` 或 `{...}`),按出现
/// 顺序返回。字符串字面量内的括号忽略。两条规则保证对脏输入鲁棒:
/// - **独立尝试**每个 `open`:`balanced_end` 未闭合即跳过该位置(+1 前进)。
@ -230,143 +479,5 @@ fn balanced_end(bytes: &[u8], start: usize, open: u8, close: u8) -> Option<usize
}
#[cfg(test)]
mod tests {
use super::*;
use op_editor_core::PenNodeExt as _;
#[test]
fn parse_nodes_reads_fenced_pennode_array() {
// 一个最小的 frame 节点(canonical schema:`type` 标签 +
// base 字段)。
let text = r#"Sure, here are the nodes:
```json
[
{ "type": "frame", "id": "hero", "name": "Hero", "x": 0, "y": 0, "width": 1200, "height": 400, "children": [] }
]
```"#;
let nodes = parse_nodes(text).expect("parse");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id_str(), "hero");
}
#[test]
fn parse_nodes_rejects_empty_array() {
assert!(parse_nodes("[]").is_err());
}
#[test]
fn parse_nodes_rejects_no_array() {
assert!(parse_nodes("the model wrote prose only").is_err());
}
#[test]
fn parse_nodes_defaults_missing_image_src_to_empty_string() {
let text = r#"[
{ "type": "image", "id": "photo", "name": "Restaurant photo", "x": 0, "y": 0, "width": 240, "height": 160 }
]"#;
let nodes = parse_nodes(text).expect("missing image src should be tolerated");
let PenNode::Image(image) = &nodes[0] else {
panic!("expected image node");
};
assert_eq!(image.src, "");
}
#[test]
fn parse_nodes_skips_prose_bracket_before_fenced_array() {
// 弱模型在真正 JSON 之前写了带 `[` 的推理散文。老逻辑取第一个
// `[` 会抓到 `[step 1]` 报 "expected ident";新逻辑扫描所有平衡
// 数组、取节点数最多的有效结果,跳过散文括号。
let text = "Let me plan this [step 1]: build the card.\n```json\n[\n { \"type\": \"frame\", \"id\": \"card\", \"name\": \"Card\", \"x\": 0, \"y\": 0, \"width\": 300, \"height\": 200, \"children\": [] }\n]\n```";
let nodes = parse_nodes(text).expect("should skip prose bracket and parse real array");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id_str(), "card");
}
#[test]
fn parse_nodes_tolerates_one_bad_node_keeps_rest() {
// 弱模型在节点数组里混入一个 `type:"solid"`(fill 类型当节点)。
// 老的整组 from_value 会因这一个坏元素丢掉整段;新逻辑逐元素
// 反序列化、跳过坏的、保留有效节点(实测 Dashboard charts-row)。
let text = r##"[
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
{ "type": "solid", "color": "#06B6D4" },
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
]"##;
let nodes = parse_nodes(text).expect("should keep the valid nodes, skip the bad one");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].id_str(), "a");
assert_eq!(nodes[1].id_str(), "b");
}
#[test]
fn parse_nodes_returns_outer_array_not_nested_children() {
// 嵌套格式:外层只有 1 个 root frame,其 children 有 3 个。只收
// 顶层数组,返回外层 [root],而不是内层 [a,b,c](Codex review)。
let text = r#"[
{ "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "children": [
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
{ "type": "frame", "id": "c", "name": "C", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
] }
]"#;
let nodes = parse_nodes(text).expect("parse");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id_str(), "root");
}
#[test]
fn parse_nodes_reads_jsonl_bare_objects() {
// DeepSeek 输出 JSONL:```json 围栏里每行一个裸对象,无 [ ] 包裹。
// 数组路径只看到内层 children:[] 空数组(失败),JSONL 回退按顶层
// {...} 逐个解析。
let text = "```json\n{\"type\":\"frame\",\"id\":\"a\",\"name\":\"A\",\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"children\":[]}\n{\"type\":\"frame\",\"id\":\"b\",\"name\":\"B\",\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"children\":[]}\n```";
let nodes = parse_nodes(text).expect("JSONL fallback should parse bare objects");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].id_str(), "a");
assert_eq!(nodes[1].id_str(), "b");
}
#[test]
fn parse_nodes_bare_object_returns_top_level_not_nested_children() {
// 单个裸对象(无数组包裹)带真实嵌套 children。合并深度判定下,
// children:[...] 在 {} 内(深度>0)不被数组路径当节点数组,返回顶层
// root 而不是内层 [a,b](Codex review)。
let text = r#"{ "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "children": [
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
] }"#;
let nodes = parse_nodes(text).expect("parse");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id_str(), "root");
}
#[test]
fn parse_nodes_finds_array_after_unmatched_prose_bracket() {
// 散文里有个不闭合的 `[`(如 "options [1, 2 …"),其后才是真数组。
// 独立尝试每个 `[` + 不闭合即跳过,后面的真数组不被漏掉(Codex
// review;修上一轮 combined-depth 引入的回归)。
let text = "Consider options [a and b, then build:\n```json\n[{ \"type\": \"frame\", \"id\": \"hero\", \"name\": \"Hero\", \"x\": 0, \"y\": 0, \"width\": 200, \"height\": 100, \"children\": [] }]\n```";
let nodes =
parse_nodes(text).expect("should find the real array after an unmatched prose bracket");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id_str(), "hero");
}
#[test]
fn parse_nodes_reads_labelled_wrapper_object() {
// 模型把节点数组包进带标签的对象 {"nodes":[...]}。colon-skip 曾把
// "nodes":[...] 一并跳掉;现在按总节点数择优——wrapper 无 type 解析
// 失败,数组路径取出 [a,b](Codex review)。
let text = r#"```json
{ "nodes": [
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
] }
```"#;
let nodes = parse_nodes(text).expect("labelled wrapper array should be parsed");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].id_str(), "a");
assert_eq!(nodes[1].id_str(), "b");
}
}
#[path = "parse_tests.rs"]
mod tests;

View file

@ -0,0 +1,330 @@
use super::*;
use op_editor_core::PenNodeExt as _;
#[test]
fn parse_nodes_reads_fenced_pennode_array() {
// 一个最小的 frame 节点(canonical schema:`type` 标签 +
// base 字段)。
let text = r#"Sure, here are the nodes:
```json
[
{ "type": "frame", "id": "hero", "name": "Hero", "x": 0, "y": 0, "width": 1200, "height": 400, "children": [] }
]
```"#;
let nodes = parse_nodes(text).expect("parse");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id_str(), "hero");
}
#[test]
fn parse_nodes_rejects_empty_array() {
assert!(parse_nodes("[]").is_err());
}
#[test]
fn parse_nodes_rejects_no_array() {
assert!(parse_nodes("the model wrote prose only").is_err());
}
#[test]
fn parse_nodes_defaults_missing_image_src_to_empty_string() {
let text = r#"[
{ "type": "image", "id": "photo", "name": "Restaurant photo", "x": 0, "y": 0, "width": 240, "height": 160 }
]"#;
let nodes = parse_nodes(text).expect("missing image src should be tolerated");
let PenNode::Image(image) = &nodes[0] else {
panic!("expected image node");
};
assert_eq!(image.src, "");
}
#[test]
fn parse_nodes_skips_prose_bracket_before_fenced_array() {
// 弱模型在真正 JSON 之前写了带 `[` 的推理散文。老逻辑取第一个
// `[` 会抓到 `[step 1]` 报 "expected ident";新逻辑扫描所有平衡
// 数组、取节点数最多的有效结果,跳过散文括号。
let text = "Let me plan this [step 1]: build the card.\n```json\n[\n { \"type\": \"frame\", \"id\": \"card\", \"name\": \"Card\", \"x\": 0, \"y\": 0, \"width\": 300, \"height\": 200, \"children\": [] }\n]\n```";
let nodes = parse_nodes(text).expect("should skip prose bracket and parse real array");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id_str(), "card");
}
#[test]
fn parse_nodes_tolerates_one_bad_node_keeps_rest() {
// 弱模型在节点数组里混入一个 `type:"solid"`(fill 类型当节点)。
// 老的整组 from_value 会因这一个坏元素丢掉整段;新逻辑逐元素
// 反序列化、跳过坏的、保留有效节点(实测 Dashboard charts-row)。
let text = r##"[
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
{ "type": "solid", "color": "#06B6D4" },
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
]"##;
let nodes = parse_nodes(text).expect("should keep the valid nodes, skip the bad one");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].id_str(), "a");
assert_eq!(nodes[1].id_str(), "b");
}
#[test]
fn parse_nodes_returns_outer_array_not_nested_children() {
// 嵌套格式:外层只有 1 个 root frame,其 children 有 3 个。只收
// 顶层数组,返回外层 [root],而不是内层 [a,b,c](Codex review)。
let text = r#"[
{ "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "children": [
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
{ "type": "frame", "id": "c", "name": "C", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
] }
]"#;
let nodes = parse_nodes(text).expect("parse");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id_str(), "root");
}
#[test]
fn parse_nodes_reads_jsonl_bare_objects() {
// DeepSeek 输出 JSONL:```json 围栏里每行一个裸对象,无 [ ] 包裹。
// 数组路径只看到内层 children:[] 空数组(失败),JSONL 回退按顶层
// {...} 逐个解析。
let text = "```json\n{\"type\":\"frame\",\"id\":\"a\",\"name\":\"A\",\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"children\":[]}\n{\"type\":\"frame\",\"id\":\"b\",\"name\":\"B\",\"x\":0,\"y\":0,\"width\":100,\"height\":50,\"children\":[]}\n```";
let nodes = parse_nodes(text).expect("JSONL fallback should parse bare objects");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].id_str(), "a");
assert_eq!(nodes[1].id_str(), "b");
}
#[test]
fn parse_nodes_bare_object_returns_top_level_not_nested_children() {
// 单个裸对象(无数组包裹)带真实嵌套 children。合并深度判定下,
// children:[...] 在 {} 内(深度>0)不被数组路径当节点数组,返回顶层
// root 而不是内层 [a,b](Codex review)。
let text = r#"{ "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "children": [
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
] }"#;
let nodes = parse_nodes(text).expect("parse");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id_str(), "root");
}
#[test]
fn parse_nodes_finds_array_after_unmatched_prose_bracket() {
// 散文里有个不闭合的 `[`(如 "options [1, 2 …"),其后才是真数组。
// 独立尝试每个 `[` + 不闭合即跳过,后面的真数组不被漏掉(Codex
// review;修上一轮 combined-depth 引入的回归)。
let text = "Consider options [a and b, then build:\n```json\n[{ \"type\": \"frame\", \"id\": \"hero\", \"name\": \"Hero\", \"x\": 0, \"y\": 0, \"width\": 200, \"height\": 100, \"children\": [] }]\n```";
let nodes =
parse_nodes(text).expect("should find the real array after an unmatched prose bracket");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id_str(), "hero");
}
#[test]
fn parse_nodes_reads_labelled_wrapper_object() {
// 模型把节点数组包进带标签的对象 {"nodes":[...]}。colon-skip 曾把
// "nodes":[...] 一并跳掉;现在按总节点数择优——wrapper 无 type 解析
// 失败,数组路径取出 [a,b](Codex review)。
let text = r#"```json
{ "nodes": [
{ "type": "frame", "id": "a", "name": "A", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] },
{ "type": "frame", "id": "b", "name": "B", "x": 0, "y": 0, "width": 100, "height": 50, "children": [] }
] }
```"#;
let nodes = parse_nodes(text).expect("labelled wrapper array should be parsed");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].id_str(), "a");
assert_eq!(nodes[1].id_str(), "b");
}
#[test]
fn parse_nodes_reads_ark_metrics_jsonl_regression() {
// 实采:方舟 ark-code-latest 的 metrics 子任务 —— 31 行合法 JSONL、1 个
// root(`_parent:null`)、所有 `_parent` 可解析。实测却 `0/0 roots valid`
// 失败(benchmark 矩阵复现)。固化为回归 fixture。
let text = include_str!("test_fixtures/ark_metrics.jsonl");
let nodes = parse_nodes(text).expect("valid 31-node _parent JSONL must parse");
assert_eq!(nodes.len(), 1, "single metrics-root after re-nest");
assert_eq!(nodes[0].id_str(), "metrics-root");
// 31 行里有 1 个重复 id(方舟自身),去重后 30 个 unique 节点。
assert!(
total_nodes(&nodes) >= 29,
"expected ~30 nodes, got {}",
total_nodes(&nodes)
);
}
#[test]
fn resolve_numeric_design_token_table() {
assert_eq!(
resolve_numeric_design_token("$type-caption-size"),
Some(12.0)
);
assert_eq!(
resolve_numeric_design_token("$type-display-size"),
Some(64.0)
);
assert_eq!(
resolve_numeric_design_token("$type-body-line-height"),
Some(1.5)
);
assert_eq!(
resolve_numeric_design_token("$type-display-letter-spacing"),
Some(-0.5)
);
assert_eq!(resolve_numeric_design_token("$spacing-3"), Some(12.0));
assert_eq!(resolve_numeric_design_token("$radius-md"), Some(8.0));
// colors / weight / sizing keywords stay strings (not numeric tokens).
assert_eq!(resolve_numeric_design_token("$color-accent"), None);
assert_eq!(resolve_numeric_design_token("$type-body-weight"), None);
assert_eq!(resolve_numeric_design_token("fill_container"), None);
}
#[test]
fn normalize_resolves_numeric_token_and_wraps_bare_fill() {
let mut v = serde_json::json!({
"type":"text","id":"x","fontSize":"$type-caption-size","fill":"$color-surface"
});
normalize_generated_node_json(&mut v);
assert_eq!(v["fontSize"], serde_json::json!(12.0));
assert_eq!(
v["fill"],
serde_json::json!([{"type":"solid","color":"$color-surface"}])
);
}
#[test]
fn parse_nodes_renests_flat_parent_array() {
// 扁平 `_parent` 数组(M2.7 形态):root + 一个横向 row + row 的 3 个
// 子项,全是兄弟、靠 `_parent` 指父。移植 TS parseJsonlToTree 后应重组
// 成树:root 直接子 = [row],row 子 = [a,b,c],而非 5 个扁平兄弟
// (扁平 → 横向容器子项跑到 root 下 → 布局竖排破损,这正是回归点)。
let text = r#"[
{ "type": "frame", "id": "root", "name": "Root", "x": 0, "y": 0, "width": 300, "height": 200, "layout": "vertical", "_parent": null },
{ "type": "frame", "id": "row", "name": "Row", "width": "fill_container", "height": "fit_content", "layout": "horizontal", "_parent": "root" },
{ "type": "frame", "id": "a", "name": "A", "width": 80, "height": 50, "_parent": "row" },
{ "type": "frame", "id": "b", "name": "B", "width": 80, "height": 50, "_parent": "row" },
{ "type": "frame", "id": "c", "name": "C", "width": 80, "height": 50, "_parent": "row" }
]"#;
let nodes = parse_nodes(text).expect("flat _parent array should re-nest into a tree");
assert_eq!(nodes.len(), 1, "single root after re-nest");
assert_eq!(nodes[0].id_str(), "root");
assert_eq!(
crate::cleanup::count_descendants(&nodes[0]),
4,
"root subtree = row + a + b + c"
);
}
#[test]
fn parse_nodes_renests_flat_parent_jsonl() {
// DeepSeek 形态:JSONL 裸对象,每个带 `_parent`。重组后 root 含嵌套子树
// (2 个直接子),而非 3 个扁平兄弟。
let text = "```json\n{\"type\":\"frame\",\"id\":\"root\",\"name\":\"Root\",\"x\":0,\"y\":0,\"width\":300,\"height\":200,\"_parent\":null}\n{\"type\":\"frame\",\"id\":\"a\",\"name\":\"A\",\"width\":80,\"height\":50,\"_parent\":\"root\"}\n{\"type\":\"frame\",\"id\":\"b\",\"name\":\"B\",\"width\":80,\"height\":50,\"_parent\":\"root\"}\n```";
let nodes = parse_nodes(text).expect("flat _parent JSONL should re-nest");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id_str(), "root");
assert_eq!(crate::cleanup::count_descendants(&nodes[0]), 2);
}
#[test]
fn parse_nodes_renest_orphan_parent_becomes_root() {
// `_parent` 指向不存在的 id → 该节点当 root(TS parseJsonlToTree:
// "Parent not found, treat as root")。两个节点都成 root。
let text = r#"[
{ "type": "frame", "id": "a", "name": "A", "width": 80, "height": 50, "_parent": "ghost" },
{ "type": "frame", "id": "b", "name": "B", "width": 80, "height": 50, "_parent": null }
]"#;
let nodes = parse_nodes(text).expect("orphan parent should fall back to root");
assert_eq!(nodes.len(), 2);
}
#[test]
fn parse_nodes_renest_cycle_is_lossless() {
// 病态输入:纯 `_parent` 环(a↔b)。两者互为父 → 没有天然 root。
// 无丢失保证应把它们装配出来(环被 take 打断),不静默丢节点。
let text = r#"[
{ "type": "frame", "id": "a", "name": "A", "width": 80, "height": 50, "_parent": "b" },
{ "type": "frame", "id": "b", "name": "B", "width": 80, "height": 50, "_parent": "a" }
]"#;
let nodes = parse_nodes(text).expect("a _parent cycle must not drop all nodes");
// a 先序、被提升为 root,b 作为 a 的子;总节点数 2(无丢失)。
assert_eq!(total_nodes(&nodes), 2);
}
#[test]
fn parse_nodes_ignores_draft_inside_think_block() {
// 推理模型(MiniMax-M3)在 <think> 里写**未完成的扁平草稿**,`</think>`
// 之后才是真答案。应解析真答案(嵌套 root),而不是 think 里的扁平草稿
// (否则得到 M3 实测的扁平 depth-2 残骸)。
let text = r#"<think>
Let me draft the cards: [
{"type":"frame","id":"draft-a","name":"A","width":80,"height":50},
{"type":"frame","id":"draft-b","name":"B","width":80,"height":50}
]
and similarly for the rest...
</think>
[
{"type":"frame","id":"root","name":"Root","x":0,"y":0,"width":300,"height":200,"layout":"horizontal","_parent":null},
{"type":"frame","id":"a","name":"A","width":80,"height":50,"_parent":"root"},
{"type":"frame","id":"b","name":"B","width":80,"height":50,"_parent":"root"}
]"#;
let nodes = parse_nodes(text).expect("should parse the real answer after </think>");
assert_eq!(
nodes.len(),
1,
"real answer is 1 nested root, not the 2-node draft"
);
assert_eq!(nodes[0].id_str(), "root");
assert_eq!(crate::cleanup::count_descendants(&nodes[0]), 2);
}
#[test]
fn strip_reasoning_takes_text_after_last_close_tag() {
assert_eq!(strip_reasoning("<think>draft</think>REAL").trim(), "REAL");
assert_eq!(strip_reasoning("no tags here"), "no tags here");
// 在 think 中被截断(无闭合标签)→ 返回开标签之前(此处空),
// 不把未完成草稿当输出。
assert_eq!(strip_reasoning("<think>cut off draft"), "");
assert_eq!(strip_reasoning("preamble <think>cut off"), "preamble ");
// 闭合块 + 内容 + 末尾被截断的第二个 think 块 → 只保留中间内容
// (Codex review:截断的第二个 think 块仍被当真节点)。
assert_eq!(
strip_reasoning("<think>plan</think>REAL<think>redo [{\"id\"").trim(),
"REAL"
);
}
#[test]
fn parse_nodes_rejects_truncated_think_draft() {
// <think> 打开但被 max_tokens 截断(无 </think>),里面是未完成的草稿
// JSON。绝不能把草稿当真节点解析(Codex review:truncated think 仍被
// 当真节点)——strip 到 <think> 之前(空)→ 解析失败、上层重试。
let text = r#"<think>
Let me draft the cards: [
{"type":"frame","id":"draft","name":"D","width":80,"height":50}
"#;
assert!(
parse_nodes(text).is_err(),
"truncated think draft must not parse as real nodes"
);
}
#[test]
fn parse_nodes_ignores_draft_in_truncated_second_think() {
// 闭合 think#1 + 真答案 + 被截断的 think#2(含草稿)。只解析真答案,
// 不碰 think#2 草稿(Codex review:截断的第二个 think 块仍被当真节点)。
let text = r#"<think>planning the layout</think>
[
{"type":"frame","id":"root","name":"Root","x":0,"y":0,"width":300,"height":200,"layout":"horizontal","_parent":null},
{"type":"frame","id":"a","name":"A","width":80,"height":50,"_parent":"root"}
]
<think>wait, let me reconsider and draft more cards: [
{"type":"frame","id":"draft-x","name":"X","width":99,"height":99}
"#;
let nodes = parse_nodes(text).expect("real answer between think blocks must parse");
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0].id_str(), "root");
// 只有 a;draft-x(在被截断的 think#2 里)不计入。
assert_eq!(crate::cleanup::count_descendants(&nodes[0]), 1);
}

View file

@ -0,0 +1,31 @@
{"_parent":null,"id":"metrics-root","type":"frame","name":"Metric Cards Row","role":"section","width":"fill_container","height":"fit_content","layout":"vertical","gap":0,"padding":[12,0]}
{"_parent":"metrics-root","id":"metrics-row","type":"frame","name":"Metrics Cards","role":"row","width":"fill_container","height":156,"layout":"horizontal","gap":20,"padding":0}
{"_parent":"metrics-row","id":"metrics-card-revenue","type":"frame","name":"Revenue Card","role":"stat-card","width":"fill_container","height":"fill_container","layout":"vertical","gap":18,"padding":20,"cornerRadius":12,"fill":[{"type":"solid","color":"$color-surface"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"$color-border"}]},"effects":[{"type":"shadow","offsetX":0,"offsetY":8,"blur":24,"spread":0,"color":"#0F172A14"}]}
{"_parent":"metrics-card-revenue","id":"metrics-revenue-top","type":"frame","name":"Revenue Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0}
{"_parent":"metrics-revenue-top","id":"metrics-revenue-label","type":"text","name":"Revenue Label","role":"label","content":"Revenue","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":500,"lineHeight":"$type-caption-line-height","letterSpacing":1.2,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-muted"}]}
{"_parent":"metrics-revenue-top","id":"metrics-revenue-badge","type":"frame","name":"Revenue Change Badge","role":"badge","width":"fit_content","height":"fit_content","layout":"horizontal","alignItems":"center","gap":4,"padding":[6,8],"cornerRadius":999,"fill":[{"type":"solid","color":"$color-success-bg"}]}
{"_parent":"metrics-revenue-badge","id":"metrics-revenue-trend-icon","type":"icon_font","name":"Revenue Trend Icon","iconFontName":"trending-up","width":14,"height":14,"fill":"$color-success-text"}
{"_parent":"metrics-revenue-badge","id":"metrics-revenue-change","type":"text","name":"Revenue Change","content":"+12.5%","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":600,"lineHeight":"$type-caption-line-height","width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-success-text"}]}
{"_parent":"metrics-card-revenue","id":"metrics-revenue-value","type":"text","name":"Revenue Value","content":"$128.4K","fontFamily":"Inter","fontSize":32,"fontWeight":700,"lineHeight":1.1,"letterSpacing":-0.5,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-primary"}]}
{"_parent":"metrics-card-users","id":"metrics-users-top","type":"frame","name":"Users Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0}
{"_parent":"metrics-row","id":"metrics-card-users","type":"frame","name":"Active Users Card","role":"stat-card","width":"fill_container","height":"fill_container","layout":"vertical","gap":18,"padding":20,"cornerRadius":12,"fill":[{"type":"solid","color":"$color-surface"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"$color-border"}]},"effects":[{"type":"shadow","offsetX":0,"offsetY":8,"blur":24,"spread":0,"color":"#0F172A14"}]}
{"_parent":"metrics-card-users","id":"metrics-users-top","type":"frame","name":"Users Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0}
{"_parent":"metrics-users-top","id":"metrics-users-label","type":"text","name":"Users Label","role":"label","content":"Active Users","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":500,"lineHeight":"$type-caption-line-height","letterSpacing":1.2,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-muted"}]}
{"_parent":"metrics-users-top","id":"metrics-users-badge","type":"frame","name":"Users Change Badge","role":"badge","width":"fit_content","height":"fit_content","layout":"horizontal","alignItems":"center","gap":4,"padding":[6,8],"cornerRadius":999,"fill":[{"type":"solid","color":"$color-success-bg"}]}
{"_parent":"metrics-users-badge","id":"metrics-users-trend-icon","type":"icon_font","name":"Users Trend Icon","iconFontName":"trending-up","width":14,"height":14,"fill":"$color-success-text"}
{"_parent":"metrics-users-badge","id":"metrics-users-change","type":"text","name":"Users Change","content":"+8.2%","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":600,"lineHeight":"$type-caption-line-height","width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-success-text"}]}
{"_parent":"metrics-card-users","id":"metrics-users-value","type":"text","name":"Users Value","content":"24,892","fontFamily":"Inter","fontSize":32,"fontWeight":700,"lineHeight":1.1,"letterSpacing":-0.5,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-primary"}]}
{"_parent":"metrics-row","id":"metrics-card-profit","type":"frame","name":"Profit Card","role":"stat-card","width":"fill_container","height":"fill_container","layout":"vertical","gap":18,"padding":20,"cornerRadius":12,"fill":[{"type":"solid","color":"$color-surface"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"$color-border"}]},"effects":[{"type":"shadow","offsetX":0,"offsetY":8,"blur":24,"spread":0,"color":"#0F172A14"}]}
{"_parent":"metrics-card-profit","id":"metrics-profit-top","type":"frame","name":"Profit Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0}
{"_parent":"metrics-profit-top","id":"metrics-profit-label","type":"text","name":"Profit Label","role":"label","content":"Net Profit","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":500,"lineHeight":"$type-caption-line-height","letterSpacing":1.2,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-muted"}]}
{"_parent":"metrics-profit-top","id":"metrics-profit-badge","type":"frame","name":"Profit Change Badge","role":"badge","width":"fit_content","height":"fit_content","layout":"horizontal","alignItems":"center","gap":4,"padding":[6,8],"cornerRadius":999,"fill":[{"type":"solid","color":"$color-danger-bg"}]}
{"_parent":"metrics-profit-badge","id":"metrics-profit-trend-icon","type":"icon_font","name":"Profit Trend Icon","iconFontName":"trending-down","width":14,"height":14,"fill":"$color-danger-text"}
{"_parent":"metrics-profit-badge","id":"metrics-profit-change","type":"text","name":"Profit Change","content":"-3.1%","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":600,"lineHeight":"$type-caption-line-height","width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-danger-text"}]}
{"_parent":"metrics-card-profit","id":"metrics-profit-value","type":"text","name":"Profit Value","content":"$38.9K","fontFamily":"Inter","fontSize":32,"fontWeight":700,"lineHeight":1.1,"letterSpacing":-0.5,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-primary"}]}
{"_parent":"metrics-row","id":"metrics-card-conversion","type":"frame","name":"Conversion Card","role":"stat-card","width":"fill_container","height":"fill_container","layout":"vertical","gap":18,"padding":20,"cornerRadius":12,"fill":[{"type":"solid","color":"$color-surface"}],"stroke":{"thickness":1,"fill":[{"type":"solid","color":"$color-border"}]},"effects":[{"type":"shadow","offsetX":0,"offsetY":8,"blur":24,"spread":0,"color":"#0F172A14"}]}
{"_parent":"metrics-card-conversion","id":"metrics-conversion-top","type":"frame","name":"Conversion Header","width":"fill_container","height":"fit_content","layout":"horizontal","justifyContent":"space_between","alignItems":"center","gap":12,"padding":0}
{"_parent":"metrics-conversion-top","id":"metrics-conversion-label","type":"text","name":"Conversion Label","role":"label","content":"Conversion","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":500,"lineHeight":"$type-caption-line-height","letterSpacing":1.2,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-muted"}]}
{"_parent":"metrics-conversion-top","id":"metrics-conversion-badge","type":"frame","name":"Conversion Change Badge","role":"badge","width":"fit_content","height":"fit_content","layout":"horizontal","alignItems":"center","gap":4,"padding":[6,8],"cornerRadius":999,"fill":[{"type":"solid","color":"$color-info-bg"}]}
{"_parent":"metrics-conversion-badge","id":"metrics-conversion-trend-icon","type":"icon_font","name":"Conversion Trend Icon","iconFontName":"trending-up","width":14,"height":14,"fill":"$color-info-text"}
{"_parent":"metrics-conversion-badge","id":"metrics-conversion-change","type":"text","name":"Conversion Change","content":"+1.8%","fontFamily":"Inter","fontSize":"$type-caption-size","fontWeight":600,"lineHeight":"$type-caption-line-height","width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-info-text"}]}
{"_parent":"metrics-card-conversion","id":"metrics-conversion-value","type":"text","name":"Conversion Value","content":"6.84%","fontFamily":"Inter","fontSize":32,"fontWeight":700,"lineHeight":1.1,"letterSpacing":-0.5,"width":"fit_content","textGrowth":"auto","fill":[{"type":"solid","color":"$color-text-primary"}]}