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:
parent
cb5435d5ff
commit
13ab3f150f
11
ROADMAP.md
11
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 `<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 |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}))
|
||||
}
|
||||
)]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {}`
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
24
docs/gaps.md
24
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 `<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 |
|
||||
|
|
|
|||
26
docs/mcp.md
26
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 `<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,
|
||||
|
|
|
|||
|
|
@ -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`). |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue