fix(mcp): emit numeric gap/padding in system-mode builders

theme=system audit (D2 prerequisite): the schema accepts $spacing-*
expression refs in gap/padding, but both Rust layout paths zero them
out — jian-core container_to_style and op-pen-loader gap_value fall
through Expression to 0 — so a ref collapses the layout instead of
resolving. Exactly three emission sites existed: modal_shell's card
padding ($spacing-5) and gap ($spacing-3), and toast's pill gap
($spacing-2). Emit the palette-equal numbers (24/12/8) in every mode,
same pattern as the numeric font-size toast fix (011f4104). A new
catalog-wide invariant test builds all kinds with theme=system and
rejects any expression gap/padding, so future ports can't reintroduce
the class. Color $refs are unaffected — scene_vars resolves fill and
stroke refs at paint.
This commit is contained in:
Fini 2026-06-13 01:05:46 +08:00
parent 05e1e4e2d8
commit ebcb85a569
3 changed files with 62 additions and 14 deletions

View file

@ -139,7 +139,10 @@ fn build_toast(args: &BTreeMap<String, String>, theme_aware: bool) -> Result<Val
// fontSize is a strict-number schema slot — emit 14 in every mode
// (the TS `$type-body-size` ref resolves to the same default at
// render time; see the fidelity caveat on `build_modal_shell`).
// gap accepts an expression ref, so system mode keeps `$spacing-2`.
// gap must ALSO stay numeric: the layout chain zeroes Expression
// gap (jian-core `container_to_style` / op-pen-loader `gap_value`),
// so a `$spacing-2` ref would collapse the pill. 8 equals the
// semantic palette's spacing-2 value.
let (pill_fill, fg, font_size, gap) = if theme_aware {
match theme {
"dark" => (json!("#F1F5F9"), json!("#0F172A"), json!(14), json!(8)),
@ -147,7 +150,7 @@ fn build_toast(args: &BTreeMap<String, String>, theme_aware: bool) -> Result<Val
json!("$color-text-primary"),
json!("$color-surface"),
json!(14),
json!("$spacing-2"),
json!(8),
),
_ => (json!("#111827"), json!("#FFFFFF"), json!(14), json!(8)),
}

View file

@ -745,4 +745,51 @@ mod tests {
failures.join("\n")
);
}
/// 收集子树里所有布局层不解析的 expression gap/padding。Rust 布局链
/// 把 Expression 归零(jian-core `container_to_style` 与 op-pen-loader
/// `gap_value` 的 `_ => 0` 臂),所以任何 `$spacing-*` ref 都等于
/// gap/padding 直接消失。
fn collect_expression_layout_refs(node: &PenNode, kind: &str, hits: &mut Vec<String>) {
use jian_ops_schema::node::base::NumberOrExpression;
use jian_ops_schema::node::container::Padding;
let (container, children) = match node {
PenNode::Frame(f) => (Some(&f.container), f.children.as_deref()),
PenNode::Group(g) => (Some(&g.container), g.children.as_deref()),
PenNode::Rectangle(r) => (Some(&r.container), None),
_ => (None, None),
};
if let Some(c) = container {
if let Some(NumberOrExpression::Expression(expr)) = c.gap.as_ref() {
hits.push(format!("{kind}: gap = {expr:?}"));
}
if let Some(Padding::Expression(expr)) = c.padding.as_ref() {
hits.push(format!("{kind}: padding = {expr:?}"));
}
}
for child in children.unwrap_or(&[]) {
collect_expression_layout_refs(child, kind, hits);
}
}
/// system 模式布局不变量:任何 kind 在 theme=system 下都不得发
/// `$spacing-*` 等 expression gap/padding(2026-06-13 审计:曾有
/// modal_shell 的 padding/gap 与 toast 的 gap 三处,system 模式下
/// 卡片/胶囊直接坍缩)。颜色 `$color-*` ref 不在此列——scene_vars
/// 的 VariableTable 在 paint 期解析 fill/stroke ref。
#[test]
fn system_mode_never_emits_expression_gap_or_padding() {
let mut hits = Vec::new();
for kind in known_element_kinds() {
let built = build_element(&kind, &args(&[("theme", "system")]))
.unwrap_or_else(|err| panic!("{kind} must build in system mode: {err:?}"));
collect_expression_layout_refs(&built.node, &kind, &mut hits);
}
assert!(
hits.is_empty(),
"{} expression gap/padding emission(s) — the layout chain zeroes these:\n{}",
hits.len(),
hits.join("\n")
);
}
}

View file

@ -107,9 +107,12 @@ fn shell_theme_colors(theme_aware: bool, args: &BTreeMap<String, String>) -> She
/// numbers in every mode. In `system` mode the TS would emit `$type-*` refs
/// for these; the Rust `fontSize`/`fontWeight`/`lineHeight` schema slots are
/// strict numbers, and the refs resolve to these same defaults at render
/// time, so the rendered result is identical. Card padding DOES accept an
/// expression ref, so in system mode (no explicit override) we faithfully
/// emit `$spacing-5`; gap likewise emits `$spacing-3`.
/// time, so the rendered result is identical. Padding/gap must ALSO stay
/// numeric in system mode: the schema accepts `$spacing-*` expression refs
/// but the Rust layout chain zeroes them (jian-core `container_to_style`
/// and op-pen-loader `gap_value` both fall through Expression to 0), which
/// collapses the card. 24/12 equal the semantic palette's spacing-5/3
/// values, so the rendered result matches a resolved ref.
fn build_modal_shell(
args: &BTreeMap<String, String>,
theme_aware: bool,
@ -129,19 +132,14 @@ fn build_modal_shell(
};
let c = shell_theme_colors(theme_aware, args);
// Padding: explicit override (max(12, floor)) wins in every mode; absent +
// system → $spacing-5 ref; absent + light/dark → 24.
// Padding: explicit override (max(12, floor)) wins; absent → 24 in
// every mode (numeric — see the fidelity caveat above).
let card_padding: Value = match args.get("card_padding").and_then(|v| v.parse::<f64>().ok()) {
Some(raw) => json!(raw.floor().max(12.0)),
None if theme == "system" => json!("$spacing-5"),
None => json!(24),
};
// gap: light/dark → 12, system → $spacing-3.
let card_gap: Value = if theme == "system" {
json!("$spacing-3")
} else {
json!(12)
};
// gap: 12 in every mode (numeric — see the fidelity caveat above).
let card_gap: Value = json!(12);
// Title: v0/light has no fill; v1 dark/system gets textPrimary.
let mut title_node = json!({