M8: branch version history and finer list merges

- A 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; the main file's history is untouched. The Version history panel on a branch tab lists the branch's versions (its banner gives way to the version banner while one is viewed).
- Branch versions are saved in the `.figcraft` file with the branch (`branches/<id>/versions/index.json` plus one snapshot each, the same layout as the file's own history; images shared; damaged entries skipped and reported; the snapshot byte budget is shared across branches).
- Merging adds a version to the main file's history (`Merged "<name>"`, after an autosave of main when it had changed); the merged or archived branch keeps its own history and opens with it.
- Fills, strokes and effects merge item by item in `figcraft_collab::merge`: different items changed on each side, or items added at the end on one side (or the same ones on both), merge without a conflict; the same item changed both ways, an item removed on one side, or different items added on both sides still conflict as the whole list. Both sides' writes become the merged list, so merging and Update from main write the same result.
This commit is contained in:
bflatastic 2026-10-12 02:04:08 +07:00 committed by BFlat
parent 970eb001f1
commit c50bbd08bd
18 changed files with 470 additions and 80 deletions

View File

@ -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 “<branch>”` 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 |

View File

@ -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 {

View File

@ -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;

View File

@ -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<Key, Vec<usize>> {
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<Json> {
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<Vec<Value>> {
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<Plan,
let base_r = Replica::new(base.clone());
let b = diff(&base_r, branch)?;
let m = diff(&base_r, main)?;
let bc: Vec<Change> = b.changes.into_iter().filter(merges).collect();
let mc: Vec<Change> = m.changes.into_iter().filter(merges).collect();
let mut bc: Vec<Change> = b.changes.into_iter().filter(merges).collect();
let mut mc: Vec<Change> = 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<Plan,
// out once the plan can tell followed values from overrides.
let Some(mi) = km.get(&k) else { continue };
let same = bi.len() == mi.len() && bi.iter().zip(mi).all(|(x, y)| bc.get(*x) == mc.get(*y));
if !same {
conflicts.push(Conflict { key: k, deleted_by: None, branch: bi, main: mi.clone() });
if same {
continue;
}
match merged_list(base, &k, only(&bi, &bc), only(mi, &mc)) {
Some(value) => 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)] {

View File

@ -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<String> = 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<String> = 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]))));
}

View File

@ -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()));

View File

@ -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<String> {
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"));
}

View File

@ -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<Document>)> =
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<Value> {
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<usize> {
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<Value> {
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();

View File

@ -121,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
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));

View File

@ -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::<Vec<_>>(), [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);

View File

@ -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;

View File

@ -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.

View File

@ -7,7 +7,8 @@
//! name, description, time, images}]}`, oldest first) plus one `versions/<id>.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/<id>/base.json` and `branches/<id>/head.json`. A published file also holds its
//! `branches/<id>/base.json` and `branches/<id>/head.json`; a branch's own version history is
//! kept like the file's, under `branches/<id>/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<Document>,
pub head: Arc<Document>,
/// The branch's own version history, oldest first.
pub versions: Vec<Version>,
}
/// 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::<Vec<String>>();
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`: `<dir>/index.json` and one
/// `<dir>/<id>.json` snapshot per version. Nothing when there are none.
fn put_versions<W: Write + Seek>(z: &mut zip::ZipWriter<W>, 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<String>), 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<Loaded, FormatError> {
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<Loaded, FormatError> {
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<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<String, ImageData>, issues: &mut Vec<String>) -> Vec<Version> {
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<R: Read + Seek>(
z: &mut zip::ZipArchive<R>,
dir: &str,
images: &BTreeMap<String, ImageData>,
issues: &mut Vec<String>,
budget: &mut u64,
) -> Vec<Version> {
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::<Value>(&b).map_err(|e| FormatError::Corrupt(e.to_string())))
{
Ok(v) => v,
@ -489,7 +510,6 @@ fn read_versions<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
};
let entries = index.get("versions").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
let mut out: Vec<Version> = 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<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
issues.push("a version with an invalid id was skipped".into());
continue;
}
let json = match read_member(z, &format!("versions/{id}.json"), budget.min(MAX_MEMBER)) {
let json = match read_member(z, &format!("{dir}/{id}.json"), (*budget).min(MAX_MEMBER)) {
Ok(j) => 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<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
time: e.get("time").and_then(Value::as_u64).unwrap_or(0),
client: e.get("client").and_then(Value::as_u64).and_then(|c| u32::try_from(c).ok()).unwrap_or(0),
state: e.get("state").and_then(|s| BranchState::deserialize(s).ok()).unwrap_or(BranchState::Active),
versions: read_versions(z, &format!("branches/{id}/versions"), images, issues, &mut budget),
base,
head,
});

View File

@ -67,7 +67,8 @@ pub fn keys(app: &mut FigApp, ctx: &egui::Context) {
pub fn banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
let Ok(ds) = app.session.ds() else { return };
let Some((main, id)) = ds.branch_of.clone() else { return };
if ds.focus.is_some() || app.ui.branch_review.is_some() {
// A branch version being viewed shows its own banner in this place.
if ds.focus.is_some() || ds.preview.is_some() || app.ui.branch_review.is_some() {
return;
}
let Some(m) = app.session.docs.iter().find(|d| d.uid == main) else { return };

View File

@ -1,6 +1,6 @@
# Collaboration parity
> **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 “<branch>”` 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% |

View File

@ -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) |

View File

@ -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 `<layer id>/<property>`, e.g. `4/fills`; a layer's parent and position are one
values (key `<layer id>/<property>`, 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 id>/layer`, with `deletedBy`), or a document-level entry or setting changed on both sides
(`style/<id>`, `variable/<id>`, `comment/<id>`, `doc/<property>`…).
- `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 “<name>”`, 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

View File

@ -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 “<branch>”` 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 |