fix(editor-core): ref slot-children fallback — instance inline children fill empty-master shells (Pencil slot-component pattern)

Pencil's slot-component pattern uses an empty styled master (slot:[...] + zero children)
with the instance supplying content via its own inline children. expand_ref only
populated children from the master, dropping instance content → empty shell → blank
white circle in converted templates. Now falls back to instance children (remapped)
when the master has none. Fixes converted-template stat-card rendering + composed-ref
content. +2 ref_resolve tests +1 variables_resolve nested-token regression guard.
This commit is contained in:
Fini 2026-07-02 21:21:20 +08:00
parent 72dbf535d5
commit 77f60a4efb
2 changed files with 130 additions and 1 deletions

View file

@ -152,7 +152,20 @@ fn expand_ref(
.and_then(Value::as_str)
.unwrap_or_default();
let overrides = ref_map.get("descendants").and_then(Value::as_object);
if let Some(Value::Array(children)) = component_map.get("children") {
// Pencil's slot-component pattern: the master is an empty styled
// shell carrying a `slot:[…]` id list, and each instance supplies
// its real content as its OWN inline `children`. When the master
// has no children to render, fall back to the instance's inline
// slot content so the shell isn't rendered empty (an empty
// `fit_content` frame collapses to its padding box → a white
// circle for a high-`cornerRadius` card). The master-has-children
// branch is unchanged, so the 115 normal refs and the master-with-
// default-content (ambiguous) refs keep TS-parity behaviour.
let children_source = match component_map.get("children") {
Some(Value::Array(children)) if !children.is_empty() => Some(children),
_ => ref_map.get("children").and_then(Value::as_array),
};
if let Some(children) = children_source {
let remapped: Vec<Value> = children
.iter()
.map(|child| remap_ids(child, ref_id, overrides))
@ -475,4 +488,92 @@ mod tests {
"the cyclic inner ref is dropped"
);
}
// Pencil slot-component pattern: the master is an empty styled
// shell (carrying a `slot` id list, no children of its own) and
// the instance supplies its real content as its OWN inline
// `children`. Without the fallback the expanded instance renders
// empty (a high-`cornerRadius` `fit_content` card collapses to a
// white circle).
const DOC_WITH_SLOT_INSTANCE: &str = r##"{
"version":"0.8.0",
"children":[
{"type":"frame","id":"cardMaster","name":"Card/Default","reusable":true,
"width":"fit_content","height":"fit_content","cornerRadius":24,"padding":16,
"slot":["regionA","regionB"],
"fill":[{"type":"solid","color":"#f5f5f5"}]},
{"type":"ref","id":"card1","ref":"cardMaster","x":40,"y":40,
"children":[
{"type":"text","id":"label","name":"Label","content":"Revenue"},
{"type":"text","id":"value","name":"Value","content":"$12.4k"}
]}
]
}"##;
#[test]
fn empty_master_falls_back_to_instance_slot_children() {
let doc = doc_from(DOC_WITH_SLOT_INSTANCE);
let resolved = resolve_refs_for_canvas(&doc);
assert_eq!(resolved.children.len(), 2);
let PenNode::Frame(card) = &resolved.children[1] else {
panic!("slot instance expands to the master's Frame type");
};
assert_eq!(card.base.id, "card1");
// The master shell's style is preserved (still the white card).
assert_eq!(card.base.x, Some(40.0));
let children = card
.children
.as_ref()
.expect("instance slot content fills the empty master shell");
assert_eq!(
children.len(),
2,
"instance's own inline children render instead of an empty shell"
);
let PenNode::Text(label) = &children[0] else {
panic!("first slot child survives");
};
// Slot children are remapped into the instance's virtual id
// space exactly like master children would be.
assert_eq!(label.base.id, "card1__label");
assert_eq!(
label.content,
jian_ops_schema::node::text::TextContent::Plain("Revenue".into())
);
}
#[test]
fn master_children_win_over_instance_children_no_regression() {
// A master WITH its own children keeps TS-parity behaviour:
// the instance's inline children never replace the master
// subtree (this guards the 115 normal + ambiguous refs against
// the slot-fallback path).
let doc = doc_from(
r##"{
"version":"0.8.0",
"children":[
{"type":"frame","id":"master","name":"Master","reusable":true,
"width":200,"height":100,
"children":[{"type":"text","id":"masterText","name":"M","content":"FromMaster"}]},
{"type":"ref","id":"inst","ref":"master","x":10,"y":10,
"children":[{"type":"text","id":"instText","name":"I","content":"FromInstance"}]}
]
}"##,
);
let resolved = resolve_refs_for_canvas(&doc);
let PenNode::Frame(inst) = &resolved.children[1] else {
panic!("frame");
};
let children = inst.children.as_ref().unwrap();
assert_eq!(children.len(), 1);
let PenNode::Text(text) = &children[0] else {
panic!("master text wins");
};
assert_eq!(text.base.id, "inst__masterText");
assert_eq!(
text.content,
jian_ops_schema::node::text::TextContent::Plain("FromMaster".into()),
"instance inline children do not override a master that has children"
);
}
}

View file

@ -848,6 +848,34 @@ mod tests {
assert_eq!(resolve_color_ref("$not-a-token", None, &theme_light), None);
}
#[test]
fn nested_namespaced_ref_resolves_against_literal_keyed_var() {
// Pencil emits namespaced tokens like `$surface/surface` and
// keys its variable table with the same literal nested string.
// `strip_prefix('$')` + a direct `vars.get("surface/surface")`
// hit already resolves them — no slash special-casing needed.
// This is the exact fill the converted Pencil cards carry, so
// it guards the resolver against a future nested-token regress.
let vars = vars_with(
"surface/surface",
color_var(VariableValue::Scalar(VariableScalar::Str("#f5f5f5".into()))),
);
let theme = Theme::new();
assert_eq!(
resolve_color_ref("$surface/surface", Some(&vars), &theme),
Some("#f5f5f5".to_string())
);
// A flat token sharing no slash still resolves unchanged.
let flat = vars_with(
"accent",
color_var(VariableValue::Scalar(VariableScalar::Str("#2563eb".into()))),
);
assert_eq!(
resolve_color_ref("$accent", Some(&flat), &theme),
Some("#2563eb".to_string())
);
}
#[test]
fn circular_ref_is_guarded() {
let vars = vars_with(