Fixes from agent reports: hex colours everywhere, camelCase in/out, unique variable ids, strict batch, ui.key modifiers, export rounding, Fit images, inner shadows on all edges

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-09 13:34:25 -04:00
co-authored by Claude Opus 5.5
parent b36fd771ab
commit b26b449fe3
13 changed files with 177 additions and 28 deletions
+6 -1
View File
@@ -250,7 +250,12 @@ pub fn specs() -> Vec<CommandSpec> {
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
let cmds: Vec<(String, Value)> = match get(p, "commands") {
Some(Value::Array(a)) => {
a.iter().filter_map(|c| Some((c.get("command")?.as_str()?.to_string(), c.get("params").cloned().unwrap_or(json!({}))))).collect()
let mut out = Vec::new();
for (i, c) in a.iter().enumerate() {
let cmd = c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
out.push((cmd.to_string(), c.get("params").cloned().unwrap_or(json!({}))));
}
out
}
_ => return Err(bad("command.batch", "`commands` must be an array")),
};
+30 -4
View File
@@ -19,6 +19,32 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l
(widthAndHeight|height|none|truncate), textDecoration, textCase, paragraphSpacing, listStyle, pointCount, innerRadius, arcStart, arcSweep, \
arcInner, constrainProportions, description, interactions, overflow, pluginData {k: v}";
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
pub fn snake(v: &Value) -> Value {
match v {
Value::Object(m) => Value::Object(
m.iter()
.map(|(k, v)| {
let mut out = String::new();
for c in k.chars() {
if c.is_ascii_uppercase() {
out.push('_');
out.push(c.to_ascii_lowercase());
} else {
out.push(c);
}
}
// Paint/effect type tags stay as they are.
let key = if matches!(k.as_str(), "type" | "kind") { k.clone() } else { out };
(key, if matches!(k.as_str(), "type" | "kind") { v.clone() } else { snake(v) })
})
.collect(),
),
Value::Array(a) => Value::Array(a.iter().map(snake).collect()),
other => other.clone(),
}
}
fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
let s = v.as_str()?;
// Accept camelCase, snake/upper-case and spaced variants.
@@ -209,7 +235,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
}
}
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("effects: {e}")))?,
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
"layout" | "layoutMode" => {
@@ -229,7 +255,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
};
ensure_layout(n).mode = mode;
}
Value::Object(_) => n.layout = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("layout: {e}")))?),
Value::Object(_) => n.layout = Some(serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("layout: {e}")))?),
_ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")),
}
}
@@ -293,9 +319,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
n.constraints.vertical = enum_of(vv).ok_or_else(|| bad(cmd, "vertical: top|bottom|topBottom|center|scale"))?;
}
}
"grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("grids: {e}")))?,
"grids" | "layoutGrids" => doc.get_mut(id)?.grids = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("grids: {e}")))?,
"exports" | "exportSettings" => {
doc.get_mut(id)?.exports = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("exports: {e}")))?
doc.get_mut(id)?.exports = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("exports: {e}")))?
}
"interactions" => {
doc.get_mut(id)?.interactions = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("interactions: {e}")))?
+8 -2
View File
@@ -72,7 +72,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
let name = req_string("variable.createCollection", p, "name")?;
let doc = s.doc_mut()?;
let id = format!("VariableCollectionId:{}", doc.variables.collections.len() + 1);
let id = unique_id("VariableCollectionId", doc.variables.collections.iter().map(|c| c.id.as_str()));
let modes: Vec<String> = match get(p, "modes") {
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
_ => vec!["Mode 1".into()],
@@ -396,7 +396,7 @@ fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
for m in &coll.modes {
values.entry(m.id.clone()).or_insert_with(|| default.clone());
}
let id = format!("VariableID:{}", doc.variables.variables.len() + 1);
let id = unique_id("VariableID", doc.variables.variables.iter().map(|v| v.id.as_str()));
doc.variables.variables.push(Variable {
id: id.clone(),
name,
@@ -455,3 +455,9 @@ pub fn apply_bindings(doc: &mut Document) {
let _ = super::props::apply("variables", doc, id, &sets);
}
}
/// `prefix:N` with N one more than the largest existing.
fn unique_id<'a>(prefix: &str, existing: impl Iterator<Item = &'a str>) -> String {
let max = existing.filter_map(|e| e.strip_prefix(prefix)?.strip_prefix(':')?.parse::<u64>().ok()).max().unwrap_or(0);
format!("{prefix}:{}", max + 1)
}