M6: Inspect tab: properties with styles and variables, redlines on hover (#41)

The Inspect tab lists the selected layer's properties for handoff:
layout (size, position from the parent, rotation, opacity, radius), auto
layout (direction, gap, padding, alignment), typography, fills, strokes
and effects, each with the style or variable it comes from. Clicking a
value copies it. With the Inspect tab open, hovering measures distances
from the selection without holding Alt.

New command inspect.properties returns the same groups for agents.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 06:07:48 +07:00 committed by GitHub
parent 476cdd95f1
commit d53cd60691
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 426 additions and 21 deletions

View File

@ -159,7 +159,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Styles & variables | 5 | 72% | binding effects, line height and letter spacing, variable groups in the editor sidebar | 3–5 |
| Prototyping | 6 | 70% | scrolling inside device frames, prototype preview pane in the editor | 3–5 |
| Import/export | 6 | 50% | PDF (in progress), .fig import, export panel polish, REST import of prototypes/variables | 18–32 |
| Dev mode / inspect | 2 | 55% | asset export list in Inspect, redlines in the Inspect tab, code for whole frames as components | 4–6 |
| Dev mode / inspect | 2 | 65% | asset export list in Inspect, code for whole frames as components | 3–4 |
| Comments, versions, misc | 3 | 45% | comment polish, plugins (WASM) | 9–15 |
Weighted feature depth ≈ **58%** (self-graded; the table's arithmetic gives ~59%).

View File

@ -81,6 +81,7 @@ fn registry() -> &'static Registry {
list.extend(crate::vectoredit::specs());
list.extend(crate::shapebuilder::specs());
list.extend(crate::codegen::specs());
list.extend(crate::devinspect::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {
if index.insert(s.id, i).is_some() {

View File

@ -0,0 +1,277 @@
//! Dev-mode inspection: a layer's properties grouped for handoff (layout, auto layout,
//! typography, fills, strokes, effects), each with the style or variable behind it when there is
//! one. The Inspect tab shows these, and `inspect.properties` returns them.
use figcraft_color::{Color, GradientKind, PaintKind};
use figcraft_doc::*;
use serde::Serialize;
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::params::*;
use crate::spec;
/// One group of properties ("Layout", "Typography"…).
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Section {
pub title: &'static str,
pub rows: Vec<Prop>,
}
/// One property: its label and value as shown, an optional colour swatch (hex with alpha) and
/// the style or variable name it comes from.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct Prop {
pub label: String,
pub value: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
}
pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.properties",
"Inspect properties",
"{id?} → {sections: [{title, rows: [{label, value, color?, token?}]}]} layout (relative to the parent), auto layout, typography, fills, strokes and effects, with style and variable names",
false,
|s, p| {
let id = match id(p, "id") {
Some(i) => i,
None => s.selection().first().copied().ok_or_else(|| crate::bad("inspect.properties", "select a layer or pass `id`"))?,
};
let doc = s.doc()?;
doc.node(id).ok_or_else(|| crate::bad("inspect.properties", "no such layer"))?;
Ok(json!({"sections": serde_json::to_value(properties(doc, id)).unwrap_or(Value::Null)}))
}
)]
}
/// A number as a designer reads it: at most two decimals, no trailing zeros.
fn num(v: f64) -> String {
if !v.is_finite() {
return "0".into();
}
let s = format!("{:.2}", (v * 100.0).round() / 100.0);
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.to_string() }
}
/// `#RRGGBB` plus the opacity when it isn't 100%.
fn color_text(c: Color) -> String {
if c.a < 0.999 { format!("#{} · {}%", c.to_hex(), (c.a * 100.0).round()) } else { format!("#{}", c.to_hex()) }
}
fn prop(label: impl Into<String>, value: impl Into<String>) -> Prop {
Prop { label: label.into(), value: value.into(), color: None, token: None }
}
impl Prop {
fn token(mut self, t: Option<String>) -> Self {
self.token = t;
self
}
fn color(mut self, c: Color) -> Self {
self.color = Some(c.to_css_hex());
self
}
}
/// The variable bound to `field` on `n`, by name.
fn variable(doc: &Document, n: &Node, field: &str) -> Option<String> {
n.bound.get(field).and_then(|v| doc.variables.get(v)).map(|v| v.name.clone())
}
fn style_name(doc: &Document, id: Option<&StyleId>) -> Option<String> {
id.and_then(|s| doc.style(s)).map(|s| s.name.clone())
}
/// The properties of `id`, grouped; empty when it doesn't exist.
pub fn properties(doc: &Document, id: NodeId) -> Vec<Section> {
let Some(n) = doc.node(id) else { return Vec::new() };
let mut out = vec![layout(doc, id, n)];
if let Some(l) = &n.layout {
out.push(auto_layout(doc, n, l));
}
if let Some(t) = n.kind.text() {
out.push(typography(doc, n, t));
}
let paint_rows = |paints: &[figcraft_color::Paint]| -> Vec<Prop> {
paints
.iter()
.filter(|p| p.visible)
.map(|p| match &p.kind {
PaintKind::Solid { color } => {
let c = color.with_alpha(color.a * p.opacity);
prop("Solid", color_text(c)).color(c)
}
PaintKind::Gradient(g) => {
let kind = match g.kind {
GradientKind::Linear => "Linear",
GradientKind::Radial => "Radial",
GradientKind::Angular => "Angular",
GradientKind::Diamond => "Diamond",
};
let stops: Vec<String> = g.stops.iter().map(|s| format!("#{} {}%", s.color.to_hex(), (s.position * 100.0).round())).collect();
let r = prop(kind, stops.join(", "));
match g.stops.first() {
Some(s) => r.color(s.color),
None => r,
}
}
PaintKind::Image(_) => prop("Image", format!("{}%", (p.opacity * 100.0).round())),
})
.collect()
};
let fills = paint_rows(&n.fills);
if !fills.is_empty() {
let token = style_name(doc, n.styles.fill.as_ref()).or_else(|| variable(doc, n, "fill"));
out.push(Section { title: "Fills", rows: tag_first(fills, token) });
}
let mut strokes = paint_rows(&n.strokes);
if !strokes.is_empty() {
let token = style_name(doc, n.styles.stroke.as_ref()).or_else(|| variable(doc, n, "stroke"));
strokes = tag_first(strokes, token);
let align = match n.stroke.align {
StrokeAlign::Inside => "inside",
StrokeAlign::Center => "center",
StrokeAlign::Outside => "outside",
};
strokes.push(prop("Weight", format!("{} {align}", num(n.stroke.weight))).token(variable(doc, n, "strokeWeight")));
out.push(Section { title: "Strokes", rows: strokes });
}
let effects: Vec<Prop> = n
.effects
.iter()
.filter(|e| e.visible)
.filter_map(|e| match &e.kind {
EffectKind::DropShadow(s) | EffectKind::InnerShadow(s) => {
let label = if matches!(e.kind, EffectKind::DropShadow(_)) { "Drop shadow" } else { "Inner shadow" };
let v = format!("{} {} {} {} {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), color_text(s.color));
Some(prop(label, v).color(s.color))
}
EffectKind::LayerBlur { radius } => Some(prop("Layer blur", num(*radius))),
EffectKind::BackgroundBlur { radius } => Some(prop("Background blur", num(*radius))),
_ => None,
})
.collect();
if !effects.is_empty() {
out.push(Section { title: "Effects", rows: tag_first(effects, style_name(doc, n.styles.effect.as_ref())) });
}
out
}
/// A style or variable applies to the whole list: show it on the first row.
fn tag_first(mut rows: Vec<Prop>, token: Option<String>) -> Vec<Prop> {
if let Some(first) = rows.first_mut() {
first.token = token;
}
rows
}
fn layout(doc: &Document, id: NodeId, n: &Node) -> Section {
let mut rows =
vec![prop("Width", num(n.width)).token(variable(doc, n, "width")), prop("Height", num(n.height)).token(variable(doc, n, "height"))];
// Position from the parent frame's top-left (from the page origin at the top level).
if let Some(b) = doc.world_bounds(id) {
let origin = n
.parent
.filter(|p| !doc.node(*p).is_some_and(|pn| pn.is_page()))
.and_then(|p| doc.world_bounds(p))
.map_or((0.0, 0.0), |pb| (pb.x0, pb.y0));
rows.push(prop("Top", num(b.y0 - origin.1)));
rows.push(prop("Left", num(b.x0 - origin.0)));
}
let rot = figcraft_geom::rotation_degrees(n.transform);
if rot.abs() > 0.01 {
rows.push(prop("Rotation", format!("{}°", num(rot))));
}
if n.opacity < 0.999 {
rows.push(prop("Opacity", format!("{}%", (f64::from(n.opacity) * 100.0).round())).token(variable(doc, n, "opacity")));
}
if n.kind.has_corner_radius() {
let r = match n.corners.uniform() {
Some(r) if r > 0.0 => Some(num(r)),
Some(_) => None,
None => Some(n.corners.radii.iter().map(|r| num(*r)).collect::<Vec<_>>().join(" ")),
};
if let Some(r) = r {
rows.push(prop("Radius", r).token(variable(doc, n, "cornerRadius")));
}
}
Section { title: "Layout", rows }
}
fn auto_layout(doc: &Document, n: &Node, l: &AutoLayout) -> Section {
let dir = match (l.mode, l.wrap) {
(LayoutMode::Horizontal, true) => "Horizontal, wrap",
(LayoutMode::Horizontal, false) => "Horizontal",
(LayoutMode::Vertical, _) => "Vertical",
(LayoutMode::Grid, _) => "Grid",
};
let mut rows = vec![prop("Direction", dir)];
if l.mode == LayoutMode::Grid {
rows.push(prop("Grid", format!("{} × {}", l.grid_columns, l.grid_rows)));
rows.push(prop("Gap", format!("{} {}", num(l.gap), num(l.cross_gap))));
} else {
let gap = if l.main_align == MainAlign::SpaceBetween { "Auto (space between)".to_string() } else { num(l.gap) };
rows.push(prop("Gap", gap).token(variable(doc, n, "gap")));
if l.wrap {
rows.push(prop("Row gap", num(l.cross_gap)).token(variable(doc, n, "crossGap")));
}
}
let [t, r, b, lft] = l.padding;
let padding = if t == r && r == b && b == lft {
num(t)
} else if t == b && r == lft {
format!("{} {}", num(t), num(r))
} else {
format!("{} {} {} {}", num(t), num(r), num(b), num(lft))
};
let pad_token = variable(doc, n, "padding").or_else(|| variable(doc, n, "paddingHorizontal")).or_else(|| variable(doc, n, "paddingVertical"));
rows.push(prop("Padding", padding).token(pad_token));
let main = match l.main_align {
MainAlign::Min => "start",
MainAlign::Center => "center",
MainAlign::Max => "end",
MainAlign::SpaceBetween => "space between",
};
let cross = match l.cross_align {
CrossAlign::Min => "start",
CrossAlign::Center => "center",
CrossAlign::Max => "end",
CrossAlign::Baseline => "baseline",
};
rows.push(prop("Align", format!("{main}, {cross}")));
Section { title: "Auto layout", rows }
}
fn typography(doc: &Document, n: &Node, t: &TextData) -> Section {
let s = &t.style;
let token = style_name(doc, n.styles.text.as_ref());
let mut rows = vec![
prop("Font", s.font_family.clone()).token(token),
prop("Style", s.font_style.clone()),
prop("Size", num(s.font_size)).token(variable(doc, n, "fontSize")),
prop("Line height", s.line_height.label()),
prop("Letter spacing", s.letter_spacing.label()),
];
if t.paragraph_spacing > 0.0 {
rows.push(prop("Paragraph spacing", num(t.paragraph_spacing)));
}
let align = match t.align_h {
TextAlignH::Left => "left",
TextAlignH::Center => "center",
TextAlignH::Right => "right",
TextAlignH::Justified => "justified",
};
rows.push(prop("Align", align));
if s.decoration != TextDecoration::None {
rows.push(prop("Decoration", if s.decoration == TextDecoration::Underline { "underline" } else { "strikethrough" }));
}
if variable(doc, n, "characters").is_some() {
rows.push(prop("Content", t.characters.chars().take(80).collect::<String>()).token(variable(doc, n, "characters")));
}
Section { title: "Typography", rows }
}

View File

@ -0,0 +1,84 @@
//! Dev-mode inspection: properties grouped with their styles and variables, positions relative
//! to the parent, and redlines on hover without Alt.
use serde_json::{Value, json};
use crate::Session;
use crate::devinspect::{Section, properties};
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
fn id(v: Value) -> figcraft_doc::NodeId {
id_of(&v["id"]).unwrap()
}
fn row<'a>(sections: &'a [Section], title: &str, label: &str) -> &'a crate::devinspect::Prop {
sections.iter().find(|s| s.title == title).and_then(|s| s.rows.iter().find(|r| r.label == label)).unwrap_or_else(|| panic!("{title}/{label}"))
}
#[test]
fn properties_group_layout_auto_layout_paints_and_tokens() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 100, "y": 50, "width": 300, "height": 200})).unwrap());
let r = id(s.execute("shape.rectangle", &json!({"x": 130, "y": 90, "width": 80, "height": 40, "parent": f})).unwrap());
s.execute("node.set", &json!({"id": r, "props": {"fill": "#0D99FF80", "cornerRadius": 6, "opacity": 0.5}})).unwrap();
s.execute("variable.createCollection", &json!({"name": "T"})).unwrap();
s.execute("variable.create", &json!({"collection": "T", "name": "radius/sm", "type": "number", "value": 6})).unwrap();
s.execute("variable.bind", &json!({"ids": [r], "field": "cornerRadius", "variable": "radius/sm"})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
s.execute("style.create", &json!({"type": "paint", "name": "Brand/Blue"})).unwrap();
s.execute("style.apply", &json!({"ids": [r], "style": "Brand/Blue"})).unwrap();
let doc = s.doc().unwrap();
let p = properties(doc, r);
assert_eq!(row(&p, "Layout", "Width").value, "80");
// Top/left from the parent frame's corner (the rectangle was placed in the frame's coordinates).
assert_eq!((row(&p, "Layout", "Top").value.as_str(), row(&p, "Layout", "Left").value.as_str()), ("90", "130"));
assert_eq!(row(&p, "Layout", "Opacity").value, "50%");
let radius = row(&p, "Layout", "Radius");
assert_eq!((radius.value.as_str(), radius.token.as_deref()), ("6", Some("radius/sm")));
let fill = &p.iter().find(|s| s.title == "Fills").unwrap().rows[0];
assert_eq!(fill.value, "#0D99FF · 50%");
assert_eq!(fill.color.as_deref(), Some("#0D99FF80"));
assert_eq!(fill.token.as_deref(), Some("Brand/Blue"));
assert!(p.iter().all(|s| s.title != "Auto layout" && s.title != "Typography"));
// Auto layout: direction, gap, padding shorthand, alignment.
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
s.execute("node.set", &json!({"id": f, "props": {"layoutMode": "vertical", "gap": 12, "padding": [8, 16, 8, 16]}})).unwrap();
let p = properties(s.doc().unwrap(), f);
assert_eq!(row(&p, "Auto layout", "Direction").value, "Vertical");
assert_eq!(row(&p, "Auto layout", "Gap").value, "12");
assert_eq!(row(&p, "Auto layout", "Padding").value, "8 16");
// At the top level, positions are from the page origin.
assert_eq!(row(&p, "Layout", "Left").value, "100");
// The command returns the same, for MCP.
let out = s.execute("inspect.properties", &json!({"id": r})).unwrap();
assert_eq!(out["sections"][0]["title"], "Layout");
assert!(s.execute("inspect.properties", &json!({"id": 999_999})).is_err());
}
#[test]
fn typography_section_for_text() {
let mut s = Session::new();
let t = id(s.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 0})).unwrap());
s.execute("node.set", &json!({"id": t, "props": {"fontSize": 18, "textAlignHorizontal": "center"}})).unwrap();
let p = properties(s.doc().unwrap(), t);
assert_eq!(row(&p, "Typography", "Size").value, "18");
assert!(p.iter().any(|s| s.title == "Typography"));
}
#[test]
fn redlines_measure_on_hover_without_alt() {
let mut s = Session::new();
let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 50, "height": 50})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let hover = |s: &mut Session| {
s.pointer(&PointerEvent::at(PointerKind::Move, 125.0, 25.0, 1.0)).unwrap();
s.tool.overlay.guides.len()
};
assert_eq!(hover(&mut s), 0, "plain hover doesn't measure");
s.tool.redlines = true;
assert!(hover(&mut s) > 0, "Inspect measures on hover");
}

View File

@ -11,6 +11,7 @@ pub mod catalog;
pub mod cmd;
pub mod codegen;
pub mod components;
pub mod devinspect;
pub mod drop;
pub mod guard;
pub mod inspect;
@ -868,6 +869,8 @@ mod al_drag_tests;
#[cfg(test)]
mod autosave_tests;
#[cfg(test)]
mod devinspect_tests;
#[cfg(test)]
mod frame_tests;
#[cfg(test)]
mod mask_tests;

View File

@ -334,6 +334,8 @@ pub struct ToolState {
/// removes; Alt merges).
pub shape_builder: Option<std::sync::Arc<crate::shapebuilder::Arrangement>>,
pub builder_remove: bool,
/// Show distances from the selection on hover without holding Alt (Dev mode inspection).
pub redlines: bool,
}
impl ToolState {
@ -534,13 +536,13 @@ impl Session {
} else if let Some(leaf) = doc.hit_test(page, ev.p(), ev.tol() * 2.0) {
let t = select_target(&doc, leaf, &sel, ev.cmd);
hover = Some(t);
if ev.alt && !sel.is_empty() && !sel.contains(&t) {
if (ev.alt || self.tool.redlines) && !sel.is_empty() && !sel.contains(&t) {
// Alt: measure from the selection to the hovered layer.
if let (Some(a), Some(b)) = (doc.bounds_of(&sel), doc.world_bounds(t)) {
guides = crate::snap::measure(a, b);
}
}
} else if ev.alt && !sel.is_empty() {
} else if (ev.alt || self.tool.redlines) && !sel.is_empty() {
// Alt over empty canvas inside a frame: distances to the parent.
if let Some(a) = doc.bounds_of(&sel)
&& let Some(p) = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).filter(|p| *p != page)

View File

@ -376,6 +376,8 @@ impl FigApp {
let t = theme::Tokens::get(&ctx);
// Control requests run first so their results reflect this frame.
control::drain(self, &ctx);
// The Inspect tab measures on hover without Alt.
self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect;
if self.ui.present.is_some() {
present::show(self, ui);
return;

View File

@ -1403,35 +1403,71 @@ fn export(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
}
/// The Inspect tab: size and position, and code for the selected layer.
/// One group of inspected properties: label, value (with a swatch for colours) and the style or
/// variable it comes from. Clicking a row copies its value.
fn inspect_section(app: &mut FigApp, ui: &mut Ui, s: &figcraft_engine::devinspect::Section) {
let t = Tokens::get(ui.ctx());
widgets::separator(ui);
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::hover());
ui.painter().text(pos2(hr.min.x + PAD, hr.center().y), Align2::LEFT_CENTER, s.title, semibold(11.0), t.text);
for p in &s.rows {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
let resp = resp.on_hover_text(format!("Copy {}", p.value));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover);
}
let painter = ui.painter();
painter.text(pos2(r.min.x + PAD, r.center().y), Align2::LEFT_CENTER, &p.label, regular(11.0), t.text_secondary);
// The style or variable, right-aligned as a chip.
let mut right = r.max.x - PAD;
if let Some(token) = &p.token {
let galley = painter.layout_no_wrap(token.clone(), regular(10.0), t.text_secondary);
let chip =
Rect::from_min_max(pos2((right - galley.size().x - 12.0).max(r.min.x + 196.0), r.center().y - 9.0), pos2(right, r.center().y + 9.0));
painter.rect_filled(chip, CornerRadius::same(4), t.field);
painter.with_clip_rect(chip.shrink2(vec2(6.0, 0.0))).galley(
pos2(chip.min.x + 6.0, chip.center().y - galley.size().y / 2.0),
galley,
t.text_secondary,
);
right = chip.min.x - 6.0;
}
let mut x = r.min.x + 96.0;
if let Some(c) = p.color.as_deref().and_then(figcraft_color::Color::from_hex) {
let sw = Rect::from_min_size(pos2(x, r.center().y - 6.0), vec2(12.0, 12.0));
widgets::paint_swatch(painter, sw, &Paint::solid(c));
x += 18.0;
}
let value_r = Rect::from_min_max(pos2(x, r.min.y), pos2(right.max(x + 10.0), r.max.y));
painter.with_clip_rect(value_r).text(pos2(x, r.center().y), Align2::LEFT_CENTER, &p.value, regular(11.0), t.text);
if resp.clicked() {
app.services.clipboard_set_text(&p.value);
app.toast(format!("Copied {}", p.label.to_lowercase()));
}
}
ui.add_space(4.0);
}
fn inspect(app: &mut FigApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let Ok(ds) = app.session.ds() else { return };
let doc = ds.doc.clone();
let sel = ds.selection.clone();
widgets::section_header(ui, "Inspect", &[], false);
let Some(first) = sel.first().copied() else {
row(ui, 32.0, |ui| widgets::label(ui, "Select a layer to see its code and measurements", t.text_secondary));
widgets::section_header(ui, "Inspect", &[], false);
row(ui, 32.0, |ui| widgets::label(ui, "Select a layer to see its properties and code", t.text_secondary));
row(ui, 32.0, |ui| widgets::label(ui, "Hover other layers to measure the distance", t.text_secondary));
return;
};
if let Some(b) = doc.world_bounds(first) {
row(ui, 32.0, |ui| {
widgets::label(
ui,
&format!(
"{} × {} at {}, {}",
crate::canvas::fmt(b.width()),
crate::canvas::fmt(b.height()),
crate::canvas::fmt(b.x0),
crate::canvas::fmt(b.y0)
),
t.text,
);
});
let name = doc.node(first).map(|n| n.name.clone()).unwrap_or_default();
widgets::section_header(ui, &name, &[], false);
for s in figcraft_engine::devinspect::properties(&doc, first) {
inspect_section(app, ui, &s);
}
widgets::separator(ui);
use figcraft_engine::codegen::{CodeLang, code_for};
let lang_id = Id::new("inspect_lang");
let lang = ui.data(|d| d.get_temp::<usize>(lang_id)).and_then(|i| CodeLang::ALL.get(i).copied()).unwrap_or_default();
widgets::separator(ui);
let copy = widgets::section_header(ui, "Code", &[(Icon::Export, "Copy")], false).is_some();
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code));

View File

@ -95,7 +95,7 @@ Or in a project's `.mcp.json`:
| Tool | What it does |
|---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`). |
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `inspect.code` (CSS, SwiftUI or Jetpack Compose for a layer), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names), `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. |
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`), text…); returns the updated layers. |