diff --git a/README.md b/README.md
index ae4c9f24c..b858b5c0c 100644
--- a/README.md
+++ b/README.md
@@ -191,7 +191,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
Prototype and hand off
- Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
+ Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs
index 463406368..edbae279f 100644
--- a/crates/engine/src/code_connect.rs
+++ b/crates/engine/src/code_connect.rs
@@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50;
/// The languages a component can be connected in.
pub fn language(s: &str) -> Option {
- CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))
+ CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose))
}
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
@@ -66,6 +66,8 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
_ if path.is_empty() => String::new(),
CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
+ // A single-file component is a default export.
+ CodeLang::Vue => format!("import {} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
_ => format!("import {path}"),
}
}
@@ -174,6 +176,8 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
match (swapped, lang) {
(Some(l), CodeLang::React) => format!("<{} />", l.name),
(Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name),
+ // A Vue binding takes the component itself.
+ (Some(l), CodeLang::Vue) => l.name.clone(),
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
(Some(l), _) => format!("{{ {}() }}", l.name),
(None, _) => value_expr(doc, c, p, v, lang),
@@ -195,11 +199,24 @@ pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)],
let e = expr(doc, c, p, v, lang);
// JSX and `view!`: a plain string reads best as an attribute value, anything
// else is an expression in braces (JSX attribute strings have no escapes).
- let markup = matches!(lang, CodeLang::React | CodeLang::Leptos);
- if !markup || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
- out.push_str(&e);
+ // A Vue binding takes a quoted value. A plain string literal brings its own
+ // quotes; the template's own quote, when it wrote one, ends it; anything else
+ // is wrapped here, and a quote inside is escaped for the attribute.
+ if lang == CodeLang::Vue {
+ if !out.ends_with('"') && e.starts_with('"') && !e.contains('\\') {
+ out.push_str(&e);
+ } else if out.ends_with('"') {
+ out.push_str(&e.replace('"', """));
+ } else {
+ out.push_str(&format!("\"{}\"", e.replace('"', """)));
+ }
} else {
- out.push_str(&format!("{{{e}}}"));
+ let markup = matches!(lang, CodeLang::React | CodeLang::Leptos);
+ if !markup || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
+ out.push_str(&e);
+ } else {
+ out.push_str(&format!("{{{e}}}"));
+ }
}
}
None => out.push_str(&format!("{{{inner}}}")),
@@ -221,6 +238,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
CodeLang::Leptos => {
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::())
}
+ CodeLang::Vue => format!("<{name}{} />", params.iter().map(|p| format!(" :{p}={{{p}}}")).collect::()),
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")),
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::>().join(", ")),
}
@@ -241,12 +259,14 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
let path_ok = match lang {
CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
+ CodeLang::Vue => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./@-".contains(ch)),
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
};
if path.chars().count() > MAX_IMPORT || !path_ok {
return Err(match lang {
CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"),
+ CodeLang::Vue => format!("importPath: a module path (letters, digits, _ . / @ and -), up to {MAX_IMPORT} characters"),
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
});
}
@@ -282,7 +302,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result> {
/// The `language` parameter (default React).
fn lang_param(cmd: &str, p: &Value) -> Result {
match text(cmd, p, "language")? {
- Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")),
+ Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose")),
None => Ok(CodeLang::React),
}
}
@@ -312,7 +332,7 @@ pub fn specs() -> Vec {
spec!(
"codeConnect.set",
"Connect to code",
- "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
+ "{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
true,
|s, p| {
let cmd = "codeConnect.set";
@@ -427,7 +447,7 @@ fn import(s: &mut Session, p: &Value) -> Result {
let (Some(component), Some(lang), Some(name), Some(template)) =
(field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str))
else {
- warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1));
+ warnings.push(format!("link {}: needs component, language (react|leptos|vue|swiftui|compose), name and template strings", i + 1));
continue;
};
let link = CodeLink {
diff --git a/crates/engine/src/code_connect_tests.rs b/crates/engine/src/code_connect_tests.rs
index 9d657d5f1..eb34656b6 100644
--- a/crates/engine/src/code_connect_tests.rs
+++ b/crates/engine/src/code_connect_tests.rs
@@ -183,3 +183,38 @@ fn code_connect_commands_survive_junk() {
}
}
}
+
+#[test]
+fn vue_links_are_a_default_import_with_quoted_bindings() {
+ let mut s = Session::new();
+ let c = card(&mut s);
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "Buy"}}));
+ // A single-file component is a default import, and a binding's value is quoted (the template
+ // writes `:prop={prop}`, so a plain string brings its own quotes).
+ let v = ex(
+ &mut s,
+ "codeConnect.set",
+ json!({"id": i, "language": "vue", "importPath": "@/components/Card.vue",
+ "template": " "}),
+ );
+ let want = "import Card from \"@/components/Card.vue\";\n\n \n";
+ assert_eq!(v["code"], want);
+ assert_eq!(code(&mut s, i, "vue"), want);
+ assert_eq!(ex(&mut s, "inspect.code", json!({"id": i, "language": "vue"}))["connected"], true);
+ // No template given: the generated one binds a quoted value for every property.
+ ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "vue"}));
+ ex(&mut s, "codeConnect.set", json!({"id": c, "language": "vue", "importPath": "./Card.vue"}));
+ assert_eq!(code(&mut s, c, "vue"), "import Card from \"./Card.vue\";\n\n \n");
+ // A string with escapes stays one value: the attribute delimiter is what gets escaped, so the
+ // binding stays a single well-formed attribute.
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\""}}));
+ let escaped = code(&mut s, i, "vue");
+ let line = escaped.lines().find(|l| l.contains(""")).unwrap_or_default();
+ assert!(line.starts_with(" "), "{line}");
+ // Vue is linkable; a language that isn't stays refused.
+ assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "css"})).is_err(), "not a code language");
+ assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "vue", "importPath": "a b"})).is_err(), "no spaces in a path");
+}
diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs
index 0871f886d..618d430a5 100644
--- a/crates/engine/src/codegen.rs
+++ b/crates/engine/src/codegen.rs
@@ -18,13 +18,13 @@ pub fn specs() -> Vec {
vec![spec!(
"inspect.code",
"Code for layer",
- "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
+ "{id?, language?: css|html|react|leptos|vue|swiftui|compose (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
false,
|s, p| {
let cmd = "inspect.code";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
let lang = match string(p, "language") {
- Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?,
+ Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose"))?,
None => CodeLang::Css,
};
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
@@ -61,18 +61,22 @@ pub enum CodeLang {
React,
/// A Leptos component (`view!` markup) with its stylesheet.
Leptos,
+ /// A Vue single-file component (``, `\n\n");
+ }
+ out.push_str(&format!("\n{markup} \n\n\n"));
+ out
+}
+
/// A Leptos `#[component]` with its variant enums and props (defaults as `#[prop]` attributes),
/// and its stylesheet.
fn leptos(doc: &Document, c: &Component) -> String {
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 0871eebf4..82781448c 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -1893,6 +1893,8 @@ mod version_tests;
#[cfg(test)]
mod view_behaviour_tests;
#[cfg(test)]
+mod vue_codegen_tests;
+#[cfg(test)]
mod widgets_tests;
#[cfg(test)]
diff --git a/crates/engine/src/playground.rs b/crates/engine/src/playground.rs
index 64286cef8..bc4743e3a 100644
--- a/crates/engine/src/playground.rs
+++ b/crates/engine/src/playground.rs
@@ -52,7 +52,7 @@ pub fn specs() -> Vec {
vec![spec!(
"component.playground",
"Component playground",
- "{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|swiftui|compose (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
+ "{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
false,
|s, p| {
let cmd = "component.playground";
@@ -63,7 +63,7 @@ pub fn specs() -> Vec {
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
};
let lang = match string(p, "language") {
- Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose"))?,
+ Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose"))?,
None => CodeLang::React,
};
let (copy, inst) = build(s.doc()?, target, &values)?;
diff --git a/crates/engine/src/vue_codegen_tests.rs b/crates/engine/src/vue_codegen_tests.rs
new file mode 100644
index 000000000..d147d7580
--- /dev/null
+++ b/crates/engine/src/vue_codegen_tests.rs
@@ -0,0 +1,184 @@
+//! Vue code: `inspect.code {language: "vue"}` writes a single-file component — ``, a
+//! `\n\n\n"), "{out}");
+ // A text property is an interpolation, not React's braced expression.
+ assert!(out.contains("{{ title }}"), "{out}");
+}
+
+#[test]
+fn a_boolean_property_guards_its_layer_with_v_if() {
+ let mut s = Session::new();
+ let (c, _, _) = card(&mut s);
+ let out = vue(&mut s, c);
+ // Vue can't wrap an element in an expression: the guard rides on the element itself.
+ assert!(out.contains("v-if=\"showDot\""), "{out}");
+ assert!(!out.contains("showDot &&"), "{out}");
+}
+
+#[test]
+fn variants_switch_on_data_attributes() {
+ let mut s = Session::new();
+ let mut variants = Vec::new();
+ for (k, (state, color)) in [("Default", "#ff0000"), ("Hover", "#0000ff")].iter().enumerate() {
+ let f =
+ idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": format!("State={state}")})));
+ ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg", "fill": color}}));
+ variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
+ }
+ let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
+ let out = vue(&mut s, set);
+ // A variant property is a string union, bound to the root's data attribute.
+ assert!(out.contains("state?: \"Default\" | \"Hover\";"), "{out}");
+ assert!(out.contains(":data-state=\"state\""), "{out}");
+ // What the other variant changes is a rule on that attribute, as on the web.
+ assert!(out.contains("[data-state=\"Hover\"]"), "{out}");
+}
+
+#[test]
+fn instances_are_calls_passing_the_values_they_set() {
+ let mut s = Session::new();
+ let (c, _, _) = card(&mut s);
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0})));
+ // Values left at the definition's default aren't passed.
+ assert_eq!(component_code(&mut s, i, "vue")["usage"], " \n");
+ ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "Buy"}}));
+ // A plain string is an attribute value; anything else is a `:name` binding.
+ assert_eq!(component_code(&mut s, i, "vue")["usage"], " \n");
+ let out = code(&mut s, i, "vue");
+ assert!(out.starts_with(" \n"), "{out}");
+ assert!(out.contains("defineProps<{"), "{out}");
+}
+
+#[test]
+fn a_layer_tree_with_an_instance_calls_it() {
+ let mut s = Session::new();
+ let (c, _, _) = card(&mut s);
+ let parent = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 500, "height": 300, "name": "Page"})));
+ ex(&mut s, "instance.create", json!({"component": c, "x": 10, "y": 10, "parent": parent}));
+ let out = vue(&mut s, parent);
+ assert!(out.contains(" "), "{out}");
+ assert!(!out.contains("content?: "), "{out}");
+ assert!(!out.contains("props"), "{out}");
+
+ // Content in the slot is its fallback, shown until an instance fills it.
+ let mut s = Session::new();
+ let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Shell2"})));
+ let body = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": frame, "name": "Body2"})));
+ ex(&mut s, "text.create", json!({"text": "Empty", "x": 0, "y": 0, "parent": body, "props": {"name": "Hint"}}));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
+ ex(&mut s, "component.makeSlot", json!({"id": body, "name": "Content"}));
+ let out = vue(&mut s, c);
+ assert!(out.contains(""), "{out}");
+ assert!(out.contains(" "), "{out}");
+ assert!(out.contains("Empty"), "{out}");
+}
+
+#[test]
+fn identifiers_are_escaped_and_the_language_round_trips() {
+ let mut s = Session::new();
+ let (c, _, _) = card(&mut s);
+ // "Show dot" is not an identifier: it becomes camelCase in the props and the template alike.
+ let out = vue(&mut s, c);
+ assert!(out.contains("showDot"), "{out}");
+ assert!(!out.contains("Show dot"), "{out}");
+ // The id is discoverable from what the Inspect panel offers.
+ assert_eq!(CodeLang::from_id("vue"), Some(CodeLang::Vue));
+ assert_eq!(CodeLang::from_id("sfc"), Some(CodeLang::Vue));
+ assert!(CodeLang::Vue.is_web());
+ assert_eq!(CodeLang::Vue.id(), "vue");
+ assert!(CodeLang::ALL.contains(&CodeLang::Vue));
+}
+
+#[test]
+fn rem_converts_lengths_in_the_vue_template_and_its_style() {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 320, "height": 64, "name": "Bar"})));
+ let out = ex(&mut s, "inspect.code", json!({"id": f, "language": "vue", "unit": "rem"}))["code"].as_str().unwrap_or_default().to_string();
+ assert!(out.contains("20rem"), "{out}");
+}
+
+#[test]
+fn a_keyword_property_name_stays_usable() {
+ let mut s = Session::new();
+ let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Row"})));
+ let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
+ // "default" is a JavaScript keyword: the prop is renamed, and the template follows.
+ ex(&mut s, "component.addProperty", json!({"id": c, "name": "default", "type": "text", "default": "x"}));
+ let out = vue(&mut s, c);
+ assert!(out.contains("defaultValue"), "{out}");
+}
diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml
index eea0e8dcb..e3423c031 100644
--- a/crates/ui-egui/locales/cs.toml
+++ b/crates/ui-egui/locales/cs.toml
@@ -219,7 +219,7 @@ status = "draft — needs native review"
"Changes on main" = "Změny v hlavním souboru"
"Changes on the branch" = "Změny ve větvi"
"Changes unknown" = "Změny nejsou známy"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Pro propojení této komponenty s jejím kódem vyberte nahoře React, Leptos, SwiftUI nebo Compose."
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Pro propojení této komponenty s jejím kódem vyberte nahoře React, Leptos, Vue, SwiftUI nebo Compose."
"Choose another profile to change it." = "Pro změnu vyberte jiný profil."
"Choose collection" = "Vybrat kolekci"
"Choose image" = "Vybrat obrázek"
diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml
index f61464b8f..c73dedcc3 100644
--- a/crates/ui-egui/locales/de.toml
+++ b/crates/ui-egui/locales/de.toml
@@ -214,7 +214,7 @@ status = "draft — needs native review"
"Changes on main" = "Änderungen in der Hauptdatei"
"Changes on the branch" = "Änderungen im Branch"
"Changes unknown" = "Änderungen unbekannt"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Wählen Sie oben React, Leptos, SwiftUI oder Compose, um diese Komponente mit ihrem Code zu verbinden."
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Wählen Sie oben React, Leptos, Vue, SwiftUI oder Compose, um diese Komponente mit ihrem Code zu verbinden."
"Choose another profile to change it." = "Wählen Sie ein anderes Profil, um es zu ändern."
"Choose collection" = "Sammlung wählen"
"Choose image" = "Bild wählen"
diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml
index ae566fc81..dfe79179c 100644
--- a/crates/ui-egui/locales/en.toml
+++ b/crates/ui-egui/locales/en.toml
@@ -228,7 +228,7 @@ name = "English"
"Changes on main" = "Changes on main"
"Changes on the branch" = "Changes on the branch"
"Changes unknown" = "Changes unknown"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code."
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code."
"Choose another profile to change it." = "Choose another profile to change it."
"Choose collection" = "Choose collection"
"Choose image" = "Choose image"
diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml
index 596feda6e..d9f155978 100644
--- a/crates/ui-egui/locales/es.toml
+++ b/crates/ui-egui/locales/es.toml
@@ -229,7 +229,7 @@ status = "draft — needs native review"
"Changes on main" = "Cambios en la principal"
"Changes on the branch" = "Cambios en la rama"
"Changes unknown" = "Cambios desconocidos"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Elige React, Leptos, SwiftUI o Compose arriba para conectar este componente con su código."
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Elige React, Leptos, Vue, SwiftUI o Compose arriba para conectar este componente con su código."
"Choose another profile to change it." = "Elige otro perfil para cambiarlo."
"Choose collection" = "Elegir colección"
"Choose image" = "Elegir imagen"
diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml
index b038b3356..d3f0a86d7 100644
--- a/crates/ui-egui/locales/fr.toml
+++ b/crates/ui-egui/locales/fr.toml
@@ -223,7 +223,7 @@ status = "draft — needs native review"
"Changes on main" = "Modifications dans le fichier principal"
"Changes on the branch" = "Modifications dans la branche"
"Changes unknown" = "Modifications inconnues"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Choisissez React, Leptos, SwiftUI ou Compose ci-dessus pour relier ce composant à son code."
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Choisissez React, Leptos, Vue, SwiftUI ou Compose ci-dessus pour relier ce composant à son code."
"Choose another profile to change it." = "Choisissez un autre profil pour le changer."
"Choose collection" = "Choisir une collection"
"Choose image" = "Choisir une image"
diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml
index f21e1ca3c..01774e991 100644
--- a/crates/ui-egui/locales/id.toml
+++ b/crates/ui-egui/locales/id.toml
@@ -223,7 +223,7 @@ status = "draft — needs native review"
"Changes on main" = "Perubahan di main"
"Changes on the branch" = "Perubahan di branch"
"Changes unknown" = "Perubahan tidak diketahui"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Pilih React, Leptos, SwiftUI, atau Compose di atas untuk menghubungkan component ini ke kodenya."
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Pilih React, Leptos, Vue, SwiftUI, atau Compose di atas untuk menghubungkan component ini ke kodenya."
"Choose another profile to change it." = "Pilih profil lain untuk mengubahnya."
"Choose collection" = "Pilih koleksi"
"Choose image" = "Pilih gambar"
diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml
index c42790f35..99d5b03f3 100644
--- a/crates/ui-egui/locales/ja.toml
+++ b/crates/ui-egui/locales/ja.toml
@@ -224,7 +224,7 @@ status = "draft — needs native review"
"Changes on main" = "メインの変更"
"Changes on the branch" = "ブランチの変更"
"Changes unknown" = "変更内容は不明"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "上で React、Leptos、SwiftUI、Compose のいずれかを選び、このコンポーネントをコードに接続。"
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "上で React、Leptos、Vue、SwiftUI、Compose のいずれかを選び、このコンポーネントをコードに接続。"
"Choose another profile to change it." = "変更するには別のプロファイルを選択。"
"Choose collection" = "コレクションを選択"
"Choose image" = "画像を選択"
diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml
index b33b36b95..9889c7ea8 100644
--- a/crates/ui-egui/locales/ko.toml
+++ b/crates/ui-egui/locales/ko.toml
@@ -214,7 +214,7 @@ status = "draft — needs native review"
"Changes on main" = "메인의 변경 사항"
"Changes on the branch" = "브랜치의 변경 사항"
"Changes unknown" = "변경 내용 알 수 없음"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "위에서 React, Leptos, SwiftUI 또는 Compose를 선택해 이 컴포넌트를 코드에 연결하세요."
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "위에서 React, Leptos, Vue, SwiftUI 또는 Compose를 선택해 이 컴포넌트를 코드에 연결하세요."
"Choose another profile to change it." = "변경하려면 다른 프로필을 선택하세요."
"Choose collection" = "컬렉션 선택"
"Choose image" = "이미지 선택"
diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml
index d8f857e0f..3bf7a5b0e 100644
--- a/crates/ui-egui/locales/pt-BR.toml
+++ b/crates/ui-egui/locales/pt-BR.toml
@@ -212,7 +212,7 @@ status = "draft — needs native review"
"Changes on main" = "Alterações no principal"
"Changes on the branch" = "Alterações na branch"
"Changes unknown" = "Alterações desconhecidas"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Escolha React, Leptos, SwiftUI ou Compose acima para conectar este componente ao código dele."
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Escolha React, Leptos, Vue, SwiftUI ou Compose acima para conectar este componente ao código dele."
"Choose another profile to change it." = "Escolha outro perfil para alterá-lo."
"Choose collection" = "Escolher coleção"
"Choose image" = "Escolher imagem"
diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml
index 6e173a925..478f78d8d 100644
--- a/crates/ui-egui/locales/ru.toml
+++ b/crates/ui-egui/locales/ru.toml
@@ -226,7 +226,7 @@ name = "Русский"
"Changes on main" = "Изменения в основной ветке"
"Changes on the branch" = "Изменения в ветке"
"Changes unknown" = "Изменения неизвестны"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Выберите выше React, Leptos, SwiftUI или Compose, чтобы связать компонент с его кодом."
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Выберите выше React, Leptos, Vue, SwiftUI или Compose, чтобы связать компонент с его кодом."
"Choose another profile to change it." = "Для смены выберите другой профиль."
"Choose collection" = "Выбрать коллекцию"
"Choose image" = "Выбрать изображение"
diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml
index d86779e02..53636a8af 100644
--- a/crates/ui-egui/locales/sv.toml
+++ b/crates/ui-egui/locales/sv.toml
@@ -223,7 +223,7 @@ name = "Svenska"
"Changes on main" = "Ändringar i main"
"Changes on the branch" = "Ändringar i grenen"
"Changes unknown" = "Okända ändringar"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Välj React, Leptos, SwiftUI eller Compose ovan för att koppla den här komponenten till dess kod."
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Välj React, Leptos, Vue, SwiftUI eller Compose ovan för att koppla den här komponenten till dess kod."
"Choose another profile to change it." = "Välj en annan profil för att ändra den."
"Choose collection" = "Välj samling"
"Choose image" = "Välj bild"
diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml
index bd8bb7c9d..5cde760ad 100644
--- a/crates/ui-egui/locales/tr.toml
+++ b/crates/ui-egui/locales/tr.toml
@@ -223,7 +223,7 @@ name = "Türkçe"
"Changes on main" = "Ana dosyadaki değişiklikler"
"Changes on the branch" = "Daldaki değişiklikler"
"Changes unknown" = "Değişiklikler bilinmiyor"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Bu bileşeni koduna bağlamak için yukarıdan React, Leptos, SwiftUI veya Compose seçin."
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "Bu bileşeni koduna bağlamak için yukarıdan React, Leptos, Vue, SwiftUI veya Compose seçin."
"Choose another profile to change it." = "Değiştirmek için başka bir profil seçin."
"Choose collection" = "Koleksiyon seç"
"Choose image" = "Görüntü seç"
diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml
index 94c919425..862dc7531 100644
--- a/crates/ui-egui/locales/zh-Hans.toml
+++ b/crates/ui-egui/locales/zh-Hans.toml
@@ -215,7 +215,7 @@ status = "draft — needs native review"
"Changes on main" = "主文件上的更改"
"Changes on the branch" = "分支上的更改"
"Changes unknown" = "更改未知"
-"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "在上方选择 React、Leptos、SwiftUI 或 Compose,将此组件关联到其代码。"
+"Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code." = "在上方选择 React、Leptos、Vue、SwiftUI 或 Compose,将此组件关联到其代码。"
"Choose another profile to change it." = "选择其他配置文件以更改。"
"Choose collection" = "选择集合"
"Choose image" = "选择图片"
diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs
index 0b429820d..37a3a4b11 100644
--- a/crates/ui-egui/src/dev_mode.rs
+++ b/crates/ui-egui/src/dev_mode.rs
@@ -396,7 +396,7 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId
let text = if connectable {
tr("Link this component to its code component: instances then show your real snippet with their values.")
} else {
- tr("Choose React, Leptos, SwiftUI or Compose above to connect this component to its code.")
+ tr("Choose React, Leptos, Vue, SwiftUI or Compose above to connect this component to its code.")
};
widgets::note(ui, text, t.text_secondary);
return;
diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md
index cba5e81df..fcba79f85 100644
--- a/docs/dev-mode-parity.md
+++ b/docs/dev-mode-parity.md
@@ -10,13 +10,13 @@ inside generated layer trees. Remaining: 0–1 h.
|---|---|---|
| Dev Mode toggle (Shift+D), Inspect panel | done | |
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
-| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
+| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
| Ready for dev list, statuses, "Changed" after edits | done | |
| Focus view (one frame, previous / next) | done | |
| Compare changes (before/after side by side or overlaid, property diffs) | done | only against the snapshot taken when a frame was marked ready; not against any named version |
-| Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected |
+| Code connect (map components to repository code) | done | per language (React, Leptos, Vue, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected |
| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab; no plugin UI at run time, no third-party integrations |
| VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors |
| Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them |
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index adf0b0f2c..85eb26484 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -55,7 +55,7 @@ export limit, not a native document nesting limit.
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 |
| WebP | — | yes (ours) | | — |
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
-| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML and React | see [dev-mode-parity](dev-mode-parity.md) | — |
+| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML, React and Vue | see [dev-mode-parity](dev-mode-parity.md) | — |
| `.fig` ("Save local copy") | yes | **experimental** | the whole file, also as a copy from File ▸ Save as… (the document keeps its `.figcraft` path and unsaved state); see [Export to `.fig`](#export-to-fig-experimental). Round trips through our own importer; written files checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width | 5–15 (non-colour bindings as bindings, checks of effects, images and prototypes, and against real files) |
### Export to `.fig` (experimental)
diff --git a/docs/gaps.md b/docs/gaps.md
index 9b1a770c1..778364d83 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -356,7 +356,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
### 16. Dev Mode: the tail after dev resources
- **Landed (2026-10-11):** code connect: a component or set links to its code component per
- language (React, SwiftUI, Compose): an import path, the name and a template whose `{property}`
+ language (React, Vue, SwiftUI, Compose): an import path, the name and a template whose `{property}`
and `{property ? "a" : "b"}` placeholders take the instance's values, escaped per language and
never executed. `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet
instead of generated code (inside generated layer trees too); the panel's Connect to code edits
diff --git a/docs/mcp.md b/docs/mcp.md
index dfea95cc2..95d7b4606 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -294,7 +294,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), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React and Leptos, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
+| `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), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: `{"$step": n}` anywhere in its params becomes the `id` in step n's result (zero-based), and `{"$step": n, "field": "…"}` another field of it (see Step references below). |
| `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`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
@@ -1275,29 +1275,40 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
### Dev Mode
-- **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component,
+- **Component code:** `inspect.code {id, language: react|leptos|vue|swiftui|compose}` on a component,
a component set or a variant writes the component as a reusable unit: React (TSX) with a props
- interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI
+ interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), Vue as a
+ single-file component (``, a `