M6: code connect: link components to real code (#122)

Components (or sets) can be linked per language to a real code component (import path, name, and a template whose {prop} and {prop ? "a" : "b"} placeholders take the instance's property values, escaped per language). Dev Mode's Inspect panel shows the connected snippet with its import for instances, a Connect to code editor with live preview, and read-only plugins. Links export to and import from figcraft.connect.json.
This commit is contained in:
BFlat 2026-10-11 14:02:37 +07:00 committed by GitHub
parent cb5435d5ff
commit 13ab3f150f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 902 additions and 43 deletions

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@ -25,8 +25,8 @@ start with [`docs/gaps.md`](docs/gaps.md).
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~47% | ~360–680 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
| Mainstream practitioner | ~43% | ~170–315 h | core editor depth incl. `.fig` import (88–171), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Full target (ready for real work) | ~47% | ~355–675 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
| Mainstream practitioner | ~43% | ~165–310 h | core editor depth incl. `.fig` import (83–163), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
@ -56,7 +56,7 @@ the localization layer with Figma's own languages (20–35 h).
| Dimension | Ready | Hours | Doc |
|---|---|---|---|
| Features (depth) | 57% (core editor 67%) | 68–129 | [target-app-parity](docs/target-app-parity.md) |
| Features (depth) | 57% (core editor 67%) | 63–121 | [target-app-parity](docs/target-app-parity.md) |
| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) |
| File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) |
| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) |
@ -86,7 +86,7 @@ the localization layer with Figma's own languages (20–35 h).
| Styles & variables | 92% | 72% | 1–2 | [components-parity](docs/components-parity.md) |
| Prototyping | 88% | 70% | 5–9 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 65% | 35% | 30–60 | [file-format-parity](docs/file-format-parity.md) |
| Dev Mode / inspect | 80% | 62% | 4–8 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Dev Mode / inspect | 90% | 64% | 1–3 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 80% | 58% | 4–8 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) |
| Plugins & widgets | 35% | 20% | 25–55 | [plugins](docs/plugins.md) |
@ -171,6 +171,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h |
| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `<textPath>` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG `<pattern>`, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h |
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |

View File

@ -177,6 +177,7 @@ node_props! {
PropRefs => prop_refs,
PluginData => plugin_data,
Annotations => annotations,
CodeLinks => code_links,
}
/// A document-level property. Lists of entries with ids (styles, comments, variable collections,

View File

@ -213,6 +213,22 @@ pub struct Annotation {
pub category: Option<String>,
}
/// A main component (or component set) linked to the code component it is built as, in one
/// language: Dev Mode shows the snippet its template makes instead of generated code
/// (`figcraft_engine::code_connect`).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodeLink {
/// `react`, `swiftui` or `compose`.
pub language: String,
/// What the snippet imports from (`@acme/ui`, a Swift module, a Kotlin name).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub import_path: String,
/// The code component's name.
pub name: String,
/// The snippet with `{property}` and `{property ? "a" : "b"}` placeholders.
pub template: String,
}
/// A kind of annotation (Development, Accessibility…): its stable id, name and the colour its
/// callouts are drawn in.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -482,6 +498,10 @@ pub struct Node {
/// Dev Mode annotations, in the order they were added.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub annotations: Vec<Annotation>,
/// Main components and component sets: the code components they are linked to, one per
/// language.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub code_links: Vec<CodeLink>,
}
pub(crate) fn yes() -> bool {
@ -590,6 +610,7 @@ impl Node {
prop_refs: BTreeMap::new(),
plugin_data: BTreeMap::new(),
annotations: Vec::new(),
code_links: Vec::new(),
}
}

View File

@ -369,6 +369,15 @@ fn read(path: &str) -> Result<Vec<u8>> {
Err(EngineError::Other(format!("{path}: no file system in the browser")))
}
/// The bytes of file `path`, refused when it's larger than `max`.
pub(crate) fn read_capped(path: &str, max: u64) -> Result<Vec<u8>> {
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
if !meta.is_file() || meta.len() > max {
return Err(EngineError::Other(format!("{path}: not a file of at most {max} bytes")));
}
read(path)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> {
let tmp = format!("{path}.tmp-figcraft");

View File

@ -93,6 +93,7 @@ fn registry() -> &'static Registry {
list.extend(crate::codegen::specs());
list.extend(crate::devinspect::specs());
list.extend(crate::component_codegen::specs());
list.extend(crate::code_connect::specs());
list.extend(crate::annotations::specs());
list.extend(crate::distance::specs());
list.extend(crate::collab::specs());

View File

@ -0,0 +1,450 @@
//! Code connect: a main component (or component set) linked to the code component it is built as,
//! per language: an import path, the code component's name and a template mapping component
//! properties to its props. `inspect.code`, `inspect.componentCode` and Dev Mode then show that
//! snippet for its instances (with their values, also inside generated layer trees) instead of
//! generated code. Links are stored on the component or set (`Node::code_links`: saved, synced,
//! undoable) and travel as `figcraft.connect.json` kept in the code repository.
//!
//! Templates are text with placeholders and are never executed: `{prop}` (a property's name or its
//! parameter name) becomes the value as an expression in the language, escaped for it (React
//! keeps JSX's braces: `size={size}` → `size="Large"`, `{label}` → `{"Buy"}`), and
//! `{prop ? "a" : "b"}` picks one of two strings. Any other `{…}` stays as written.
use figcraft_doc::*;
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::CodeLang;
use crate::component_codegen::{Component, Param, ParamKind, describe, instance_values, js_string, main_of, value_expr};
use crate::components::variant_set;
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
/// Longest template (chars).
pub const MAX_TEMPLATE: usize = 4_000;
/// Longest code component name (chars).
pub const MAX_NAME: usize = 100;
/// Longest import path (chars).
pub const MAX_IMPORT: usize = 300;
/// Most links a connect file holds.
pub const MAX_FILE_LINKS: usize = 5_000;
/// Largest connect file read (bytes).
const MAX_FILE: u64 = 4 * 1024 * 1024;
/// The connect file's format version.
const VERSION: u64 = 1;
/// Most warnings an import reports.
const MAX_WARNINGS: usize = 50;
/// The languages a component can be connected in.
pub fn language(s: &str) -> Option<CodeLang> {
CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))
}
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
/// main). shortcut: links a component had before it joined a set stay on it unused; move them to
/// the set when combining variants if people hit it.
pub fn owner(doc: &Document, id: NodeId) -> Option<NodeId> {
let comp = match &doc.node(id)?.kind {
NodeKind::ComponentSet { .. } => return Some(id),
NodeKind::Component { .. } => id,
NodeKind::Instance { component, .. } => *component,
_ => return None,
};
doc.node(comp).filter(|c| matches!(c.kind, NodeKind::Component { .. }))?;
Some(variant_set(doc, comp).unwrap_or(comp))
}
/// The link layer `id` (a component, set or instance) has for `lang`.
pub fn link(doc: &Document, id: NodeId, lang: CodeLang) -> Option<&CodeLink> {
doc.node(owner(doc, id)?)?.code_links.iter().find(|l| l.language == lang.id())
}
/// The import line of `link` ("" without an import path).
pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
let path = &link.import_path;
match lang {
_ if path.is_empty() => String::new(),
CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
_ => format!("import {path}"),
}
}
/// Layer `id`'s code with `link`: the import line and the snippet (an instance's values, else the
/// defaults). `None` when `id` isn't a component, set or instance.
pub fn connected_code(doc: &Document, id: NodeId, lang: CodeLang, link: &CodeLink) -> Option<String> {
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
let values = instance_values(doc, id, &c).unwrap_or_else(|| c.params.iter().map(|p| (p.prop.clone(), p.default.clone())).collect());
let snippet = render(doc, &c, &values, link, lang, "");
let import = import_line(link, lang);
Some(if import.is_empty() { snippet } else { format!("{import}\n\n{snippet}") })
}
// ------------------------------------------------------------------ templates
/// A piece of a template (`inner`: the text between the braces, written back for names that
/// aren't properties).
#[derive(Debug, PartialEq)]
enum Piece<'a> {
Text(&'a str),
Value { name: &'a str, inner: &'a str },
Pick { name: &'a str, yes: &'a str, no: &'a str, inner: &'a str },
}
/// Whether `s` reads as a property name rather than code.
fn is_name(s: &str) -> bool {
!s.is_empty() && s.chars().count() <= MAX_NAME && s.chars().all(|c| c.is_alphanumeric() || " _-./".contains(c))
}
/// `"text"…` → (text, what follows the closing quote).
fn quoted(s: &str) -> Option<(&str, &str)> {
s.trim_start().strip_prefix('"')?.split_once('"')
}
/// One `{…}`: a placeholder, `None` for code kept as written, an error for a malformed pick.
fn placeholder(inner: &str) -> std::result::Result<Option<Piece<'_>>, String> {
let s = inner.trim();
let Some((name, picks)) = s.split_once('?') else { return Ok(is_name(s).then_some(Piece::Value { name: s, inner })) };
// A code ternary without string choices stays as written.
if !picks.contains('"') {
return Ok(None);
}
let name = name.trim();
let malformed = || format!("write {{{name} ? \"a\" : \"b\"}}");
let (yes, rest) = quoted(picks).ok_or_else(malformed)?;
let rest = rest.trim_start().strip_prefix(':').ok_or_else(malformed)?;
let (no, rest) = quoted(rest).ok_or_else(malformed)?;
if !rest.trim().is_empty() || !is_name(name) {
return Err(malformed());
}
Ok(Some(Piece::Pick { name, yes, no, inner }))
}
/// Template `t` as pieces.
fn parse(t: &str) -> std::result::Result<Vec<Piece<'_>>, String> {
let mut out = Vec::new();
let mut rest = t;
while let Some((before, after)) = rest.split_once('{') {
if !before.is_empty() {
out.push(Piece::Text(before));
}
let piece = match after.split_once('}') {
Some((inner, tail)) if !inner.contains('{') => placeholder(inner)?.map(|p| (p, tail)),
_ => None,
};
match piece {
Some((p, tail)) => {
out.push(p);
rest = tail;
}
// A literal brace (JSX's own, an object, code): the rest is scanned again.
None => {
out.push(Piece::Text("{"));
rest = after;
}
}
}
if !rest.is_empty() {
out.push(Piece::Text(rest));
}
Ok(out)
}
/// The parameter of `c` a placeholder names (its property name or its name in code), with its
/// value.
fn find<'a>(c: &'a Component, values: &'a [(String, Value)], name: &str) -> Option<(&'a Param, &'a Value)> {
c.params.iter().zip(values).find(|(p, _)| p.prop == name || p.ident == name).map(|(p, (_, v))| (p, v))
}
/// Whether a value picks the first string of `{prop ? "a" : "b"}`.
fn truthy(v: &Value) -> bool {
match v {
Value::Null => false,
Value::Bool(b) => *b,
Value::Number(n) => n.as_f64() != Some(0.0),
Value::String(s) => !s.is_empty() && !["false", "off", "no", "0", "none"].iter().any(|f| s.eq_ignore_ascii_case(f)),
_ => true,
}
}
/// A parameter's value as an expression: an instance swap to a connected component calls it by
/// its code name.
fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) -> String {
let swapped = (p.kind == ParamKind::InstanceSwap).then(|| id_of(v)).flatten().and_then(|i| link(doc, i, lang));
match (swapped, lang) {
(Some(l), CodeLang::React) => format!("<{} />", l.name),
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
(Some(l), _) => format!("{{ {}() }}", l.name),
(None, _) => value_expr(doc, c, p, v, lang),
}
}
/// `link`'s template with `values` (one per parameter of `c`), every line prefixed by `pad`.
pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], link: &CodeLink, lang: CodeLang, pad: &str) -> String {
// A template that skipped `check` (a hand-edited file) and is far too long stays as written:
// parsing rescans after each literal brace.
let checkable = link.template.len() <= MAX_TEMPLATE * 4;
let pieces = checkable.then(|| parse(&link.template).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(&link.template)]);
let mut out = String::new();
for piece in pieces {
match piece {
Piece::Text(t) => out.push_str(t),
Piece::Value { name, inner } => match find(c, values, name) {
Some((p, v)) => {
let e = expr(doc, c, p, v, lang);
// JSX: a plain string reads best as an attribute value, anything else is an
// expression in braces (JSX attribute strings have no escapes).
if lang != CodeLang::React || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
out.push_str(&e);
} else {
out.push_str(&format!("{{{e}}}"));
}
}
None => out.push_str(&format!("{{{inner}}}")),
},
Piece::Pick { name, yes, no, inner } => match find(c, values, name) {
Some((_, v)) => out.push_str(if truthy(v) { yes } else { no }),
None => out.push_str(&format!("{{{inner}}}")),
},
}
}
out.lines().map(|l| if l.is_empty() { "\n".to_string() } else { format!("{pad}{l}\n") }).collect()
}
/// A starting template: the code component called with every property (but slots) passed.
fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
let params: Vec<&str> = c.params.iter().filter(|p| p.kind != ParamKind::Slot).map(|p| p.ident.as_str()).collect();
match lang {
CodeLang::React => format!("<{name}{} />", params.iter().map(|p| format!(" {p}={{{p}}}")).collect::<String>()),
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
}
}
/// Checks a link: its name, import path and template. Returns the placeholder names that aren't
/// properties of `c` (kept as written).
pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Result<Vec<String>, String> {
let name = &link.name;
let mut chars = name.chars();
if name.chars().count() > MAX_NAME
|| !chars.next().is_some_and(|f| f.is_ascii_alphabetic() || f == '_' || f == '$')
|| !chars.all(|ch| ch.is_ascii_alphanumeric() || "_$.".contains(ch))
{
return Err(format!("name: the code component's name (letters, digits, _ $ and ., up to {MAX_NAME})"));
}
let path = &link.import_path;
let path_ok = match lang {
CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || 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"),
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
});
}
let t = &link.template;
if t.trim().is_empty() || t.chars().count() > MAX_TEMPLATE || t.chars().any(|ch| ch.is_control() && !"\n\t\r".contains(ch)) {
return Err(format!("template: the snippet, up to {MAX_TEMPLATE} characters"));
}
let pieces = parse(t).map_err(|e| format!("template: {e}"))?;
let values: Vec<(String, Value)> = c.params.iter().map(|p| (p.prop.clone(), Value::Null)).collect();
let mut unknown: Vec<String> = Vec::new();
for p in pieces {
if let Piece::Value { name, .. } | Piece::Pick { name, .. } = p
&& find(c, &values, name).is_none()
&& !unknown.iter().any(|u| u == name)
{
unknown.push(name.to_string());
}
}
Ok(unknown)
}
// ------------------------------------------------------------------ commands
/// A string parameter (`None` when missing or null).
fn text(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
match get(p, key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) => Ok(Some(s.clone())),
Some(_) => Err(bad(cmd, format!("{key}: a string"))),
}
}
/// The `language` parameter (default React).
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
match text(cmd, p, "language")? {
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|swiftui|compose")),
None => Ok(CodeLang::React),
}
}
/// The target of a command: (the layer, the component or set its links are on, its code).
fn target(cmd: &str, s: &Session, p: &Value) -> Result<(NodeId, NodeId, Component)> {
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
let doc = s.doc()?;
let not = || bad(cmd, "not a component, set or instance");
let owner = owner(doc, id).ok_or_else(not)?;
let c = main_of(doc, owner).and_then(|m| describe(doc, m)).ok_or_else(not)?;
Ok((id, owner, c))
}
/// Store `link` on `owner`, replacing its link for the same language.
fn store(doc: &mut Document, owner: NodeId, link: CodeLink) -> Result<()> {
let n = doc.get_mut(owner)?;
match n.code_links.iter_mut().find(|l| l.language == link.language) {
Some(l) => *l = link,
None => n.code_links.push(link),
}
Ok(())
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"codeConnect.set",
"Connect to code",
"{id? (component, set or instance; default the selection), language?: react|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";
let lang = lang_param(cmd, p)?;
let (id, owner, c) = target(cmd, s, p)?;
let doc = s.doc()?;
let old = link(doc, owner, lang).cloned().unwrap_or_default();
let name =
text(cmd, p, "name")?.map(|n| n.trim().to_string()).unwrap_or(if old.name.is_empty() { c.type_name.clone() } else { old.name });
let template = match text(cmd, p, "template")? {
Some(t) => t,
None if old.template.is_empty() => default_template(&c, lang, &name),
None => old.template,
};
let import_path = text(cmd, p, "importPath")?.map_or(old.import_path, |i| i.trim().to_string());
let link = CodeLink { language: lang.id().into(), import_path, name, template };
let unknown = check(&c, &link, lang).map_err(|e| bad(cmd, e))?;
let code = connected_code(doc, id, lang, &link);
store(s.doc_mut()?, owner, link)?;
Ok(json!({"component": owner, "language": lang.id(), "code": code, "unknown": unknown}))
}
),
spec!(
"codeConnect.remove",
"Disconnect from code",
"{id? (component, set or instance; default the selection), language? (omit: every language)} → {component, removed}",
true,
|s, p| {
let cmd = "codeConnect.remove";
let lang = match get(p, "language") {
None | Some(Value::Null) => None,
Some(_) => Some(lang_param(cmd, p)?),
};
let (_, owner, _) = target(cmd, s, p)?;
let n = s.doc_mut()?.get_mut(owner)?;
let before = n.code_links.len();
n.code_links.retain(|l| lang.is_some_and(|k| l.language != k.id()));
let removed = before - n.code_links.len();
if removed == 0 {
return Err(bad(cmd, "the component isn't connected to code"));
}
Ok(json!({"component": owner, "removed": removed}))
}
),
spec!(
"codeConnect.export",
"Export code connections",
"{path? (write it there, e.g. figcraft.connect.json in the code repository)} every component's links as {version: 1, links: [{component (its name), language, importPath, name, template}]}, sorted → {text, links} or {path, links}",
false,
|s, p| {
let cmd = "codeConnect.export";
let path = text(cmd, p, "path")?;
let doc = s.doc()?;
let mut links: Vec<Value> = doc
.nodes()
.filter(|n| owner(doc, n.id) == Some(n.id))
.flat_map(|n| {
n.code_links.iter().map(|l| {
json!({"component": n.name, "language": l.language, "importPath": l.import_path, "name": l.name, "template": l.template})
})
})
.collect();
let key =
|v: &Value| (v["component"].as_str().unwrap_or_default().to_string(), v["language"].as_str().unwrap_or_default().to_string());
links.sort_by_key(key);
let count = links.len();
let text =
serde_json::to_string_pretty(&json!({"version": VERSION, "links": links})).map_err(|e| EngineError::Other(e.to_string()))? + "\n";
match path {
Some(path) => {
crate::cmd::file::write(&path, text.as_bytes())?;
Ok(json!({"path": path, "links": count}))
}
None => Ok(json!({"text": text, "links": count})),
}
}
),
spec!(
"codeConnect.import",
"Import code connections",
"{json (the connect JSON as codeConnect.export writes it: an object or JSON text) | path (a figcraft.connect.json)} links each entry to every component or set of that name (replacing its link for the language); invalid entries and unknown components are skipped with a warning → {linked, warnings}",
true,
import
),
]
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "codeConnect.import";
let parse_json = |b: &[u8]| serde_json::from_slice::<Value>(b).map_err(|e| bad(cmd, format!("not JSON: {e}")));
let v = match (get(p, "json"), text(cmd, p, "path")?) {
(Some(v @ Value::Object(_)), _) => v.clone(),
(Some(Value::String(t)), _) => parse_json(t.as_bytes())?,
(Some(_), _) => return Err(bad(cmd, "json: the connect JSON (an object or JSON text)")),
(None, Some(path)) => parse_json(&crate::cmd::file::read_capped(&path, MAX_FILE)?)?,
(None, None) => return Err(bad(cmd, "pass `json` or `path`")),
};
if v.get("version").is_some_and(|x| x.as_u64() != Some(VERSION)) {
return Err(bad(cmd, format!("version: only {VERSION} is supported")));
}
let entries = v.get("links").and_then(Value::as_array).ok_or_else(|| bad(cmd, "expected {version: 1, links: [...]}"))?;
if entries.len() > MAX_FILE_LINKS {
return Err(bad(cmd, format!("at most {MAX_FILE_LINKS} links")));
}
let doc = s.doc()?;
// Components and sets by name (variants are linked through their set).
let owners: Vec<(String, NodeId)> = doc.nodes().filter(|n| owner(doc, n.id) == Some(n.id)).map(|n| (n.name.clone(), n.id)).collect();
let mut warnings: Vec<String> = Vec::new();
let mut apply: Vec<(NodeId, CodeLink)> = Vec::new();
for (i, e) in entries.iter().enumerate() {
let field = |k: &str| e.get(k).and_then(Value::as_str).map(str::trim);
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|swiftui|compose), name and template strings", i + 1));
continue;
};
let link = CodeLink {
language: lang.id().into(),
import_path: field("importPath").unwrap_or_default().to_string(),
name: name.into(),
template: template.into(),
};
let matches: Vec<NodeId> = owners.iter().filter(|(n, _)| n == component).map(|(_, id)| *id).collect();
if matches.is_empty() {
warnings.push(format!("link {}: no component named {component:?}", i + 1));
}
for id in matches {
let Some(c) = main_of(doc, id).and_then(|m| describe(doc, m)) else { continue };
match check(&c, &link, lang) {
Ok(_) => apply.push((id, link.clone())),
Err(err) => warnings.push(format!("link {} ({component}): {err}", i + 1)),
}
}
}
let linked = apply.len();
let doc = s.doc_mut()?;
for (id, link) in apply {
store(doc, id, link)?;
}
warnings.truncate(MAX_WARNINGS);
Ok(json!({"linked": linked, "warnings": warnings}))
}

View File

@ -0,0 +1,176 @@
//! Code connect: components linked to their code components show the real snippet, with the
//! instance's values, instead of generated code.
use crate::components::tests::{ex, idv};
use crate::params::id_of;
use crate::{EngineError, Session};
use figcraft_doc::*;
use serde_json::{Value, json};
fn code(s: &mut Session, id: NodeId, lang: &str) -> String {
ex(s, "inspect.code", json!({"id": id, "language": lang}))["code"].as_str().unwrap_or_default().to_string()
}
/// A "Card" component with a "Title" text property and a "Show dot" boolean property.
fn card(s: &mut Session) -> NodeId {
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
let title = idv(&ex(s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
let dot = idv(&ex(s, "shape.ellipse", json!({"x": 170, "y": 10, "width": 20, "height": 20, "parent": f, "props": {"name": "Dot"}})));
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
ex(s, "component.addProperty", json!({"id": dot, "name": "Show dot", "type": "boolean", "default": true}));
ex(s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
c
}
/// A "Button" set with State=Default|Hover. Returns (set, hover variant).
fn button(s: &mut Session) -> (NodeId, NodeId) {
let variants: Vec<NodeId> = ["Default", "Hover"]
.iter()
.enumerate()
.map(|(k, state)| {
let f =
idv(&ex(s, "frame.create", json!({"x": k as f64 * 150.0, "y": 300, "width": 100, "height": 40, "name": format!("State={state}")})));
idv(&ex(s, "component.create", json!({"ids": [f]})))
})
.collect();
let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()})));
ex(s, "node.set", json!({"id": set, "props": {"name": "Button"}}));
(set, variants[1])
}
#[test]
fn connected_instances_show_the_real_snippet() {
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"}}));
let template = "<Card title={title} dot={Show dot} onClick={onClick}>{Show dot ? \"•\" : \"\"}</Card>";
let v = ex(&mut s, "codeConnect.set", json!({"id": i, "importPath": "@acme/ui", "template": template}));
assert_eq!(id_of(&v["component"]), Some(c), "stored on the main");
assert_eq!(v["unknown"], json!(["onClick"]), "code-only names are kept as written");
let want = "import { Card } from \"@acme/ui\";\n\n<Card title=\"Buy\" dot={false} onClick={onClick}></Card>\n";
assert_eq!(v["code"], want);
assert_eq!(code(&mut s, i, "react"), want);
assert_eq!(ex(&mut s, "inspect.code", json!({"id": i, "language": "react"}))["connected"], true);
// The main itself: the defaults. Instances never carry links themselves.
assert_eq!(code(&mut s, c, "react"), "import { Card } from \"@acme/ui\";\n\n<Card title=\"Hello\" dot={true} onClick={onClick}>•</Card>\n");
assert!(s.doc().unwrap().node(i).unwrap().code_links.is_empty());
// Escaped per language: a JSX string with escapes is an expression.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\""}}));
assert!(code(&mut s, i, "react").contains("title={\"Say \\\"hi\\\"\"}"));
ex(
&mut s,
"codeConnect.set",
json!({"id": c, "language": "swiftui", "importPath": "AcmeUI", "template": "Card(title: {title}, style: {Show dot ? \".dotted\" : \".plain\"})"}),
);
assert_eq!(code(&mut s, i, "swiftui"), "import AcmeUI\n\nCard(title: \"Say \\\"hi\\\"\", style: .plain)\n");
assert!(code(&mut s, i, "compose").contains("@Composable"), "other languages stay generated");
// inspect.componentCode: the snippet as the usage, and the link.
let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"}));
assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n");
assert_eq!(v["connected"]["import"], "import AcmeUI");
assert_eq!(ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "compose"}))["connected"], Value::Null);
// Inside a generated layer tree, connected instances are their snippet.
let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Screen"})));
ex(&mut s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen}));
assert!(code(&mut s, screen, "react").contains("<Card title=\"Hello\" dot={true} onClick={onClick}>•</Card>\n"));
// Literal braces stay; fields left out keep their value.
ex(&mut s, "codeConnect.set", json!({"id": c, "template": "<Card style={{gap: 4}} label={title} />"}));
assert_eq!(code(&mut s, c, "react"), "import { Card } from \"@acme/ui\";\n\n<Card style={{gap: 4}} label=\"Hello\" />\n");
// Validation.
for bad in [
json!({"id": c, "template": "{title ? \"a\"}"}),
json!({"id": c, "template": " "}),
json!({"id": c, "name": "1Card"}),
json!({"id": c, "importPath": "a\"b"}),
json!({"id": c, "language": "swiftui", "importPath": "Acme UI"}),
json!({"id": c, "language": "html"}),
json!({"id": c, "template": 5}),
json!({"id": screen}),
] {
assert!(s.execute("codeConnect.set", &bad).is_err(), "{bad}");
}
// Disconnect one language; undo brings it back.
ex(&mut s, "codeConnect.remove", json!({"id": i, "language": "react"}));
assert!(code(&mut s, i, "react").contains("export function Card({"));
assert!(code(&mut s, i, "swiftui").starts_with("import AcmeUI"));
ex(&mut s, "edit.undo", json!({}));
assert!(code(&mut s, i, "react").starts_with("import { Card }"));
assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 2);
assert!(s.execute("codeConnect.remove", &json!({"id": c})).is_err(), "nothing left");
}
#[test]
fn sets_connect_through_the_set_and_round_trip_as_json() {
let mut s = Session::new();
let (set, hover) = button(&mut s);
let v = ex(&mut s, "codeConnect.set", json!({"id": hover}));
assert_eq!(id_of(&v["component"]), Some(set));
ex(&mut s, "codeConnect.set", json!({"id": set, "language": "swiftui"}));
let i = idv(&ex(&mut s, "instance.create", json!({"component": hover, "x": 0, "y": 500})));
assert_eq!(code(&mut s, i, "react"), "<Button state=\"Hover\" />\n", "the default template passes every property");
assert_eq!(code(&mut s, i, "swiftui"), "Button(state: .hover)\n");
// Export, then import into another file with a Button set.
let text = ex(&mut s, "codeConnect.export", json!({}))["text"].as_str().unwrap().to_string();
let file: Value = serde_json::from_str(&text).unwrap();
assert_eq!(file["version"], 1);
assert_eq!(
file["links"][0],
json!({"component": "Button", "language": "react", "importPath": "", "name": "Button", "template": "<Button state={state} />"})
);
let mut t = Session::new();
let (_, hover2) = button(&mut t);
let i2 = idv(&ex(&mut t, "instance.create", json!({"component": hover2, "x": 0, "y": 500})));
let mut links = file["links"].as_array().unwrap().clone();
links.push(json!({"component": "Nope", "language": "react", "name": "X", "template": "<X />"}));
links.push(json!({"component": "Button", "language": "react", "name": 5, "template": "<X />"}));
links.push(json!({"component": "Button", "language": "react", "name": "Bad name", "template": "<X />"}));
let r = ex(&mut t, "codeConnect.import", json!({"json": {"version": 1, "links": links}}));
assert_eq!(r["linked"], 2);
assert_eq!(r["warnings"].as_array().unwrap().len(), 3, "{r}");
assert_eq!(code(&mut t, i2, "swiftui"), "Button(state: .hover)\n");
// Through a file, as JSON text.
let path = std::env::temp_dir().join(format!("figcraft-connect-{}.json", std::process::id()));
let path = path.to_string_lossy().to_string();
ex(&mut s, "codeConnect.export", json!({"path": path}));
let mut u = Session::new();
button(&mut u);
assert_eq!(ex(&mut u, "codeConnect.import", json!({"path": path}))["linked"], 2);
assert_eq!(ex(&mut u, "codeConnect.import", json!({"json": text}))["linked"], 2);
let _ = std::fs::remove_file(&path);
// Malformed files are refused whole.
let too_many: Vec<Value> = (0..=crate::code_connect::MAX_FILE_LINKS).map(|_| json!({})).collect();
for bad in [
json!({"json": {"version": 2, "links": []}}),
json!({"json": {"links": 5}}),
json!({"json": 5}),
json!({"json": "{"}),
json!({"json": {"links": too_many}}),
json!({}),
] {
assert!(u.execute("codeConnect.import", &bad).is_err(), "{bad}");
}
}
#[test]
fn code_connect_commands_survive_junk() {
let junk = [
json!({}),
json!(null),
json!({"id": "x", "language": [1], "template": {"a": 1}, "name": null}),
json!({"ids": [-1, "0:0"], "importPath": 1e300, "json": [null]}),
json!({"template": "{".repeat(5000), "name": "\u{0}\u{2028}", "path": 7}),
json!({"template": "{a ? \"}\" : \"{\"} {{{}}} {?} {\"} {:}", "json": {"links": [null, 5, {"component": [1]}]}}),
];
for cmd in ["codeConnect.set", "codeConnect.remove", "codeConnect.export", "codeConnect.import"] {
for j in &junk {
let mut s = Session::new();
let c = card(&mut s);
ex(&mut s, "codeConnect.set", json!({"id": c}));
ex(&mut s, "select.set", json!({"ids": [c]}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}

View File

@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.code",
"Code for layer",
"{id?, language?: css|html|react|swiftui|compose (default css; html and react are the whole layer tree; components take their properties as parameters, instances pass their values)} → {code, language}",
"{id?, language?: css|html|react|swiftui|compose (default css; html and react 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)} → {code, language, connected}",
false,
|s, p| {
let id = targets(s, p).first().copied().ok_or_else(|| bad("inspect.code", "select a layer"))?;
@ -30,7 +30,8 @@ pub fn specs() -> Vec<CommandSpec> {
if !doc.contains(id) {
return Err(bad("inspect.code", "no such layer"));
}
Ok(json!({"code": code_for(doc, id, lang), "language": lang.id()}))
let connected = crate::code_connect::link(doc, id, lang).is_some();
Ok(json!({"code": code_for(doc, id, lang), "language": lang.id(), "connected": connected}))
}
)]
}

View File

@ -3,7 +3,8 @@
//! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot →
//! optional content), and an instance becomes a call passing the values it sets. Variants of a set
//! share one definition (the set's first variant); in React their differences become CSS rules on
//! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`.
//! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
//! connected to code (see [`crate::code_connect`]) shows its real snippet instead.
use figcraft_doc::*;
use serde_json::{Value, json};
@ -66,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.componentCode",
"Component code",
"{id? (component, set or instance; default the selection), language?: react|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances)}",
"{id? (component, set or instance; default the selection), language?: react|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
false,
|s, p| {
let cmd = "inspect.componentCode";
@ -97,6 +98,10 @@ pub fn specs() -> Vec<CommandSpec> {
if values.is_some() {
out["usage"] = json!(usage(doc, id, lang, 0));
}
out["connected"] = crate::code_connect::link(doc, id, lang).map_or(Value::Null, |l| {
json!({"importPath": l.import_path, "name": l.name, "template": l.template, "import": crate::code_connect::import_line(l, lang),
"code": crate::code_connect::connected_code(doc, id, lang, l)})
});
Ok(out)
}
)]
@ -107,6 +112,9 @@ pub fn specs() -> Vec<CommandSpec> {
/// function component).
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
let Some(n) = doc.node(id) else { return String::new() };
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
return code;
}
let comp = main_of(doc, id).and_then(|m| describe(doc, m));
match (&n.kind, comp) {
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
@ -376,13 +384,13 @@ fn cases(values: &[String], swift: bool) -> Vec<String> {
}
/// A JavaScript string literal.
fn js_string(s: &str) -> String {
pub(crate) fn js_string(s: &str) -> String {
let s: String = s.chars().take(10_000).collect();
serde_json::to_string(&s).unwrap_or_else(|_| "\"\"".into())
}
/// The type name of a component (a variant's set) by id.
fn component_type(doc: &Document, comp: NodeId) -> Option<String> {
pub(crate) fn component_type(doc: &Document, comp: NodeId) -> Option<String> {
let n = doc.node(comp).filter(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))?;
Some(component_type_name(&variant_set(doc, comp).and_then(|s| doc.node(s)).unwrap_or(n).name))
}
@ -393,7 +401,7 @@ fn enum_name(c: &Component, p: &Param, swift: bool) -> String {
}
/// `value` of a parameter as an expression in `lang`.
fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value, lang: CodeLang) -> String {
pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value, lang: CodeLang) -> String {
match (&p.kind, lang) {
(ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(),
(ParamKind::Text, CodeLang::React) => js_string(value.as_str().unwrap_or("")),
@ -427,7 +435,7 @@ fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value, lang: Cod
/// The values an instance sets for `c`'s parameters, in order (`None` for other layers): variant
/// values from the variant it shows, slots as whether they hold its own content.
fn instance_values(doc: &Document, id: NodeId, c: &Component) -> Option<Vec<(String, Value)>> {
pub(crate) fn instance_values(doc: &Document, id: NodeId, c: &Component) -> Option<Vec<(String, Value)>> {
let NodeKind::Instance { component, props } = &doc.node(id)?.kind else { return None };
let variant = doc.node(*component).map(|m| variant_props(&m.name)).unwrap_or_default();
let slots = slots_of(doc, id);
@ -451,8 +459,13 @@ fn instance_values(doc: &Document, id: NodeId, c: &Component) -> Option<Vec<(Str
pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option<String> {
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
let values = instance_values(doc, id, &c)?;
let slots = slots_of(doc, id);
let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) };
// A connected component: its real snippet. shortcut: inside a layer tree it brings no import
// line; collect them at the top of the tree's code when people ask for it.
if let Some(link) = crate::code_connect::link(doc, id, lang) {
return Some(crate::code_connect::render(doc, &c, &values, link, lang, &pad));
}
let slots = slots_of(doc, id);
// (parameter, expression, whether it's a plain string literal)
let mut args: Vec<(&str, String, bool)> = Vec::new();
for (p, (_, v)) in c.params.iter().zip(&values) {

View File

@ -264,6 +264,7 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
root.transform = Affine::translate((x, y));
root.prop_refs.clear();
root.annotations.clear();
root.code_links.clear();
doc.insert(root, parent, index)?;
sync_instance(doc, id);
Ok(id)
@ -679,6 +680,8 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
fresh.prop_refs = root.prop_refs.clone();
fresh.plugin_data = root.plugin_data.clone();
fresh.annotations = root.annotations.clone();
// Code links belong to the main component; instances read them from it.
fresh.code_links.clear();
fresh.locked = root.locked;
fresh.visible = root.visible;
fresh.var_modes = root.var_modes.clone();

View File

@ -133,6 +133,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
if !n.annotations.is_empty() {
o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
}
if !n.code_links.is_empty() {
o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
}
if !n.interactions.is_empty() {
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
}

View File

@ -14,6 +14,7 @@ pub mod booleans;
pub mod branches;
pub mod catalog;
pub mod cmd;
pub mod code_connect;
pub mod codegen;
pub mod collab;
pub mod component_codegen;
@ -1190,6 +1191,8 @@ mod branch_tests;
#[cfg(test)]
mod brush_tests;
#[cfg(test)]
mod code_connect_tests;
#[cfg(test)]
mod collab_ids_tests;
#[cfg(test)]
mod collab_live_tests;

View File

@ -1,14 +1,17 @@
//! Dev Mode extras in the Inspect tab: annotations (notes with pinned properties and a category)
//! drawn as callouts in their category's colour on the canvas and edited in the panel, the
//! categories and the filter of the callouts, the handoff status of the selected frames (with the
//! focus view and Compare changes), the list of frames and sections that are ready for dev, and
//! the focus view's bar.
//! focus view and Compare changes), the list of frames and sections that are ready for dev, the
//! focus view's bar, Connect to code (a component's link to its code component) and the plugins
//! that only read the document.
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{Annotation, AnnotationCategory, DevStatus, Document, NodeId};
use figcraft_doc::{Annotation, AnnotationCategory, CodeLink, DevStatus, Document, NodeId};
use figcraft_engine::annotations::{self, PINNABLE, Pinnable};
use figcraft_engine::code_connect;
use figcraft_engine::codegen::{CodeLang, code_for};
use figcraft_engine::devinspect::{self, DevItem, Prop};
use figcraft_engine::plugins::Access;
use serde_json::json;
use crate::a11y::Tip;
@ -227,10 +230,25 @@ impl Callout {
// ------------------------------------------------------------------ Inspect panel
/// Where the template being edited in Connect to code keeps its draft.
fn template_id(owner: NodeId, lang: CodeLang) -> Id {
Id::new(("code_connect_template", owner, lang.id()))
}
/// Layer `id`'s code in `lang`, generated again only when the document or the choice change (a
/// component set's code compares every variant).
pub fn code(app: &mut FigApp, id: NodeId, lang: CodeLang) -> String {
/// component set's code compares every variant). While a connected template is being edited, its
/// draft's snippet (the live preview).
pub fn code(app: &mut FigApp, ui: &Ui, id: NodeId, lang: CodeLang) -> String {
let Ok(ds) = app.session.ds() else { return String::new() };
// The draft lives on after the field loses focus to another layer: only a focused one counts.
if let Some(owner) = code_connect::owner(&ds.doc, id)
&& ui.memory(|m| m.has_focus(template_id(owner, lang).with("te")))
&& let Some(template) = ui.data(|d| d.get_temp::<String>(template_id(owner, lang)))
&& let Some(link) = code_connect::link(&ds.doc, id, lang)
{
let draft = CodeLink { template, ..link.clone() };
return code_connect::connected_code(&ds.doc, id, lang, &draft).unwrap_or_default();
}
let key = (ds.uid, ds.revision, id, lang);
match &app.ui.dev.code {
Some((k, c)) if *k == key => c.clone(),
@ -242,6 +260,121 @@ pub fn code(app: &mut FigApp, id: NodeId, lang: CodeLang) -> String {
}
}
/// Connect to code for the selected component, set or instance (its main), in the Code section's
/// language: a button to connect, then a Connected badge, the import path, name and template (the
/// code above shows the snippet, live while the template is edited).
pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, lang: CodeLang) {
let Some(owner) = code_connect::owner(doc, id) else { return };
let t = Tokens::get(ui.ctx());
let link = code_connect::link(doc, id, lang).cloned();
let connectable = code_connect::language(lang.id()).is_some();
widgets::separator(ui);
let buttons: &[(Icon, &str)] = match (&link, connectable) {
(Some(_), _) => &[(Icon::Minus, "Disconnect from code")],
(None, true) => &[(Icon::Plus, "Connect to code")],
_ => &[],
};
let clicked = widgets::section_header(ui, "Connect to code", buttons, link.is_none()).is_some();
let params = json!({"id": owner, "language": lang.id()});
let Some(link) = link else {
if clicked {
app.run("codeConnect.set", params);
}
let text = if connectable {
"Link this component to its code component: instances then show your real snippet with their values."
} else {
"Choose React, SwiftUI or Compose above to connect this component to its code."
};
widgets::note(ui, text, t.text_secondary);
return;
};
if clicked {
app.run("codeConnect.remove", params);
return;
}
// The badge, and what the snippet comes from.
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover());
let g = ui.painter().layout_no_wrap("Connected".into(), semibold(10.0), Color32::WHITE);
let pill = Rect::from_min_size(pos2(r.min.x + PAD, r.center().y - g.size().y / 2.0 - 2.0), g.size() + vec2(10.0, 4.0));
ui.painter().rect_filled(pill, CornerRadius::same(4), t.success);
ui.painter().galley(pill.min + vec2(5.0, 2.0), g, Color32::WHITE);
let from = if link.import_path.is_empty() { link.name.clone() } else { format!("{} from {}", link.name, link.import_path) };
crate::a11y::status(&resp, "Connected", &format!("Connected to code: {from}"));
let from_x = pill.max.x + 8.0;
let from = widgets::elide(ui, &from, r.max.x - PAD - from_x);
ui.painter().text(pos2(from_x, r.center().y), Align2::LEFT_CENTER, from, regular(11.0), t.text_secondary);
let mut edit: Option<(&str, String)> = None;
widgets::sublabels(ui, "Import path", Some("Name"));
let col = widgets::col_width(ui.available_width());
row(ui, 32.0, |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_import", owner, lang.id())), &link.import_path, col, "Import path") {
edit = Some(("importPath", v));
}
if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_name", owner, lang.id())), &link.name, col, "Component name") {
edit = Some(("name", v));
}
});
widgets::sublabels(ui, "Template", None);
if let Some(v) = widgets::multiline_field(ui, template_id(owner, lang), &link.template, "<Button size={size}>{label}</Button>", 3) {
edit = Some(("template", v));
}
widgets::note(ui, "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written.", t.text_secondary);
if let Some((key, value)) = edit {
let mut params = params;
params[key] = json!(value);
if let Some(out) = app.run("codeConnect.set", params)
&& let Some(names) = out["unknown"].as_array().filter(|u| !u.is_empty())
{
let names: Vec<&str> = names.iter().filter_map(serde_json::Value::as_str).collect();
app.toast(format!("Not properties, kept as written: {}", names.join(", ")));
}
}
ui.add_space(4.0);
}
/// Dev Mode plugins: the installed plugins that only read the document, run from the Inspect tab.
pub fn plugins_section(app: &mut FigApp, ui: &mut Ui) {
let runnable: Vec<(String, String, String)> = app
.session
.prefs
.plugins
.iter()
.filter(|p| p.manifest.permissions.access == Access::Read)
.flat_map(|p| {
let m = &p.manifest;
m.commands.iter().map(move |c| {
let label = if m.commands.len() > 1 { format!("{} · {}", m.name, c.label) } else { m.name.clone() };
(m.id.clone(), c.id.clone(), label)
})
})
.collect();
if runnable.is_empty() {
return;
}
let t = Tokens::get(ui.ctx());
widgets::separator(ui);
widgets::section_header(ui, "Plugins", &[], false);
let mut run = None;
for (id, command, label) in &runnable {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
let resp = resp.tip(format!("Run {label}"));
if resp.hovered() {
ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 6.0, r.center().y), vec2(12.0, 12.0)), Icon::Actions, t.icon);
let name = widgets::elide(ui, label, r.width() - PAD * 2.0 - 20.0);
ui.painter().text(pos2(r.min.x + PAD + 20.0, r.center().y), Align2::LEFT_CENTER, name, regular(11.0), t.text);
if resp.clicked() {
run = Some((id.clone(), command.clone()));
}
}
if let Some((id, command)) = run {
crate::plugins_ui::start(app, &id, &command);
}
ui.add_space(4.0);
}
/// The handoff status of the selected frames or sections (nothing for other layers).
pub fn status(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
if sel.is_empty() || !sel.iter().all(|i| crate::section_panel::has_status(doc, *i)) {

View File

@ -2057,6 +2057,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
widgets::note(ui, "Hover other layers to measure the distance", t.text_secondary);
crate::dev_mode::ready_list(app, ui);
crate::dev_mode::categories_section(app, ui);
crate::dev_mode::plugins_section(app, ui);
return;
};
let name = doc.node(first).map(|n| n.name.clone()).unwrap_or_default();
@ -2083,7 +2084,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
}
});
});
let code = crate::dev_mode::code(app, first, lang);
let code = crate::dev_mode::code(app, ui, first, lang);
egui::Frame::NONE
.fill(t.field)
.corner_radius(CornerRadius::same(6))
@ -2101,4 +2102,6 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
app.services.clipboard_set_text(&code);
app.toast(format!("Copied {}", lang.label()));
}
crate::dev_mode::connect_section(app, ui, &doc, first, lang);
crate::dev_mode::plugins_section(app, ui);
}

View File

@ -58,7 +58,8 @@ layout (including grid `columnTracks` / `rowTracks`), sizing, `minWidth` / `maxW
children), `gridCell` (a grid auto layout child's column, row and spans), clip, text properties,
component/instance links, overrides, `slot` (a slot layer's property name), boundVariables, interactions, exports,
`devStatus` (handoff status), `devChanged` (changed since it was marked ready for dev),
`annotations` (Dev Mode notes: `[{label, properties, category?}]`). The auto layout props
`annotations` (Dev Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's
links to code: `[{language, importPath, name, template}]`). The auto layout props
that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know).
### `ui.inspect {}`

View File

@ -1,9 +1,9 @@
# Dev Mode parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the ROADMAP's Dev Mode items) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (code connect and Dev Mode plugins) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
**Breadth ~80%, ready ~62% (estimated).** The inspect surface is broad; what's missing connects
designs to real codebases. Remaining: 4–8 h.
**Breadth ~90%, ready ~64% (estimated).** The inspect surface is broad and components connect
to the code components of a repository; left: dev resources links. Remaining: 1–3 h.
| Feature | Status | Notes |
|---|---|---|
@ -15,13 +15,14 @@ designs to real codebases. Remaining: 4–8 h.
| 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) | missing | |
| Plugins in Dev Mode (Jira, GitHub, etc.) | missing | needs plugins |
| VS Code extension | missing | FigCraft's MCP server and control channel already serve design data to agents and editors |
| Code connect (map components to repository code) | done | per language (React, 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 | missing | 1 h |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–720 h |
| 2026-10-10 | major | Created from the 2026-10-10 re-measure |

View File

@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@ -29,7 +29,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 13 | Boolean groups hit-tested by their operands (the rest of the vector tail landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) |
| 14 | Variables: extended collections' tail | Feature | 1–2 | [components-parity](components-parity.md) |
| 15 | Prototyping: media triggers and actions, gamepad | Feature | 3–6 | [prototyping-parity](prototyping-parity.md) |
| 16 | Dev Mode: Code Connect, editor integrations | Feature | 4–8 | [dev-mode-parity](dev-mode-parity.md) |
| 16 | Dev Mode: dev resources links, editor extension (code connect and Dev Mode plugins landed) | Feature | 1–3 | [dev-mode-parity](dev-mode-parity.md) |
| 17 | Branching: per-branch version history, finer merges of list properties (branches landed 2026-10-11) | Feature | 1–3 | [collaboration-parity](collaboration-parity.md) |
| 18 | Display P3 on screen; one window only | Hardware | 6–12 | [hardware-parity](hardware-parity.md) |
| 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) |
@ -204,10 +204,21 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
The REST importer warns and skips them (`crates/format/src/rest/proto.rs`).
- **Estimate:** 3–6 h after video fills (gap 12).
### 16. Dev Mode: Code Connect, editor integrations
- **Missing:** mapping components to real code (Code Connect), plugins in Dev Mode, VS Code
extension. FigCraft's MCP server already serves design data to agents.
- **Estimate:** 4–8 h.
### 16. Dev Mode: dev resources links, editor extension
- **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}`
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
it with a live preview. Links are stored on the component (saved, synced, undoable) and travel
as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`, validated and capped)
so code owners can version them. Plugins whose manifest asks only to read run from the Inspect
tab.
- **Missing:** dev resources links on layers; import lines for connected instances inside a
generated layer tree (only the selected layer's own import is shown); publishing mappings from
the code repository with a CLI. An editor extension is out of scope: the MCP server already
serves design data, code and the connected snippets to agents and editors.
- **Estimate:** 1–3 h.
### 17. Branching and merging
- **Landed (2026-10-11):** branches of a file (`branch.*`): File ▸ Create branch… (or the Branches
@ -263,6 +274,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h |
| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `<textPath>` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG `<pattern>`, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h |
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |

View File

@ -126,7 +126,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, 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), `inspect.assets` (layers under the selection with export settings), `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), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, 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), `inspect.assets` (layers under the selection with export settings), `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. |
| `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. |
@ -719,6 +719,30 @@ the last plugin command again.
that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
{id?, language?}` returns the parts: `{component, name, set, properties: [{name, param, type,
default, value? (instances)}], definition, usage? (instances)}`.
- **Code connect:** link a component (a variant links its set) to the code component it is built
as, per language (`react`, `swiftui`, `compose`): `codeConnect.set {id?, language?, importPath?,
name?, template?}` (undoable; fields left out keep their value; the defaults are the generated
name and a call passing every property) → `{component, language, code, unknown}`. Templates are
text, never executed: `{property}` (its name or parameter name) is the instance's value escaped
for the language (React keeps JSX's braces, so `size={size}` gives `size="Large"` and
`disabled={disabled}` gives `disabled={true}`), `{property ? "a" : "b"}` inserts one of two
strings, any other `{…}` stays as written (`unknown` lists placeholder names that aren't
properties). Then `inspect.code` on an instance or the component returns the import line and the
snippet instead of generated code (`connected: true`), connected instances inside a generated
layer tree are their snippet, and `inspect.componentCode` returns `connected: {importPath, name,
template, import, code}` with the snippet as `usage`. Example: template `<Button
variant="primary" icon="{Has icon ? "cart" : "none"}">{label}</Button>` with import path
`@acme/ui` gives `import { Button } from "@acme/ui";` then `<Button variant="primary" icon="none">{"Buy
now"}</Button>`. `codeConnect.remove {id?, language?}` (no language: all). `codeConnect.export
{path?}` → `{text, links}` (or writes the file): `{version: 1, links: [{component, language,
importPath, name, template}]}`, sorted, meant to live in the code repository as
`figcraft.connect.json`; `codeConnect.import {json | path}` → `{linked, warnings}` links each
entry to every component or set with that name (one undo step; at most 5,000 links and 4 MB;
templates up to 4,000 characters, names 100, import paths 300; invalid entries are skipped with a
warning). `get_node` lists a component's links as `codeLinks`. In the app, the Inspect tab's
Connect to code section edits them in the Code section's language, with a live preview and a
Connected badge. An editor extension isn't needed: agents and editors read the same snippets
through this server. Plugins whose manifest asks only for `read` also run from the Inspect tab.
- **Annotations:** notes for developers on any layer, each with optional pinned properties that
Dev Mode shows with their current values (and the style or variable behind them).
`annotation.add {id?, label?, properties?}` (a note, pinned properties or both; returns `{id,

View File

@ -86,7 +86,7 @@ plugins: import them in the script.
| `version`, `description` | Optional text. |
| `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. |
| `api` | The plugin API version, `1` (the default). A manifest asking for a newer one is refused. |
| `permissions.access` | `read` (the default): commands that leave the document as it is. `write`: edits too. |
| `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. |
| `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. |
| `commands` | The menu commands, each `{id, label, params?}`. Without any, the plugin has one, `run`. |
| `params[]` | `{name, label?, type, default?, options?, min?, max?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean` or `select` (one of `options`). |

View File

@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@ -52,7 +52,7 @@ blockers.
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 |
| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11) | spell check, Figma Draw multi-edit, boolean hit testing on the result | 8–19 |
| M5 Prototype & present | ~85% | media, gamepad, separate window | 5–9 |
| M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent, Code Connect | 40–80 |
| M6 Interop & Dev Mode | ~55% (code connect and Dev Mode plugins landed 2026-10-11) | `.fig` import, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 37–75 |
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library | accounts, teams, TLS in the desktop app, packaging the server | 45–90 |
@ -72,6 +72,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h |
| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `<textPath>` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG `<pattern>`, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h |
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |

View File

@ -1,6 +1,6 @@
# FigCraft parity with Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@ -35,8 +35,8 @@ tool; presence was being graded as behaviour in places.
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~47% | ~360–680 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
| Mainstream practitioner | ~43% | ~170–315 h | core editor depth incl. `.fig` import (88–171), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Full target (ready for real work) | ~47% | ~355–675 h | multiplayer (45–90), localization (60–100), plugins (25–55), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) |
| Mainstream practitioner | ~43% | ~165–310 h | core editor depth incl. `.fig` import (83–163), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) |
| Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) |
Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour
@ -108,7 +108,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 12 | Styles & variables | 5 | 92% | 72% | New library variables reaching extended collections on their own, extending an extended collection, real-library QA (extended collections, variables import/export, gradient stops and variant properties bound landed) | 1–2 |
| 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 |
| 14 | Import/export | 6 | 65% | 35% | **`.fig` import** (the blocking gap), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 30–60 |
| 15 | Dev Mode / inspect | 2 | 80% | 62% | Code Connect, plugins in Dev Mode, editor integrations ([dev-mode-parity](dev-mode-parity.md)) | 4–8 |
| 15 | Dev Mode / inspect | 2 | 90% | 64% | Dev resources links, import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (code connect and plugins in Dev Mode landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–3 |
| 16 | Comments, versions, find, misc | 3 | 85% | 60% | Comments are local only; a branch's own version history (branches with review and merge landed) | 2–5 |
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 |
| 18 | Plugins & widgets | 4 | 35% | 20% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples). Left: plugin UI at run time, settings storage, distribution, widgets; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 25–55 |
@ -122,7 +122,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| Dimension | Weight | Ready | Hours | Evidence | Doc |
|---|---|---|---|---|---|
| Features (depth) | 35 | 57% | 66–126 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file |
| Features (depth) | 35 | 57% | 63–121 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file |
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
| File formats | 12 | 40% | 30–60 | No `.fig` read or write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
| Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
@ -563,7 +563,8 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
with pinned properties and categories drawn as coloured callouts on the canvas (filtered by category),
a ready-for-dev list, a focus view on one frame with previous / next, Compare changes since a frame
was marked ready (before / after side by side or overlaid, property-level diffs), CSS/HTML/React/SwiftUI/Compose
code where components take their properties as parameters and instances are calls), colour picker
code where components take their properties as parameters and instances are calls, components
connected to their code components showing the real snippet, read-only plugins), colour picker
(HSB square, hue/alpha, hex, gradient stops, image fills), main menu generated from the catalog,
context menu, Actions palette (⌘K), keyboard shortcuts from one keymap per platform (a tabbed,
searchable Keyboard shortcuts panel), keyboard navigation (F6 between regions, Tab within them, a
@ -577,6 +578,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h |
| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h |
| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `<textPath>` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG `<pattern>`, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h |
| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h |