From 13ab3f150fadcbb001aae8109b7a9cb306c861b4 Mon Sep 17 00:00:00 2001 From: BFlat Date: Sun, 11 Oct 2026 14:02:37 +0700 Subject: [PATCH] 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. --- ROADMAP.md | 11 +- crates/collab/src/change.rs | 1 + crates/doc/src/node.rs | 21 ++ crates/engine/src/cmd/file.rs | 9 + crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/code_connect.rs | 450 ++++++++++++++++++++++++ crates/engine/src/code_connect_tests.rs | 176 +++++++++ crates/engine/src/codegen.rs | 5 +- crates/engine/src/component_codegen.rs | 27 +- crates/engine/src/components.rs | 3 + crates/engine/src/inspect.rs | 3 + crates/engine/src/lib.rs | 3 + crates/ui-egui/src/dev_mode.rs | 143 +++++++- crates/ui-egui/src/right.rs | 5 +- docs/control-protocol.md | 3 +- docs/dev-mode-parity.md | 13 +- docs/gaps.md | 24 +- docs/mcp.md | 26 +- docs/plugins.md | 2 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 14 +- 21 files changed, 902 insertions(+), 43 deletions(-) create mode 100644 crates/engine/src/code_connect.rs create mode 100644 crates/engine/src/code_connect_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 736784d..f8173e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 `` 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 ``, 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 | diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 44dd302..c39bd38 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -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, diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 0d47fdd..3cc92f2 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -213,6 +213,22 @@ pub struct Annotation { pub category: Option, } +/// 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, + /// 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, } 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(), } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 06ca52c..306054d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -369,6 +369,15 @@ fn read(path: &str) -> Result> { 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> { + 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"); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 2172297..b1407f7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs new file mode 100644 index 0000000..1938766 --- /dev/null +++ b/crates/engine/src/code_connect.rs @@ -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::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 { + 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 { + 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>, 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>, 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::()), + CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), + _ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::>().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, 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 = 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> { + 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 { + 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 { + 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 = 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 { + let cmd = "codeConnect.import"; + let parse_json = |b: &[u8]| serde_json::from_slice::(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 = 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 = 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})) +} diff --git a/crates/engine/src/code_connect_tests.rs b/crates/engine/src/code_connect_tests.rs new file mode 100644 index 0000000..d505fc8 --- /dev/null +++ b/crates/engine/src/code_connect_tests.rs @@ -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 = ["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 = "{Show dot ? \"•\" : \"\"}"; + 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\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•\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("•\n")); + // Literal braces stay; fields left out keep their value. + ex(&mut s, "codeConnect.set", json!({"id": c, "template": ""})); + assert_eq!(code(&mut s, c, "react"), "import { Card } from \"@acme/ui\";\n\n\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"), "", 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)) { diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 89161d0..3809e8e 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -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); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7772a46..727db74 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 {}` diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index 00888dc..947a8c7 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -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 | diff --git a/docs/gaps.md b/docs/gaps.md index 079d123..564f5f7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 `` 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 ``, 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 | diff --git a/docs/mcp.md b/docs/mcp.md index bc445e5..94f6288 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 `` with import path + `@acme/ui` gives `import { Button } from "@acme/ui";` then ``. `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, diff --git a/docs/plugins.md b/docs/plugins.md index 600fa0a..569a4dd 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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`). | diff --git a/docs/roadmap.md b/docs/roadmap.md index c0070cf..8e96e1e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 `` 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 ``, 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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9f2a520..c4079a7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 `` 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 ``, 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 |