diff --git a/ROADMAP.md b/ROADMAP.md index d5895c1..f8cbc03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~170–305 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; 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 ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; 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 ~80%; ready ~49%) · **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 @@ -19,7 +19,7 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~50%** | estimated: weekly areas 72.3% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~170–305 h** one agent | estimated | -| Remaining to full parity | **~304–591 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~303–590 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | ### Readiness by audience @@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Dimension | Ready | Hours | Doc | |---|---|---|---| -| Features (depth) | 61% (core editor 72%) | 44–90 | [target-app-parity](docs/target-app-parity.md) | +| Features (depth) | 61% (core editor 72%) | 43–89 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 59% (`.fig` read best effort with editable vectors, no write) | 5–10 | [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) | @@ -87,7 +87,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Prototyping | 90% | 74% | 3–6 | [prototyping-parity](docs/prototyping-parity.md) | | Import/export | 84% | 57% | 5–10 | [file-format-parity](docs/file-format-parity.md) | | Dev Mode / inspect | 93% | 70% | 1–3 | [dev-mode-parity](docs/dev-mode-parity.md) | -| Comments, versions, misc | 85% | 62% | 2–4 | [collaboration-parity](docs/collaboration-parity.md) | +| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 45% | 28% | 15–40 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | @@ -213,6 +213,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h (the Features table here still showed 80%/58%/4–8 h from before branches landed) | | 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) | | 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h | | 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h (this table still showed 80%/58%/4–8 h from before branches landed); percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index c39bd38..4c25a7b 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -87,7 +87,8 @@ macro_rules! node_props { ($($variant:ident => $field:ident),* $(,)?) => { /// A node property: the tree position (`parent`, `position`) or one saved field of /// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each, - /// replaced whole. New properties go at the end (the binary form numbers them). + /// replaced whole (three-way merges line fills, strokes and effects up item by item, see + /// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them). #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Prop { diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 4db37b4..e990915 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -130,6 +130,8 @@ mod frac_tests; #[cfg(test)] mod journal_tests; #[cfg(test)] +mod merge_list_tests; +#[cfg(test)] mod merge_tests; #[cfg(test)] mod reconnect_tests; diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index d44f8e6..bb18e66 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -4,7 +4,10 @@ //! //! A conflict is the same property (or document-level entry) changed on both sides to different //! values, or a layer deleted on one side while the other side edited it or something inside it -//! (including adding layers to it or moving layers into or out of it). Nothing that conflicts is +//! (including adding layers to it or moving layers into or out of it). Fills, strokes and effects +//! merge item by item: both sides changing different items of the list (or one adding items at its +//! end) is no conflict, only the same item changed both ways or the list shortened on a side +//! while the other changed it. Nothing that conflicts is //! merged until it is resolved: each conflict is resolved for one side, and the other side's //! changes to it are left out. Everything else from both sides is kept. @@ -13,8 +16,9 @@ use std::fmt; use figcraft_doc::{Document, NodeId}; use serde::{Deserialize, Serialize}; +use serde_json::Value; -use crate::change::{Change, DocProp, Entry, Prop}; +use crate::change::{Change, DocProp, Entry, Json, Prop}; use crate::replica::{Mode, Positions, Replica, create_change}; use crate::{Error, diff}; @@ -155,6 +159,55 @@ fn by_key(changes: &[Change]) -> BTreeMap> { out } +/// List properties whose items merge one by one. +const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects]; + +/// Both sides' writes `b` and `m` of list property `key` merged item by item against the base +/// (see [`merge_items`]); `None` when `key` isn't such a list or the writes conflict. +fn merged_list(base: &Document, key: &Key, b: Option<&Change>, m: Option<&Change>) -> Option { + let Key::Node(id, prop) = key else { return None }; + let (Some(Change::Set { value: bv, .. }), Some(Change::Set { value: mv, .. })) = (b, m) else { return None }; + if !PER_ITEM.contains(prop) { + return None; + } + let o = prop.get(base.node(*id)?).ok()?; + merge_items(o.0.as_array()?, bv.0.as_array()?, mv.0.as_array()?).map(|v| Json(Value::Array(v))) +} + +/// The change at the only index in `c`. +fn only<'a>(c: &[usize], all: &'a [Change]) -> Option<&'a Change> { + match c { + [i] => all.get(*i), + _ => None, + } +} + +/// Three-way merge of list `o` changed to `b` on one side and `m` on the other. Items at the same +/// index merge one by one (changed on one side: that side's; on both: only the same way), and +/// items added after `o`'s on one side (or the same ones on both) are kept. `None` when the same +/// item changed both ways, both added different items, or a side removed items (lists without ids +/// can't be lined up then). +pub(crate) fn merge_items(o: &[Value], b: &[Value], m: &[Value]) -> Option> { + let (tb, tm) = (b.get(o.len()..)?, m.get(o.len()..)?); + let mut out = Vec::with_capacity(b.len().max(m.len())); + for (i, base) in o.iter().enumerate() { + let (x, y) = (b.get(i)?, m.get(i)?); + let item = if x == base { + y + } else if y == base || x == y { + x + } else { + return None; + }; + out.push(item.clone()); + } + if !tb.is_empty() && !tm.is_empty() && tb != tm { + return None; + } + out.extend_from_slice(if tb.is_empty() { tm } else { tb }); + Some(out) +} + /// The changes (indexes) that edit what's in `gone`: write one of its layers, create a layer in it /// (or in a layer created in it) or move a layer into it. Changes come in [`diff`] order, so /// creations come parents first and before moves. @@ -189,9 +242,10 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result = b.changes.into_iter().filter(merges).collect(); - let mc: Vec = m.changes.into_iter().filter(merges).collect(); + let mut bc: Vec = b.changes.into_iter().filter(merges).collect(); + let mut mc: Vec = m.changes.into_iter().filter(merges).collect(); let mut conflicts = Vec::new(); + let mut lists = Vec::new(); let km = by_key(&mc); for (k, bi) in by_key(&bc) { // Deletions conflict with edits (below); images are content-addressed, so never differ. @@ -203,8 +257,23 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result lists.push((bi, mi.clone(), value)), + None => conflicts.push(Conflict { key: k, deleted_by: None, branch: bi, main: mi.clone() }), + } + } + // Both sides' writes of a list merged item by item become the merged list, so merging either + // way writes it. + for (bi, mi, value) in lists { + for (changes, at) in [(&mut bc, bi), (&mut mc, mi)] { + for i in at { + if let Some(Change::Set { value: v, .. }) = changes.get_mut(i) { + *v = value.clone(); + } + } } } for (side, changes, doc, other) in [(Side::Branch, &bc, branch, &mc), (Side::Main, &mc, main, &bc)] { diff --git a/crates/collab/src/merge_list_tests.rs b/crates/collab/src/merge_list_tests.rs new file mode 100644 index 0000000..436e6b6 --- /dev/null +++ b/crates/collab/src/merge_list_tests.rs @@ -0,0 +1,129 @@ +//! Fills, strokes and effects merge item by item: different items changed on each side merge, +//! the same item changed both ways conflicts. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Document, Effect, NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::merge::{Key, Side, merge_items, plan}; + +/// A file with one rectangle (two fills, one stroke, two effects), a branch of it and main. +fn files() -> (Document, Document, Document, NodeId) { + let mut base = Document::new("File"); + let page = base.first_page().unwrap(); + let r = base.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + let n = base.get_mut(r).unwrap(); + n.fills = vec![Paint::solid(Color::BLACK), Paint::solid(Color::WHITE)]; + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.effects = vec![Effect::drop_shadow(), Effect::layer_blur()]; + let mut branch = base.clone(); + branch.set_client_id(base.highest_id_space() + 1); + let main = base.clone(); + (base, branch, main, r) +} + +fn red() -> Paint { + Paint::solid(Color::from_rgb8_const(255, 0, 0)) +} + +fn blue() -> Paint { + Paint::solid(Color::from_rgb8_const(0, 0, 255)) +} + +/// Merging either way gives `check` the same result. +fn both_ways(base: &Document, branch: &Document, main: &Document, check: impl Fn(&Document)) { + let p = plan(base, branch, main).unwrap(); + assert!(p.conflicts.is_empty(), "{:?}", p.conflicts); + for (into, from) in [(main, Side::Branch), (branch, Side::Main)] { + let (doc, _) = p.merge(into, from, &|_| None).unwrap(); + assert!(doc.validate().is_empty()); + check(&doc); + } +} + +#[test] +fn different_items_of_a_list_merge() { + // The branch recolours the first fill and hides the shadow; main recolours the second fill + // and hides the blur. + let (base, mut branch, mut main, r) = files(); + branch.get_mut(r).unwrap().fills[0] = red(); + branch.get_mut(r).unwrap().effects[0].visible = false; + main.get_mut(r).unwrap().fills[1] = blue(); + main.get_mut(r).unwrap().effects[1].visible = false; + both_ways(&base, &branch, &main, |doc| { + let n = doc.node(r).unwrap(); + assert_eq!(n.fills, vec![red(), blue()]); + assert!(!n.effects[0].visible && !n.effects[1].visible); + }); +} + +#[test] +fn items_added_on_one_side_are_kept() { + // The branch adds a stroke; main recolours the existing one. The same item added on both + // sides is kept once. + let (base, mut branch, mut main, r) = files(); + branch.get_mut(r).unwrap().strokes.push(red()); + main.get_mut(r).unwrap().strokes[0] = blue(); + branch.get_mut(r).unwrap().fills.push(red()); + main.get_mut(r).unwrap().fills.push(red()); + both_ways(&base, &branch, &main, |doc| { + let n = doc.node(r).unwrap(); + assert_eq!(n.strokes, vec![blue(), red()]); + assert_eq!(n.fills.len(), 3); + }); +} + +#[test] +fn the_same_item_changed_both_ways_conflicts() { + let (base, mut branch, mut main, r) = files(); + branch.get_mut(r).unwrap().fills[0] = red(); + main.get_mut(r).unwrap().fills[0] = blue(); + // A removed item on one side while the other changed the list conflicts too (items have no + // ids to line them up by). + branch.get_mut(r).unwrap().effects.remove(0); + main.get_mut(r).unwrap().effects[1].visible = false; + // Different items added at the end on both sides conflict. + branch.get_mut(r).unwrap().strokes.push(red()); + main.get_mut(r).unwrap().strokes.push(blue()); + let p = plan(&base, &branch, &main).unwrap(); + let mut keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); + keys.sort(); + let mut want = [format!("{r}/effects"), format!("{r}/fills"), format!("{r}/strokes")]; + want.sort(); + assert_eq!(keys, want); + // Resolving takes that side's whole list. + let pick = |s: Side| move |_: &Key| Some(s); + let (doc, _) = p.merge(&main, Side::Branch, &pick(Side::Branch)).unwrap(); + assert_eq!(doc.node(r).unwrap().fills, branch.node(r).unwrap().fills); + let (doc, _) = p.merge(&main, Side::Branch, &pick(Side::Main)).unwrap(); + assert_eq!(doc.node(r).unwrap().fills, main.node(r).unwrap().fills); +} + +#[test] +fn other_lists_still_conflict_as_a_whole() { + // Docs silent: lists other than fills, strokes and effects (export settings here) still + // conflict whole when both sides change them. + let (mut base, _, _, r) = files(); + let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }; + base.get_mut(r).unwrap().exports = vec![export("@1"), export("@2")]; + let (mut branch, mut main) = (base.clone(), base.clone()); + branch.set_client_id(base.highest_id_space() + 1); + branch.get_mut(r).unwrap().exports[0] = export("@b"); + main.get_mut(r).unwrap().exports[1] = export("@m"); + let p = plan(&base, &branch, &main).unwrap(); + let keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); + assert_eq!(keys, [format!("{r}/exports")]); +} + +#[test] +fn item_merge_rules() { + let v = |x: Value| x.as_array().unwrap().clone(); + let (o, b, m) = (v(json!([1, 2, 3])), v(json!([9, 2, 3])), v(json!([1, 2, 8]))); + assert_eq!(merge_items(&o, &b, &m), Some(v(json!([9, 2, 8])))); + assert_eq!(merge_items(&o, &b, &b), Some(b.clone()), "the same change on both sides"); + assert_eq!(merge_items(&o, &b, &v(json!([7, 2, 3]))), None, "the same item both ways"); + assert_eq!(merge_items(&o, &v(json!([1, 2])), &m), None, "an item removed"); + assert_eq!(merge_items(&o, &v(json!([1, 2, 3, 4])), &m), Some(v(json!([1, 2, 8, 4])))); + assert_eq!(merge_items(&o, &v(json!([1, 2, 3, 4])), &v(json!([1, 2, 3, 5]))), None); + assert_eq!(merge_items(&[], &v(json!([1])), &[]), Some(v(json!([1])))); +} diff --git a/crates/engine/src/branch_tests.rs b/crates/engine/src/branch_tests.rs index e6a2286..1c2cc76 100644 --- a/crates/engine/src/branch_tests.rs +++ b/crates/engine/src/branch_tests.rs @@ -158,8 +158,6 @@ fn branch_tabs_save_with_the_main_file_and_come_back() { let path = dir.join("file.figcraft").to_string_lossy().to_string(); let (mut s, id, r) = branched(); ok(&mut s, "node.set", json!({"id": r[0], "props": {"name": "Branch edit"}})); - // Versions belong to the main file. - assert!(s.execute("file.saveVersion", &json!({})).is_err()); // Saving on the branch tab saves the main file, branch included. ok(&mut s, "file.saveAs", json!({"path": path})); assert_eq!(s.docs[0].path.as_deref(), Some(path.as_str())); diff --git a/crates/engine/src/branch_versions_tests.rs b/crates/engine/src/branch_versions_tests.rs new file mode 100644 index 0000000..30bdf73 --- /dev/null +++ b/crates/engine/src/branch_versions_tests.rs @@ -0,0 +1,126 @@ +//! A branch's own version history: versions saved on a branch tab go into the branch (not the +//! main file's history), are viewed and restored there, are saved in the file with the branch and +//! stay with it when it is merged or archived; merging adds a version to the main file's history. +//! Also fills merging item by item through real commands. + +use figcraft_doc::{NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::collab_tests::base; + +fn ok(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn names(s: &mut Session) -> Vec { + ok(s, "version.list", json!({})).as_array().unwrap().iter().map(|v| v["name"].as_str().unwrap().to_string()).collect() +} + +fn layer_name(s: &Session, id: NodeId) -> String { + s.doc().unwrap().node(id).unwrap().name.clone() +} + +/// The base file with one version saved on main and a branch "Explore" shown: (session, branch +/// id, a rectangle). +fn branched() -> (Session, String, NodeId) { + let mut s = Session::with_document(base()); + let r = s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Rectangle)).unwrap().id; + ok(&mut s, "file.saveVersion", json!({"name": "Main v1"})); + let id = ok(&mut s, "branch.create", json!({"name": "Explore"}))["id"].as_str().unwrap().to_string(); + (s, id, r) +} + +#[test] +fn versions_saved_on_a_branch_are_the_branchs_own() { + let (mut s, _, r) = branched(); + let original = layer_name(&s, r); + // A new branch's history starts empty: the main file's versions stay with it. + assert!(names(&mut s).is_empty()); + ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Before"}})); + let v = ok(&mut s, "file.saveVersion", json!({"name": "Branch v1"}))["id"].clone(); + ok(&mut s, "node.set", json!({"id": r, "props": {"name": "After"}})); + assert_eq!(names(&mut s), ["Branch v1"]); + // Viewing a branch version shows it read-only; going back shows the branch as it is. + ok(&mut s, "version.preview", json!({"id": v})); + assert_eq!(layer_name(&s, r), "Before"); + assert!(s.execute("node.set", &json!({"id": r, "props": {"name": "No"}})).is_err()); + ok(&mut s, "version.exitPreview", json!({})); + assert_eq!(layer_name(&s, r), "After"); + // Restoring is one undo step on the branch. + ok(&mut s, "version.restore", json!({"id": v})); + assert_eq!(layer_name(&s, r), "Before"); + ok(&mut s, "edit.undo", json!({})); + assert_eq!(layer_name(&s, r), "After"); + ok(&mut s, "edit.redo", json!({})); + // Renaming works on the branch's history; main's history is untouched. + ok(&mut s, "version.rename", json!({"id": v, "name": "Branch v1 (renamed)"})); + ok(&mut s, "doc.switch", json!({"index": 0})); + assert_eq!(names(&mut s), ["Main v1"]); + assert_eq!(layer_name(&s, r), original, "restoring on the branch leaves main alone"); +} + +#[test] +fn branch_versions_save_with_the_file_and_survive_merging() { + let dir = std::env::temp_dir().join(format!("figcraft-branch-versions-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("file.figcraft").to_string_lossy().to_string(); + let (mut s, id, r) = branched(); + ok(&mut s, "node.set", json!({"id": r, "props": {"name": "On the branch"}})); + ok(&mut s, "file.saveVersion", json!({"name": "Branch v1"})); + // An autosave on a branch tab goes into the branch's history too. + ok(&mut s, "node.set", json!({"id": r, "props": {"name": "On the branch, later"}})); + assert_eq!(ok(&mut s, "version.autosave", json!({}))["saved"], true); + ok(&mut s, "doc.switch", json!({"index": 0})); + ok(&mut s, "file.saveAs", json!({"path": path})); + let mut t = Session::empty(); + ok(&mut t, "file.open", json!({"path": path})); + assert_eq!(names(&mut t), ["Main v1"]); + ok(&mut t, "branch.open", json!({"id": id})); + assert_eq!(names(&mut t), ["Branch v1", "Autosave"]); + // Merging adds a version to the main file's history; the branch keeps its own. + ok(&mut t, "branch.merge", json!({})); + assert_eq!(names(&mut t), ["Main v1", "Autosave", "Merged “Explore”"]); + assert_eq!(layer_name(&t, r), "On the branch, later"); + ok(&mut t, "branch.open", json!({"id": id})); + assert_eq!(names(&mut t), ["Branch v1", "Autosave"]); + std::fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn archiving_keeps_the_branch_history() { + let (mut s, id, _) = branched(); + ok(&mut s, "file.saveVersion", json!({"name": "Branch v1"})); + ok(&mut s, "branch.archive", json!({})); + assert_eq!(names(&mut s), ["Main v1"]); + ok(&mut s, "branch.open", json!({"id": id})); + assert_eq!(names(&mut s), ["Branch v1"]); +} + +#[test] +fn different_fills_of_a_layer_merge_without_a_conflict() { + // Main gives a rectangle a second fill before branching; the branch recolours the first and + // main the second: no conflict, both kept. Recolouring the same fill both ways conflicts. + let mut s = Session::with_document(base()); + let r = s.doc().unwrap().nodes().find(|n| matches!(n.kind, NodeKind::Rectangle)).unwrap().id; + ok(&mut s, "fill.add", json!({"ids": [r]})); + let id = ok(&mut s, "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + ok(&mut s, "fill.set", json!({"ids": [r], "index": 0, "color": "#FF0000"})); + ok(&mut s, "doc.switch", json!({"index": 0})); + ok(&mut s, "fill.set", json!({"ids": [r], "index": 1, "color": "#0000FF"})); + assert_eq!(ok(&mut s, "branch.review", json!({"id": id}))["conflicts"], json!([])); + ok(&mut s, "branch.merge", json!({"id": id})); + let fills = serde_json::to_string(&s.doc().unwrap().node(r).unwrap().fills).unwrap(); + let mut t = Session::with_document(s.doc().unwrap().clone()); + ok(&mut t, "fill.set", json!({"ids": [r], "index": 0, "color": "#FF0000"})); + ok(&mut t, "fill.set", json!({"ids": [r], "index": 1, "color": "#0000FF"})); + assert_eq!(fills, serde_json::to_string(&t.doc().unwrap().node(r).unwrap().fills).unwrap(), "both recolours kept"); + + let id = ok(&mut s, "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + ok(&mut s, "fill.set", json!({"ids": [r], "index": 1, "color": "#00FF00"})); + ok(&mut s, "doc.switch", json!({"index": 0})); + ok(&mut s, "fill.set", json!({"ids": [r], "index": 1, "color": "#FFFF00"})); + let conflicts = ok(&mut s, "branch.review", json!({"id": id}))["conflicts"].clone(); + assert_eq!(conflicts.as_array().unwrap().len(), 1, "{conflicts}"); + assert_eq!(conflicts[0]["key"], format!("{r}/fills")); +} diff --git a/crates/engine/src/branches.rs b/crates/engine/src/branches.rs index 6383345..75065b8 100644 --- a/crates/engine/src/branches.rs +++ b/crates/engine/src/branches.rs @@ -2,7 +2,8 @@ //! against the main file and merged back. A branch remembers the main document as it was when //! the branch was made or last updated from main (its base); [`figcraft_collab::merge`] compares //! both sides with it. Branches are saved in the main file ([`figcraft_format::Branch`]); a -//! branch tab's edits go into its branch ([`sync`]) and are saved with the main file. +//! branch tab's edits and its own version history go into its branch ([`sync`]) and are saved with +//! the main file. Merging adds a version to the main file's history; the branch keeps its own. use std::collections::BTreeMap; use std::sync::Arc; @@ -136,13 +137,21 @@ pub(crate) fn sync(s: &mut Session) { if !s.docs.iter().any(|d| d.branch_of.is_some()) { return; } - let tabs: Vec<(u64, String, Arc)> = - s.docs.iter().filter_map(|d| d.branch_of.clone().map(|(m, id)| (m, id, d.document_itself().clone()))).collect(); - for (main, id, doc) in tabs { + let mut tabs = Vec::new(); + for d in s.docs.iter_mut() { + let Some((m, id)) = d.branch_of.clone() else { continue }; + // Versions saved, renamed or deleted on the tab since the last sync. + let versions = std::mem::take(&mut d.versions_changed).then(|| d.versions.clone()); + tabs.push((m, id, d.document_itself().clone(), versions)); + } + for (main, id, doc, versions) in tabs { let Some(ds) = s.docs.iter_mut().find(|d| d.uid == main) else { continue }; - let Some(i) = ds.branches.iter().position(|b| b.id == id && !Arc::ptr_eq(&b.head, &doc)) else { continue }; + let Some(i) = ds.branches.iter().position(|b| b.id == id && (versions.is_some() || !Arc::ptr_eq(&b.head, &doc))) else { continue }; if let Some(b) = ds.branches_mut().get_mut(i) { b.head = doc; + if let Some(v) = versions { + b.versions = v; + } } } } @@ -232,6 +241,7 @@ fn create(s: &mut Session, p: &Value) -> Result { state: BranchState::Active, base, head: Arc::new(head), + versions: Vec::new(), }); let index = open_tab(s, uid, &id)?; Ok(json!({"id": id, "name": name, "index": index})) @@ -248,7 +258,8 @@ fn open_tab(s: &mut Session, main: u64, id: &str) -> Result { let mut doc = (*b.head).clone(); // A branch read from a file has no client id yet. doc.set_client_id(b.client); - let mut tab = DocState::new(doc, None); + // The branch's own version history: the tab's versions while it is open ([`sync`]). + let mut tab = DocState::new(doc, None).with_versions(b.versions.clone()); tab.branch_of = Some((main, id.to_string())); // The same content: opening isn't an edit of the main file. b.head = tab.doc.clone(); @@ -331,7 +342,8 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { if from == Side::Branch { let m = s.docs.get_mut(mi).ok_or(EngineError::NoDocument)?; let (selection, page) = (m.selection.clone(), m.page); - // The main file's history keeps it as it was before the merge, then the merge itself. + // The main file's history keeps it as it was before the merge, then the merge itself; the + // branch keeps its own history. crate::cmd::file::keep_in_history(m); show(m, doc); let name = m.branches.get(bi).map(|b| b.name.clone()).unwrap_or_default(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index af33d4e..75aa967 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -121,7 +121,7 @@ pub fn specs() -> Vec { spec!( "file.saveVersion", "Save to version history", - "{name?, description?} snapshot the document into the file's version history (kept when the file is saved); returns the version", + "{name?, description?} snapshot the document into the file's version history (on a branch tab: the branch's own history; kept when the file is saved); returns the version", false, save_version ), @@ -252,8 +252,7 @@ pub const MAX_AUTOSAVES: usize = 20; /// `version.autosave`: a checkpoint of a document with unsaved changes since the last version. fn autosave(s: &mut Session, _: &Value) -> Result { let ds = s.ds_mut()?; - // A branch's versions would have nowhere to be saved. - if ds.preview.is_some() || ds.branch_of.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() { + if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() { return Ok(json!({"saved": false})); } Ok(match add_version(ds, AUTOSAVE, "", true) { @@ -348,10 +347,14 @@ pub(crate) fn unique_id(taken: impl Fn(&str) -> bool) -> String { } } +/// `file.saveVersion`; on a branch tab the version goes into the branch's own history. fn save_version(s: &mut Session, p: &Value) -> Result { let ds = s.ds_mut()?; - if ds.branch_of.is_some() { - return Err(EngineError::Disabled("file.saveVersion".into(), "versions are saved on the main file, not on a branch".into())); + if ds.versions.len() >= figcraft_format::MAX_VERSIONS { + return Err(EngineError::Disabled( + "file.saveVersion".into(), + format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), + )); } let name = string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Version {}", ds.versions.len() + 1)); diff --git a/crates/engine/src/history_behaviour_tests.rs b/crates/engine/src/history_behaviour_tests.rs index 6bc789d..e20f1bd 100644 --- a/crates/engine/src/history_behaviour_tests.rs +++ b/crates/engine/src/history_behaviour_tests.rs @@ -545,7 +545,9 @@ fn branches_and_the_version_history() { let b = ex(&mut s, "branch.create", json!({"name": "Explore"})); let branch = b["id"].as_str().unwrap().to_string(); // On the branch tab. - assert!(s.execute("file.saveVersion", &json!({})).is_err(), "not yet: a branch has no version history of its own (gap 17)"); + // A branch keeps its own version history: a version saved on its tab is listed there only. + ex(&mut s, "file.saveVersion", json!({"name": "On the branch"})); + assert_eq!(versions(&mut s).iter().map(|v| v["name"].clone()).collect::>(), [json!("On the branch")], "a branch's own history"); rect(&mut s, 900.0, 900.0, None); ex(&mut s, "doc.switch", json!({"index": 0})); let made_on_main = rect(&mut s, -500.0, 0.0, None); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d6e1a09..9d9c362 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1249,6 +1249,8 @@ mod booleans_tests; #[cfg(test)] mod branch_tests; #[cfg(test)] +mod branch_versions_tests; +#[cfg(test)] mod brush_tests; #[cfg(test)] mod canvas_fixes_tests; diff --git a/crates/format/src/branch_tests.rs b/crates/format/src/branch_tests.rs index aaafbff..28b9aac 100644 --- a/crates/format/src/branch_tests.rs +++ b/crates/format/src/branch_tests.rs @@ -26,6 +26,7 @@ fn branch(base: &Document) -> Branch { client: 3, state: BranchState::Merged, base: Arc::new(base.clone()), + versions: vec![Version::new("v1", "On its own", "", 9, Arc::new(base.clone()))], head: Arc::new(head), } } @@ -41,6 +42,11 @@ fn branches_round_trip_with_their_images() { assert_eq!((got.id.as_str(), got.name.as_str(), got.time, got.client, got.state), ("b1", "Explore", 7, 3, BranchState::Merged)); assert_eq!(*got.base, *b.base); assert_eq!(*got.head, *b.head); + // Its own version history comes back with it. + let v = got.versions.first().unwrap(); + assert_eq!((got.versions.len(), v.id.as_str(), v.name.as_str(), v.time), (1, "v1", "On its own", 9)); + assert_eq!(*v.document().unwrap(), *b.base); + assert!(l.versions.is_empty(), "the branch's versions aren't the file's"); // The image only the branch uses stays out of the document. assert!(l.doc.images.is_empty() && got.head.images.len() == 1); // Duplicate ids are refused when saving. diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 48d8bca..9b3bd01 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -7,7 +7,8 @@ //! name, description, time, images}]}`, oldest first) plus one `versions/.json` snapshot per //! version (a `document.json` of its own). Branches are kept the same way: `branches/index.json` //! (`{"branches": [{id, name, time, client, state, baseImages, headImages}]}`) plus -//! `branches//base.json` and `branches//head.json`. A published file also holds its +//! `branches//base.json` and `branches//head.json`; a branch's own version history is +//! kept like the file's, under `branches//versions/`. A published file also holds its //! library (see [`library`]). Readers that don't know about versions, branches or libraries //! ignore those entries. Plain //! JSON (the `document.json` alone) also opens. Reading is lenient: structural problems are @@ -45,7 +46,6 @@ pub const MAX_VERSION_DESCRIPTION: usize = 2000; /// Largest version index and total size of the version snapshots read from one file. const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024; const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024; -const VERSION_INDEX: &str = "versions/index.json"; const BRANCH_INDEX: &str = "branches/index.json"; /// Most branches a file keeps: making more is refused, extra entries in a file are skipped. pub const MAX_BRANCHES: usize = 100; @@ -156,6 +156,8 @@ pub struct Branch { pub state: BranchState, pub base: Arc, pub head: Arc, + /// The branch's own version history, oldest first. + pub versions: Vec, } /// Whether `id` can name a version (and its archive entry). @@ -363,7 +365,7 @@ pub fn save_bytes_with_history( put_json(&mut z, "document.json", doc)?; // Every image once, whether the document, a version or a branch uses it. let mut written = BTreeSet::new(); - let branch_images = branches.iter().flat_map(|b| [&b.base.images, &b.head.images]); + let branch_images = branches.iter().flat_map(|b| [&b.base.images, &b.head.images].into_iter().chain(b.versions.iter().map(Version::images))); for (hash, img) in std::iter::once(&doc.images).chain(versions.iter().map(Version::images)).chain(branch_images).flatten() { if written.insert(hash) { put(&mut z, &format!("images/{hash}"), stored, &img.bytes)?; @@ -372,27 +374,7 @@ pub fn save_bytes_with_history( if let Some(t) = thumbnail_png { put(&mut z, "thumbnail.png", stored, t)?; } - if !versions.is_empty() { - let mut ids = BTreeSet::new(); - let mut index = Vec::with_capacity(versions.len()); - for v in versions { - if !is_valid_version_id(&v.id) || !ids.insert(v.id.as_str()) { - return Err(FormatError::Io(format!("invalid or duplicate version id `{}`", v.id))); - } - let name = format!("versions/{}.json", v.id); - match &v.snapshot { - Snapshot::Doc(d) => put_json(&mut z, &name, d)?, - Snapshot::Stored { json, .. } => put(&mut z, &name, json_options(), json)?, - } - let images: Vec<&String> = v.images().keys().collect(); - let mut entry = json!({"id": v.id, "name": v.name, "description": v.description, "time": v.time, "images": images}); - if v.auto { - entry["auto"] = json!(true); - } - index.push(entry); - } - put(&mut z, VERSION_INDEX, json_options(), &serde_json::to_vec(&json!({"versions": index})).map_err(io_err)?)?; - } + put_versions(&mut z, "versions", versions)?; if !branches.is_empty() { let mut ids = BTreeSet::new(); let mut index = Vec::with_capacity(branches.len()); @@ -402,6 +384,7 @@ pub fn save_bytes_with_history( } put_json(&mut z, &format!("branches/{}/base.json", b.id), &b.base)?; put_json(&mut z, &format!("branches/{}/head.json", b.id), &b.head)?; + put_versions(&mut z, &format!("branches/{}/versions", b.id), &b.versions)?; let images = |d: &Document| d.images.keys().cloned().collect::>(); index.push(json!({"id": b.id, "name": b.name, "time": b.time, "client": b.client, "state": b.state, "baseImages": images(&b.base), "headImages": images(&b.head)})); @@ -416,6 +399,34 @@ pub fn save_bytes_with_history( Ok(buf.into_inner()) } +/// Write version history `versions` (oldest first) under `dir`: `/index.json` and one +/// `/.json` snapshot per version. Nothing when there are none. +fn put_versions(z: &mut zip::ZipWriter, dir: &str, versions: &[Version]) -> Result<(), FormatError> { + if versions.is_empty() { + return Ok(()); + } + let mut ids = BTreeSet::new(); + let mut index = Vec::with_capacity(versions.len()); + for v in versions { + if !is_valid_version_id(&v.id) || !ids.insert(v.id.as_str()) { + return Err(FormatError::Io(format!("invalid or duplicate version id `{}`", v.id))); + } + let name = format!("{dir}/{}.json", v.id); + match &v.snapshot { + Snapshot::Doc(d) => put_json(z, &name, d)?, + Snapshot::Stored { json, .. } => put(z, &name, json_options(), json)?, + } + let images: Vec<&String> = v.images().keys().collect(); + let mut entry = json!({"id": v.id, "name": v.name, "description": v.description, "time": v.time, "images": images}); + if v.auto { + entry["auto"] = json!(true); + } + index.push(entry); + } + put(z, &format!("{dir}/index.json"), json_options(), &serde_json::to_vec(&json!({"versions": index})).map_err(io_err)?)?; + Ok(()) +} + /// Read a `.figcraft` archive (or bare JSON) from bytes. Returns the document and any repairs made. pub fn load_bytes(bytes: &[u8]) -> Result<(Document, Vec), FormatError> { load_bytes_with_versions(bytes).map(|l| (l.doc, l.issues)) @@ -458,10 +469,12 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { Ok(lib) => doc.published = lib.map(Arc::new), Err(e) => issues.push(format!("the published library was skipped: {e}")), } - let versions = read_versions(&mut z, &images, &mut issues); + let mut budget = MAX_VERSION_BYTES; + let versions = read_versions(&mut z, "versions", &images, &mut issues, &mut budget); let branches = read_branches(&mut z, &images, &mut issues); // Images only the version history or branches use stay with them. - let branch_images = branches.iter().flat_map(|b| b.base.images.keys().chain(b.head.images.keys())); + let branch_images = + branches.iter().flat_map(|b| b.base.images.keys().chain(b.head.images.keys()).chain(b.versions.iter().flat_map(|v| v.images().keys()))); let history_only: BTreeSet<&String> = versions .iter() .flat_map(|v| v.images().keys()) @@ -472,13 +485,21 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result { Ok(Loaded { doc, issues, versions, branches }) } -/// The version history of an archive (none when it has no index). Damaged entries are skipped and -/// reported in `issues`; nothing here fails the load. -fn read_versions(z: &mut zip::ZipArchive, images: &BTreeMap, issues: &mut Vec) -> Vec { - if z.index_for_name(VERSION_INDEX).is_none() { +/// The version history kept under `dir` in an archive (none when it has no index), reading at +/// most `budget` bytes of snapshots. Damaged entries are skipped and reported in `issues`; nothing +/// here fails the load. +fn read_versions( + z: &mut zip::ZipArchive, + dir: &str, + images: &BTreeMap, + issues: &mut Vec, + budget: &mut u64, +) -> Vec { + let index_name = format!("{dir}/index.json"); + if z.index_for_name(&index_name).is_none() { return Vec::new(); } - let index = match read_member(z, VERSION_INDEX, MAX_VERSION_INDEX) + let index = match read_member(z, &index_name, MAX_VERSION_INDEX) .and_then(|b| serde_json::from_slice::(&b).map_err(|e| FormatError::Corrupt(e.to_string()))) { Ok(v) => v, @@ -489,7 +510,6 @@ fn read_versions(z: &mut zip::ZipArchive, images: &BTreeMap = Vec::new(); - let mut budget = MAX_VERSION_BYTES; for e in entries { if out.len() >= MAX_VERSIONS { issues.push(format!("only the first {MAX_VERSIONS} versions were kept")); @@ -500,14 +520,14 @@ fn read_versions(z: &mut zip::ZipArchive, images: &BTreeMap j, Err(err) => { issues.push(format!("version `{id}` was skipped: {err}")); continue; } }; - budget = budget.saturating_sub(json.len() as u64); + *budget = budget.saturating_sub(json.len() as u64); let text = |k: &str, max: usize| e.get(k).and_then(Value::as_str).map(|s| clip(s, max)).unwrap_or_default(); let images = e .get("images") @@ -578,6 +598,7 @@ fn read_branches(z: &mut zip::ZipArchive, images: &BTreeMap **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -8,7 +8,7 @@ server ordering, client reconciliation, convergence tests), and live sessions (p several apps co-edit a file hosted by one desktop app or kept on a self-hosted `figcraft-server` with share links to edit or view, joined from the desktop app or the browser, with others' cursors, selections and text carets shown live ([`collab-protocol.md`](collab-protocol.md), [`server.md`](server.md)). Remaining: 55–110 h -(multiplayer 45–90 h, shared libraries 10–20 h); branching landed (its tail: 1–3 h). +(multiplayer 45–90 h, shared libraries 10–20 h); branching landed (its tail: 1–2 h). | Feature | Figma | FigCraft | Hours | |---|---|---|---| @@ -18,7 +18,7 @@ cursors, selections and text carets shown live ([`collab-protocol.md`](collab-pr | Share links, permissions (view / edit), teams and projects | yes | **partial**: `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. No accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | -| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived), update from main, archive. Left: a branch's own version history, list properties (fills, text runs) merged item by item, branches in live sessions | 1–3 | +| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 | | Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 | | Notifications, activity | yes | no | with sharing | @@ -26,6 +26,7 @@ cursors, selections and text carets shown live ([`collab-protocol.md`](collab-pr | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | | 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions; percentages and hours unchanged here (comments and versions are graded with feature area 16 in target-app-parity) | | 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | | 2026-10-11 | minor | Team libraries on `figcraft-server` (gap 10): a file's library is published to the server under the file and enabled in other files by a share link, updates are noticed in the background and accepted with overrides kept, the server counts which files use which components, and a library can be swapped for another by names; team libraries 10–20 h → 4–8 h; ready ~12% → ~13% | diff --git a/docs/gaps.md b/docs/gaps.md index 7682cce..1d86ce3 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; 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 @@ -30,7 +30,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 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: 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) | +| 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | | 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | | 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | | 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) | @@ -248,9 +248,18 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe one undo step on the main file and archives the branch; Update from main brings main's changes into the branch the same way; branches can be archived without merging. Three-way merge over the collaboration change sets (`figcraft_collab::merge`), fuzz-tested with random real commands. -- **Missing:** a branch's own version history; a list property (fills, text runs…) changed on both - sides conflicts as a whole; branches of a file in a live session are refused. -- **Estimate:** 1–3 h. +- **Landed (2026-10-12):** a branch's own version history: versions saved on a branch tab (by hand + or autosaved) go into the branch, not the main file, and are viewed, restored (one undo step on + the branch), renamed, deleted and duplicated there; they are saved in the file with the branch. + Merging adds a version to the main file's history (Merged "name"); the merged or archived + branch keeps its own history and opens with it. Fills, strokes and effects merge item by item: + different items changed on each side, or items added at the end on one side, merge without a + conflict; only the same item changed both ways, an item removed on one side, or different items + added on both sides conflict (as the whole list). +- **Missing:** branches of a file in a live session are refused; other lists (text runs, export + settings, guides…) still conflict as a whole; the branch history has no entries of its own for + its creation or updates from main (only saved versions, autosaves and the merge on main). +- **Estimate:** 1–2 h. ### 18. Display P3 on screen (several windows landed) - **Landed (2026-10-11):** several windows: File ▸ New window, a tab's Open in new window or a tab @@ -294,6 +303,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | | 2026-10-12 | minor | Comments and version history behaviour suite and fixes added to gap 4's landed list (gap 17 unchanged: a branch's own version history is still left); ranking and hours unchanged | | 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | | 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Gap 1 30–60 h → 8–15 h (vector networks, other layer kinds, real-file checks; export 15–30 h on top, as before) | diff --git a/docs/mcp.md b/docs/mcp.md index 0929106..0960a1f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -752,12 +752,15 @@ branches). Driven with `run_command`: name, property, branch, main, deletedBy?}]}`. `layers` are the changed layers with their property changes formatted like Inspect (`fill #2563EB → #DC2626`); `document` lists styles, variables, comments and file settings. A conflict is the same property changed on both sides to different - values (key `/`, e.g. `4/fills`; a layer's parent and position are one + values (key `/`, e.g. `4/fills`; fills, strokes and effects compare item by + item, so changing different items of one list on each side, or adding items at its end on one + side, merges without a conflict; a layer's parent and position are one property, `parent`), a layer deleted on one side while the other edited it or what's inside it (`/layer`, with `deletedBy`), or a document-level entry or setting changed on both sides (`style/`, `variable/`, `comment/`, `doc/`…). - `branch.merge {id?, resolutions?}` merges the branch into the main file as one undo step, - archives it (`state: merged`), closes its tab and shows the main file. `resolutions` maps every + archives it (`state: merged`), closes its tab and shows the main file; the main file's version + history gets a version for the merge (`Merged “”`, after an autosave of main as it was when it had changed). `resolutions` maps every conflict key to `"branch"` or `"main"`; the merge is refused while a conflict has none, so neither side's work is dropped without a choice. - `branch.updateFromMain {id?, resolutions?}` brings the main file's changes since the base into the @@ -766,8 +769,10 @@ branches). Driven with `run_command`: - `branch.archive {id?}` sets a branch aside without merging (it stays in the file and can still be opened, but not merged). -Versions are saved on the main file (`file.saveVersion` is refused on a branch tab), and branches -aren't available in a live session. +Each branch keeps its own version history: on a branch tab, `file.saveVersion`, `version.autosave` +and the `version.*` commands (list, preview, restore, rename, delete, duplicate) act on the +branch's versions, which are saved in the file with the branch and stay with it when it is merged +or archived. Branches aren't available in a live session. ### Collaboration changes diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index aca6d35..3fc6699 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; 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 ~80%; ready ~49%; 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-12 · **Change:** minor (branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; 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 ~80%; ready ~49%; 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 @@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) | | Mainstream practitioner | **~50%** | estimated ([below](#mainstream-practitioner): weekly areas 72.3% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~170–305 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~304–591 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~303–590 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -109,20 +109,20 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 13 | Prototyping | 6 | 90% | 74% | Video triggers and media actions, gamepad triggers, delays on mouse triggers, manual overlay offsets, real prototype QA (behaviour suite from the documentation, key combinations, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 3–6 | | 14 | Import/export | 6 | 84% | 57% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | | 15 | Dev Mode / inspect | 2 | 93% | 70% | Dev resources links, import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect and plugins in Dev Mode landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–3 | -| 16 | Comments, versions, find, misc | 3 | 85% | 62% | Comments are local only; a branch's own version history; comparing two versions (branches with review and merge, and a comments and version history behaviour suite from the documentation landed) | 2–4 | +| 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 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 ([collaboration-parity](collaboration-parity.md)) | 45–90 | | 18 | Plugins & widgets | 4 | 45% | 28% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples), then plugin panels called back per button or changed value and per-plugin settings (ABI v2). Left: distribution, widgets; panels one at a time without a colour picker; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 15–40 | | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~84%** | **~63%** | | **146–305** | -| | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 44–91 | +| | **Weighted (÷113)** | 113 | **~84%** | **~63%** | | **145–304** | +| | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 43–90 | ## By dimension (ready for real work) | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 61% | 44–90 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file | +| Features (depth) | 35 | 61% | 43–89 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | 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 | 59% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [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) | @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~51%** | **~304–591** | | | +| **Ready for real work** | 100 | **~51%** | **~303–590** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -612,6 +612,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | | 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) | | 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h | | 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, find, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h; mainstream weekly areas 71.0% → 71.1%; percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h |