fix(engine): read the documented gradient shorthand on every route (#252)

`{"type":"linearGradient","stops":["#hex",…]}` was only expanded by the MCP
tools, so `figcraft-cli run` and the control channel's `engine.execute`
refused it ("`fills` must be paints or colours"). The expansion now lives in
the engine (`params::normalize_paint`, used by `paint_of`), so node.set,
node.create props, fill.add/set, strokes and paint styles all take it; MCP
calls the engine's copy instead of its own.

Tests: engine (every paint command, MCP-equal defaults, junk still refused),
control channel `engine.execute`, and `figcraft-cli run` end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Piettro Castro
2026-10-14 01:02:45 -05:00
co-authored by Claude Opus 5.5
parent bcedfeba10
commit f3cd0509cb
10 changed files with 238 additions and 71 deletions
+21
View File
@@ -78,6 +78,27 @@ fn run_export_info_convert() {
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn run_takes_the_documented_gradient_shorthand() {
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
let d = tmp("gradient");
let script = d.join("s.json");
std::fs::write(
&script,
r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}},
{"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##,
)
.unwrap();
let file = d.join("g.figcraft");
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
let json = d.join("g.json");
assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success());
let doc = std::fs::read_to_string(&json).unwrap();
assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn mcp_over_stdio() {
let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
+1 -1
View File
@@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
/// Keys `node.set` understands (documented for agents).
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
@@ -0,0 +1,107 @@
//! The documented gradient shorthand (`{"type":"linearGradient","stops":["#hex",…]}`) works
//! through every route into the engine, not only MCP (#252): `figcraft-cli run` and the control
//! channel send params straight to `Session::execute`.
use figcraft_color::Paint;
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
use crate::params::{id_of, paint_of};
/// The red→blue left-to-right gradient as written in the docs.
fn short() -> Value {
json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]})
}
/// The same gradient in the engine's own form.
fn long() -> Value {
json!({"type": "gradient", "kind": "linear",
"stops": [{"position": 0, "color": {"r": 1, "g": 0, "b": 0, "a": 1}},
{"position": 1, "color": {"r": 0, "g": 0, "b": 1, "a": 1}}],
"from": [0, 0.5], "to": [1, 0.5], "width": [0, 1.5]})
}
fn rect(s: &mut Session) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap()
}
fn fills(s: &Session, id: NodeId) -> Vec<Paint> {
s.doc().unwrap().node(id).unwrap().fills.clone()
}
#[test]
fn the_shorthand_reads_as_the_long_form() {
assert_eq!(paint_of(&short()).expect("the documented shorthand is a paint"), paint_of(&long()).unwrap());
}
#[test]
fn node_set_takes_the_shorthand() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("node.set", &json!({"ids": [r], "fills": [short()]})).unwrap();
s.execute("node.set", &json!({"ids": [r], "strokes": [short()]})).unwrap();
let want = paint_of(&long()).unwrap();
assert_eq!(fills(&s, r), vec![want.clone()]);
assert_eq!(s.doc().unwrap().node(r).unwrap().strokes, vec![want]);
}
#[test]
fn node_create_fill_add_and_fill_set_take_the_shorthand() {
let want = paint_of(&long()).unwrap();
let mut s = Session::new();
let v = s.execute("node.create", &json!({"type": "rectangle", "width": 200, "height": 100, "props": {"fills": [short()]}})).unwrap();
let r = id_of(&v["id"]).unwrap();
assert_eq!(fills(&s, r), vec![want.clone()]);
s.execute("fill.add", &json!({"ids": [r], "paint": short()})).unwrap();
assert_eq!(fills(&s, r), vec![want.clone(), want.clone()]);
let r2 = rect(&mut s);
s.execute("fill.set", &json!({"ids": [r2], "index": 0, "paint": short()})).unwrap();
assert_eq!(fills(&s, r2), vec![want]);
}
#[test]
fn shorthand_defaults_match_the_mcp_server() {
// No handles: top to bottom, with a square width handle.
let p = paint_of(&json!({"type": "linearGradient", "stops": ["#000000", "#ffffff"]})).unwrap();
let v = serde_json::to_value(&p).unwrap();
assert_eq!(
(v["kind"].clone(), v["from"].clone(), v["to"].clone(), v["width"].clone()),
(json!("linear"), json!([0.5, 0.0]), json!([0.5, 1.0]), json!([-0.5, 0.0]))
);
// Plain colour stops are spread evenly.
let p = paint_of(&json!({"type": "radialGradient", "stops": ["#000000", "#808080", "#ffffff"]})).unwrap();
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["kind"], "radial");
let pos: Vec<f64> = v["stops"].as_array().unwrap().iter().map(|s| s["position"].as_f64().unwrap()).collect();
assert_eq!(pos, vec![0.0, 0.5, 1.0]);
for kind in ["angular", "diamond"] {
let p = paint_of(&json!({"type": format!("{kind}Gradient"), "stops": ["#000000", "#ffffff"]})).unwrap();
assert_eq!(serde_json::to_value(&p).unwrap()["kind"], kind);
}
// A CSS colour inside a solid paint or a positioned stop is read too.
assert_eq!(paint_of(&json!({"type": "solid", "color": "#00ff00"})), paint_of(&json!("#00ff00")));
let p = paint_of(&json!({"type": "gradient", "stops": [{"position": 0, "color": "#ff0000"}, {"position": 1, "color": "#0000ff"}], "from": [0, 0.5], "to": [1, 0.5]})).unwrap();
assert_eq!(p, paint_of(&long()).unwrap());
}
#[test]
fn junk_is_still_refused() {
let mut s = Session::new();
let r = rect(&mut s);
for junk in [
json!({"type": "linearGradient", "stops": "red"}),
json!({"type": "linearGradient", "stops": [42]}),
json!({"type": "fooGradient", "stops": ["#ff0000"]}),
json!({"type": "linearGradient", "stops": ["#ff0000"], "from": "left"}),
] {
let e = s.execute("node.set", &json!({"ids": [r], "fills": [junk.clone()]})).unwrap_err().to_string();
assert!(e.contains("`fills` must be paints or colours"), "{junk}: {e}");
}
// Deep nesting doesn't overflow the stack.
let mut deep = json!("#ff0000");
for _ in 0..200 {
deep = json!({"type": "linearGradient", "stops": [{"position": 0, "color": deep}]});
}
assert!(paint_of(&deep).is_none());
}
+2
View File
@@ -1467,6 +1467,8 @@ mod fx_behaviour_tests;
#[cfg(test)]
mod gesture_preview_tests;
#[cfg(test)]
mod gradient_shorthand_tests;
#[cfg(test)]
mod hide_publishing_tests;
#[cfg(test)]
mod history_behaviour_tests;
+71 -3
View File
@@ -3,7 +3,7 @@
use figcraft_color::{BlendMode, Color, Paint};
use figcraft_doc::NodeId;
use serde_json::Value;
use serde_json::{Value, json};
use crate::{EngineError, Result, Session, bad};
@@ -135,12 +135,80 @@ pub fn color(p: &Value, key: &str) -> Option<Color> {
get(p, key).and_then(color_of)
}
/// A paint from JSON: a colour string/object (solid) or a full paint object.
/// Read the friendlier paint/effect JSON that the docs and MCP tools describe as the engine's own
/// form: any `"color"` given as a CSS string ("#RRGGBB[AA]", "rgb(…)") becomes {r,g,b,a},
/// `{"type":"linearGradient"}` becomes `{"type":"gradient","kind":"linear"}`, gradients get default
/// handles (top to bottom) when `from`/`to`/`width` are missing, and stops given as plain colours
/// are spread evenly. Anything else is left for serde to refuse.
pub fn normalize_paint(v: &mut Value) {
normalize_paintish(v, 0);
}
fn normalize_paintish(v: &mut Value, depth: usize) {
if depth > 16 {
return;
}
match v {
Value::Array(a) => {
for x in a {
normalize_paintish(x, depth + 1);
}
}
Value::Object(o) => {
if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(color_of) {
o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null));
}
let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient"));
if is_gradient {
// Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}.
if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase)
&& t != "gradient"
{
let kind = t.trim_end_matches("gradient").to_string();
o.insert("type".into(), json!("gradient"));
o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind }));
}
o.entry("kind").or_insert(json!("linear"));
let linear = o.get("kind").and_then(Value::as_str) == Some("linear");
let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) };
o.entry("from").or_insert(json!(from));
o.entry("to").or_insert(json!(to));
// A linear gradient's width handle goes square to its axis (else its bands skew,
// or vanish when the handle lies on the axis).
let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
if linear
&& !o.contains_key("width")
&& let (Some(f), Some(t)) = (pt("from"), pt("to"))
{
o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)]));
}
o.entry("width").or_insert(json!(width));
if let Some(Value::Array(stops)) = o.get_mut("stops") {
let n = stops.len();
for (i, s) in stops.iter_mut().enumerate() {
if let Some(c) = color_of(s).filter(|_| s.is_string()) {
let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
*s = json!({"position": pos, "color": c});
} else {
normalize_paintish(s, depth + 1);
}
}
}
}
}
_ => {}
}
}
/// A paint from JSON: a colour string/object (solid), a full paint object or its shorthand
/// ([`normalize_paint`]).
pub fn paint_of(v: &Value) -> Option<Paint> {
if let Some(c) = color_of(v) {
return Some(Paint::solid(c));
}
let mut p: Paint = serde_json::from_value(v.clone()).ok()?;
let mut v = v.clone();
normalize_paint(&mut v);
let mut p: Paint = serde_json::from_value(v).ok()?;
p.opacity = if p.opacity.is_finite() { p.opacity.clamp(0.0, 1.0) } else { 1.0 };
Some(p)
}
+6 -62
View File
@@ -3,6 +3,7 @@
//! behave the same.
use base64::Engine as _;
use figcraft_engine::params::normalize_paint;
use serde_json::{Map, Value, json};
use crate::backend::{Backend, decode_data, page_children};
@@ -369,74 +370,17 @@ fn bounds_of(b: &mut dyn Backend, ids: &[u64]) -> Option<(f64, f64, f64, f64)> {
acc
}
/// Make paint/effect JSON friendlier than the engine's strict serde: any `"color"` given as a CSS
/// string ("#RRGGBB[AA]", "rgb(…)") becomes the engine's {r,g,b,a} object, gradient paints get
/// default handles (top-to-bottom) when `from`/`to`/`width` are missing, and gradient stops given
/// as plain colours are spread evenly.
/// Make paint/effect JSON friendlier than the engine's strict serde (see
/// [`figcraft_engine::params::normalize_paint`]). The engine reads paints this way itself; effects
/// still need it here.
pub fn normalize_props(props: &mut Map<String, Value>) {
for k in ["fills", "strokes", "effects"] {
if let Some(v) = props.get_mut(k) {
normalize_paintish(v, 0);
normalize_paint(v);
}
}
}
fn normalize_paintish(v: &mut Value, depth: usize) {
if depth > 16 {
return;
}
match v {
Value::Array(a) => {
for x in a {
normalize_paintish(x, depth + 1);
}
}
Value::Object(o) => {
if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(figcraft_engine::params::color_of) {
o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null));
}
let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient"));
if is_gradient {
// Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}.
if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase)
&& t != "gradient"
{
let kind = t.trim_end_matches("gradient").to_string();
o.insert("type".into(), json!("gradient"));
o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind }));
}
o.entry("kind").or_insert(json!("linear"));
let linear = o.get("kind").and_then(Value::as_str) == Some("linear");
let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) };
o.entry("from").or_insert(json!(from));
o.entry("to").or_insert(json!(to));
// A linear gradient's width handle goes square to its axis (else its bands skew,
// or vanish when the handle lies on the axis).
let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
if linear
&& !o.contains_key("width")
&& let (Some(f), Some(t)) = (pt("from"), pt("to"))
{
o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)]));
}
o.entry("width").or_insert(json!(width));
if let Some(Value::Array(stops)) = o.get_mut("stops") {
let n = stops.len();
for (i, s) in stops.iter_mut().enumerate() {
if let Some(c) = figcraft_engine::params::color_of(s).filter(|_| s.is_string()) {
let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 };
*s = json!({"position": pos, "color": c});
} else {
normalize_paintish(s, depth + 1);
}
}
}
}
}
_ => {}
}
}
/// [`normalize_props`] for command params: `props`, flat node.set keys, `paint`, `effect`.
fn friendly_params(p: &mut Value) {
let Some(o) = p.as_object_mut() else { return };
@@ -446,7 +390,7 @@ fn friendly_params(p: &mut Value) {
normalize_props(o);
for k in ["paint", "effect"] {
if let Some(v) = o.get_mut(k) {
normalize_paintish(v, 0);
normalize_paint(v);
}
}
}
@@ -0,0 +1,21 @@
//! The control channel takes the documented gradient shorthand, as MCP does (#252).
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
#[test]
fn engine_execute_takes_the_gradient_shorthand() {
let mut u = Ui::new(Session::new());
let exec = |u: &mut Ui, command: &str, params: serde_json::Value| {
crate::control::handle(&mut u.app, &u.ctx.clone(), "engine.execute", &json!({"command": command, "params": params}))
};
let r = exec(&mut u, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone();
let short = json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]});
exec(&mut u, "node.set", json!({"ids": [r], "fills": [short]})).unwrap();
let n = exec(&mut u, "node.get", json!({"id": r, "depth": 0})).unwrap();
let f = &n["fills"][0];
assert_eq!((f["type"].clone(), f["kind"].clone(), f["width"].clone()), (json!("gradient"), json!("linear"), json!([0.0, 1.5])), "{n}");
assert_eq!(f["stops"].as_array().map(Vec::len), Some(2), "{n}");
}
+2
View File
@@ -84,6 +84,8 @@ mod frame_label_tests;
#[cfg(test)]
mod gap_handle_tests;
#[cfg(test)]
mod gradient_shorthand_tests;
#[cfg(test)]
mod home_recents_tests;
#[cfg(test)]
mod home_tests;
+3 -1
View File
@@ -51,7 +51,9 @@ unknown id is an error.
Run any command (the same ids as the menus, shortcuts, Actions palette, CLI and MCP). Programmatic
calls never open dialogs: dialog requests are dropped. Returns the command's result (creation
commands return `{"id": N}`).
commands return `{"id": N}`). Paints (`fills`, `strokes`, `paint`) take a colour, the full paint
JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"],"from"?,"to"?}`
(as in `docs/mcp.md`).
```json
{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}
+4 -4
View File
@@ -128,10 +128,10 @@ Or in a project's `.mcp.json`:
resolved `gridCell`.
- **Every menu action is a command** (`list_commands`), and most commands act on `ids` when given,
else on the selection. New layers become the selection.
- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills, strokes and effects passed through the MCP
tools, `color` may also be a hex string and gradients can be written
`{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; the server converts them to the
engine's paint JSON.
- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills and strokes, `color` may also be a hex
string and gradients can be written `{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`;
the engine reads them as its paint JSON, so `run_command`, `figcraft-cli run` and the control
channel take them too. Effects passed through the MCP tools may use hex `color` strings as well.
- **Blend modes**: `blend` on a layer (`node.set`) or a paint (`fill.set` / `stroke.set`) is a
menu label or CSS keyword (`"Normal"`, `"pass through"`, `"multiply"`, `"plusDarker"`, ...;
`normal` is Normal). Groups and frames start as pass-through: their children blend with what is