diff --git a/ROADMAP.md b/ROADMAP.md index d7cd3c10..e403b02d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | | 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | | 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h | +| 2026-10-24 | minor | Branches in live sessions (gap 17): create, edit, review, update from main and merge while the file is in a session; the branch stays with its author (ids in their own client space, far above the main file's), merging is one synced edit and one undo step, peers' edits merge against the branch's own base and conflicts resolve as offline, a view link refuses merging, a branch made outside the session that adds layers is refused with a reason; a branch's history gets entries for its creation and updates from main; no wire format change. Left: branches others in the session can see, text runs item by item. Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | diff --git a/crates/engine/src/branch_live_tests.rs b/crates/engine/src/branch_live_tests.rs new file mode 100644 index 00000000..ae1a5c87 --- /dev/null +++ b/crates/engine/src/branch_live_tests.rs @@ -0,0 +1,240 @@ +//! Branches of a file in a live session: the branch stays with its author and makes ids in their +//! own client space; merging is one synced edit (and one step of the author's undo history) that +//! everyone receives; peers' edits on main while the branch is open merge against the branch's own +//! base, and conflicts with them resolve as offline; update from main takes them in; a view link +//! still refuses merging; a branch made outside the session that adds layers is refused instead +//! of being sent for the server to refuse. Plus a fuzz test with random real commands. + +use figcraft_collab::sim::Rng; +use figcraft_doc::ids::space_of; +use figcraft_doc::{Document, NodeId}; +use serde_json::{Map, Value, json}; + +use crate::Session; +use crate::branch_tests::{base_with_lists, random_edit}; +use crate::collab_live_tests::{Room, frame_and_rect}; +use crate::collab_tests::base; +use crate::live::{Access, Event, Role, VIEW_ONLY}; +use crate::live_tests::{info, push, relay, serve_as, session}; + +fn ok(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn me(s: &Session) -> u32 { + s.live().client().unwrap().id() +} + +fn version_names(s: &mut Session) -> Vec { + ok(s, "version.list", json!({})).as_array().unwrap().iter().map(|v| v["name"].as_str().unwrap().to_string()).collect() +} + +/// The conflict keys of branch `id`'s review. +fn conflict_keys(s: &mut Session, id: &str) -> Vec { + let review = ok(s, "branch.review", json!({"id": id})); + review["conflicts"].as_array().unwrap().iter().map(|c| c["key"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn a_branch_merges_into_the_session_as_one_synced_edit() { + let mut room = Room::new(base(), 2, 7); + let (f, r) = frame_and_rect(room.server.document()); + let you = me(room.s(0)); + let id = ok(room.s(0), "branch.create", json!({"name": "Explore"}))["id"].as_str().unwrap().to_string(); + // On the branch tab: a recolour, a new layer and a new style. + ok(room.s(0), "fill.set", json!({"ids": [r], "color": "#FF0000"})); + let made = NodeId(ok(room.s(0), "shape.rectangle", json!({"x": 900, "y": 900, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + assert_eq!(space_of(made.0), you, "the branch makes ids in your own client space"); + ok(room.s(0), "style.create", json!({"type": "paint", "name": "Brand", "from": r})); + room.settle(); + assert!(!room.server.document().contains(made), "the branch stays with its author"); + // Someone else edits the main file meanwhile. + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Renamed by a peer"}})); + room.settle(); + assert_eq!(conflict_keys(room.s(0), &id), Vec::::new()); + assert_eq!(ok(room.s(0), "branch.merge", json!({"id": id}))["merged"], true); + room.settle(); + room.check(); + let doc = room.server.document(); + assert!(doc.contains(made), "everyone has the merge"); + assert_eq!(doc.node(f).unwrap().name, "Renamed by a peer", "the peer's edit stays"); + assert!(doc.styles.iter().any(|st| st.name == "Brand")); + assert_ne!(doc.node(r).unwrap().fills, base().node(r).unwrap().fills); + // The merge is one step of the author's own undo history. + ok(room.s(0), "edit.undo", json!({})); + room.settle(); + room.check(); + assert!(!room.server.document().contains(made)); + assert_eq!(room.server.document().node(f).unwrap().name, "Renamed by a peer"); +} + +#[test] +fn conflicts_with_peers_resolve_as_offline() { + let mut room = Room::new(base(), 2, 3); + let (f, r) = frame_and_rect(room.server.document()); + let r2 = room.server.document().node(f).unwrap().children[1]; + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + ok(room.s(0), "node.set", json!({"id": r, "props": {"name": "Kept on the branch"}})); + ok(room.s(0), "node.set", json!({"id": r2, "props": {"opacity": 0.5}})); + // A peer deletes the layer the branch edited and sets the same property another way. + ok(room.s(1), "edit.delete", json!({"ids": [r]})); + ok(room.s(1), "node.set", json!({"id": r2, "props": {"opacity": 0.2}})); + room.settle(); + let keys = conflict_keys(room.s(0), &id); + let mut want = vec![format!("{r}/layer"), format!("{r2}/opacity")]; + want.sort(); + let mut got = keys.clone(); + got.sort(); + assert_eq!(got, want); + assert!(room.s(0).execute("branch.merge", &json!({"id": id})).is_err(), "conflicts first"); + // The branch's edits win the deletion, main wins the opacity. + let picks: Map = keys.iter().map(|k| (k.clone(), json!(if k.ends_with("/layer") { "branch" } else { "main" }))).collect(); + ok(room.s(0), "branch.merge", json!({"id": id, "resolutions": picks})); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!(doc.node(r).map(|n| n.name.as_str()), Some("Kept on the branch"), "the deleted layer is back, with the branch's edit"); + assert_eq!(doc.node(r2).unwrap().opacity, 0.2); +} + +#[test] +fn update_from_main_takes_in_peers_edits_and_shows_in_the_branch_history() { + let mut room = Room::new(base(), 2, 5); + let (f, r) = frame_and_rect(room.server.document()); + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + ok(room.s(0), "node.set", json!({"id": r, "props": {"name": "Branch"}})); + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Peer 1"}})); + room.settle(); + assert_eq!(ok(room.s(0), "branch.updateFromMain", json!({}))["updated"], true); + assert_eq!(room.s(0).doc().unwrap().node(f).unwrap().name, "Peer 1", "the branch tab has the peer's edit"); + assert_eq!(version_names(room.s(0)), ["Branch created", "Updated from main"]); + // The new base is main as it was then: a later peer edit is main's change, not a conflict. + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Peer 2"}})); + room.settle(); + assert_eq!(conflict_keys(room.s(0), &id), Vec::::new()); + ok(room.s(0), "branch.merge", json!({})); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!((doc.node(f).unwrap().name.as_str(), doc.node(r).unwrap().name.as_str()), ("Peer 2", "Branch")); +} + +#[test] +fn a_view_link_still_refuses_merging() { + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + let mut doc = Document::new("Shared"); + let page = doc.first_page().unwrap(); + let card = doc.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap(); + let (_server, id, welcome) = serve_as(&doc, Access::View); + let mut you = info(Role::Guest, id); + you.access = Access::View; + push(&script, vec![Event::Welcome { info: you, peers: vec![] }, welcome]); + s.live_poll(); + assert_eq!(s.active_read_only(), Some(VIEW_ONLY)); + // The branch is yours to edit; merging it would edit the file you may only view. + ok(&mut s, "branch.create", json!({})); + ok(&mut s, "node.set", json!({"id": card, "props": {"name": "Mine"}})); + ok(&mut s, "branch.updateFromMain", json!({})); + match s.execute("branch.merge", &json!({})) { + Err(crate::EngineError::Disabled(_, why)) => assert_eq!(why, VIEW_ONLY), + other => panic!("expected a refusal, got {other:?}"), + } + assert!(script.lock().unwrap().msgs.is_empty(), "nothing was sent"); +} + +#[test] +fn a_branch_made_outside_the_session_merges_edits_but_not_new_layers() { + let (mut s, script) = session(); + let r = NodeId(ok(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap()); + let edits = ok(&mut s, "branch.create", json!({"name": "Edits"}))["id"].as_str().unwrap().to_string(); + ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Edited before the session"}})); + ok(&mut s, "doc.switch", json!({"index": 0})); + let adds = ok(&mut s, "branch.create", json!({"name": "Adds"}))["id"].as_str().unwrap().to_string(); + ok(&mut s, "shape.ellipse", json!({"x": 90, "y": 0, "width": 20, "height": 20})); + ok(&mut s, "doc.switch", json!({"index": 0})); + s.execute("collab.host", &json!({})).unwrap(); + let (mut server, id, welcome) = serve_as(s.doc().unwrap(), Access::Edit); + push(&script, vec![welcome]); + s.live_poll(); + assert!(s.live().syncing()); + // Its new layer is in another id space: refused here rather than by the server. + match s.execute("branch.merge", &json!({"id": adds})) { + Err(crate::EngineError::Disabled(_, why)) => assert!(why.contains("outside this live session"), "{why}"), + other => panic!("expected a refusal, got {other:?}"), + } + assert_eq!(s.doc().unwrap().node_count(), server.document().node_count(), "nothing merged"); + // Edits of layers everyone has go through. + ok(&mut s, "branch.merge", json!({"id": edits})); + let answers = relay(&script, &mut server, id); + push(&script, answers); + s.live_poll(); + assert_eq!(server.document().node(r).unwrap().name, "Edited before the session"); + assert!(*s.doc().unwrap() == *server.document()); +} + +/// The live fuzz test: three people edit with random real commands (export settings and guides +/// included) while one of them works on a branch; every conflict resolved for a random side, the +/// merge goes through the server as it is and everyone converges on it. +#[test] +fn random_edits_on_a_branch_merge_into_a_live_session() { + let (mut created, mut conflicted) = (0, 0); + for seed in 0..8 { + let mut room = Room::new(base_with_lists(), 3, seed); + let mut rng = Rng::new(seed + 50); + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + for _ in 0..30 { + let i = rng.below(3); + let (cmd, params) = random_edit(room.s(i).doc().unwrap(), &mut rng, true); + let _ = room.s(i).execute(cmd, ¶ms); + if rng.chance(0.3) { + room.settle(); + } + } + room.settle(); + let keys = conflict_keys(room.s(0), &id); + conflicted += usize::from(!keys.is_empty()); + let picks: Map = keys.into_iter().map(|k| (k, json!(if rng.chance(0.5) { "branch" } else { "main" }))).collect(); + let before = room.server.document().clone(); + ok(room.s(0), "branch.merge", json!({"id": id, "resolutions": picks})); + let merged = room.s(0).doc().unwrap().clone(); + assert!(merged.validate().is_empty(), "seed {seed}: {:?}", merged.validate()); + created += usize::from(merged.ids().any(|n| !before.contains(n))); + room.settle(); + room.check(); + assert!(*room.server.document() == merged, "seed {seed}: the server took the merge as it is"); + } + assert!(created >= 3 && conflicted >= 2, "{created} merges added layers, {conflicted} had conflicts"); +} + +/// No branch command panics on junk parameters in a live session, from the main file's tab or +/// the branch's, and the session stays in sync. +#[test] +fn branch_commands_survive_junk_params_in_a_live_session() { + let junk = [ + json!({}), + json!(null), + json!([1, 2]), + json!({"id": 5, "name": 1e308, "resolutions": [1]}), + json!({"id": "nope", "resolutions": {"1/layer": "both", "doc/name": 7}}), + json!({"name": "\u{0}".repeat(500), "resolutions": {"x": "branch"}}), + ]; + let mut room = Room::new(base(), 2, 11); + ok(room.s(0), "branch.create", json!({})); + let ids: Vec<&str> = crate::branches::specs().iter().map(|s| s.id).collect(); + for tab in [0, 1] { + for id in &ids { + for j in &junk { + let s = room.s(0); + if s.docs.len() > tab { + let _ = s.execute("doc.switch", &json!({"index": tab})); + } + if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(id, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + room.settle(); + } + } + } + room.check(); +} diff --git a/crates/engine/src/branch_tests.rs b/crates/engine/src/branch_tests.rs index 1c2cc769..f0e8a72e 100644 --- a/crates/engine/src/branch_tests.rs +++ b/crates/engine/src/branch_tests.rs @@ -1,9 +1,9 @@ //! Branches through commands: create, edit on both sides, review, resolve, merge as one undo step, //! update from main, archive, saving with the file, and a fuzz test over random real commands. -use figcraft_collab::merge::{Side, plan}; +use figcraft_collab::merge::{Key, Side, plan}; use figcraft_collab::sim::Rng; -use figcraft_collab::{Change, DocProp, Mode, Replica, Target}; +use figcraft_collab::{Change, DocProp, Mode, Prop, Replica, Target}; use figcraft_doc::{Document, NodeId, NodeKind}; use serde_json::{Value, json}; @@ -215,26 +215,68 @@ fn shows(changes: &[Change], doc: &Document) -> Result<(), String> { Ok(()) } -/// The fuzz test: random real commands on a branch and on main. Without conflicts the merge (both -/// ways) equals applying both change sets and shows every change of either side; with conflicts, -/// resolving them all for either side gives a valid document. +/// [`base`] with two export settings and two guides on its first frame (lists that merge item by +/// item). +pub(crate) fn base_with_lists() -> Document { + let mut doc = base(); + let f = doc.node(doc.first_page().unwrap()).unwrap().children[0]; + let n = doc.get_mut(f).unwrap(); + n.exports = ["@1", "@2"].map(|suffix| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }).to_vec(); + n.guides = vec![figcraft_doc::Guide { vertical: true, offset: 10.0 }, figcraft_doc::Guide { vertical: false, offset: 20.0 }]; + doc +} + +/// A random real command: often an edit of a frame's export settings or guides, else +/// [`random_command`]. +pub(crate) fn random_edit(doc: &Document, rng: &mut Rng, with_undo: bool) -> (&'static str, Value) { + if rng.chance(0.6) { + return random_command(doc, rng, with_undo); + } + let frames: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Frame)).map(|n| n.id).collect(); + let f = rng.pick(&frames).map_or(json!(0), |i| json!(i)); + let i = rng.below(3); + match rng.below(6) { + 0 => ("export.add", json!({"ids": [f]})), + 1 => ("export.update", json!({"ids": [f], "index": i, "suffix": format!("@{}", rng.below(9))})), + 2 => ("export.remove", json!({"ids": [f], "index": i})), + 3 => ("guide.add", json!({"frame": f, "vertical": rng.chance(0.5), "offset": rng.below(300)})), + 4 => ("guide.move", json!({"frame": f, "index": i, "offset": rng.below(300)})), + _ => ("guide.remove", json!({"frame": f, "index": i})), + } +} + +/// The fuzz test: random real commands on a branch and on main (export settings and guides +/// included). Without conflicts the merge (both ways) equals applying both change sets and shows +/// every change of either side; with conflicts, resolving them all for either side gives a valid +/// document. #[test] fn random_edits_on_both_sides_merge() { - let (mut disjoint, mut conflicted) = (0, 0); + let (mut disjoint, mut conflicted, mut lists) = (0, 0, 0); for seed in 0..60 { let mut rng = Rng::new(seed); - let start = base(); + let start = base_with_lists(); let mut on_branch = start.clone(); on_branch.set_client_id(start.highest_id_space() + 1); let mut sides = [Session::with_document(on_branch), Session::with_document(start.clone())]; for s in &mut sides { for _ in 0..1 + rng.below(5) { - let (cmd, params) = random_command(s.doc().unwrap(), &mut rng, false); + let (cmd, params) = random_edit(s.doc().unwrap(), &mut rng, false); let _ = s.execute(cmd, ¶ms); } } let [b, m] = sides.map(|s| s.doc().unwrap().clone()); let p = plan(&start, &b, &m).unwrap(); + // Export settings or guides of a layer changed on both sides that merged item by item. + let list_writes = |changes: &[Change]| -> std::collections::BTreeSet { + changes + .iter() + .filter_map(|c| match c { + Change::Set { id, prop: prop @ (Prop::Exports | Prop::Guides), .. } => Some(Key::Node(*id, *prop)), + _ => None, + }) + .collect() + }; + lists += list_writes(&p.branch).intersection(&list_writes(&p.main)).filter(|k| !p.conflicts.iter().any(|c| c.key == **k)).count(); if p.conflicts.is_empty() { disjoint += 1; let none = |_: &figcraft_collab::merge::Key| None; @@ -260,5 +302,5 @@ fn random_edits_on_both_sides_merge() { } } } - assert!(disjoint >= 10 && conflicted >= 5, "{disjoint} disjoint, {conflicted} with conflicts"); + assert!(disjoint >= 10 && conflicted >= 5 && lists >= 3, "{disjoint} disjoint, {conflicted} with conflicts, {lists} lists merged item by item"); } diff --git a/crates/engine/src/branch_versions_tests.rs b/crates/engine/src/branch_versions_tests.rs index 30bdf736..30f5134e 100644 --- a/crates/engine/src/branch_versions_tests.rs +++ b/crates/engine/src/branch_versions_tests.rs @@ -35,12 +35,12 @@ fn branched() -> (Session, String, NodeId) { 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()); + // A new branch's history starts with its creation: the main file's versions stay with it. + assert_eq!(names(&mut s), ["Branch created"]); 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"]); + assert_eq!(names(&mut s), ["Branch created", "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"); @@ -77,13 +77,13 @@ fn branch_versions_save_with_the_file_and_survive_merging() { 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"]); + assert_eq!(names(&mut t), ["Branch created", "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"]); + assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]); std::fs::remove_dir_all(&dir).ok(); } @@ -94,7 +94,7 @@ fn archiving_keeps_the_branch_history() { 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"]); + assert_eq!(names(&mut s), ["Branch created", "Branch v1"]); } #[test] diff --git a/crates/engine/src/branches.rs b/crates/engine/src/branches.rs index 8fdea0fc..a4589180 100644 --- a/crates/engine/src/branches.rs +++ b/crates/engine/src/branches.rs @@ -4,6 +4,13 @@ //! both sides with it. Branches are saved in the main file ([`figcraft_format::Branch`]); a //! 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. +//! +//! In a live session a branch stays with the person who made it (others don't see it), and +//! merging it is one edit of the session document, which syncs to everyone like any other. The +//! session server takes new layers only in the sender's own id space, so a branch made in the +//! session makes its ids in yours, far above the ids the main file makes there ([`WINDOW`]). +//! Merging a branch made outside the session is refused when it adds layers, styles, variables or +//! comments (their ids are in another space). use std::collections::BTreeMap; use std::sync::Arc; @@ -11,6 +18,7 @@ use std::sync::Arc; pub use figcraft_collab::merge::Side; use figcraft_collab::merge::{self, Conflict, Key, Plan}; use figcraft_collab::{Change, Entry, Prop, Target}; +use figcraft_doc::ids::space_of; use figcraft_doc::{Document, NodeId}; use figcraft_format::Branch; pub use figcraft_format::BranchState; @@ -27,7 +35,7 @@ pub fn specs() -> Vec { spec!( "branch.create", "Create branch", - "{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base) → {id, name, index (its tab)}", + "{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base); in a live session it stays yours → {id, name, index (its tab)}", false, create ), @@ -53,14 +61,14 @@ pub fn specs() -> Vec { spec!( "branch.updateFromMain", "Update from main", - "{id?, resolutions? ({: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch; refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}", + "{id?, resolutions? ({: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch (a version in its history); refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}", false, |s, p| combine(s, p, Side::Main) ), spec!( "branch.merge", "Merge branch", - "{id?, resolutions? ({: \"branch\" | \"main\"})} merge the branch into the main file as one undo step, then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}", + "{id?, resolutions? ({: \"branch\" | \"main\"})} merge the branch into the main file as one undo step (in a live session one edit everyone gets; refused on a view link), then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}", false, |s, p| combine(s, p, Side::Branch) ), @@ -219,11 +227,42 @@ fn disabled(cmd: &str, why: impl Into) -> EngineError { EngineError::Disabled(cmd.into(), why.into()) } -/// shortcut: branches of a file in a live session are refused (its participants' client ids -/// aren't known here, so a branch's id space could clash); allow them once the server hands out -/// branch id spaces. -fn refuse_live(s: &Session, cmd: &str, uid: u64) -> Result<()> { - if s.live_doc(uid) { Err(disabled(cmd, "branches aren't available in a live session")) } else { Ok(()) } +/// shortcut: in a live session a branch makes ids in your own client space starting this far +/// above every id made there on the main file or its other branches, so neither side makes the +/// other's ids before the merge unless one makes 16 million first; have the server hand out +/// branch id spaces if that ever matters. +const WINDOW: u64 = 1 << 24; + +/// Where a new branch of the file in tab `main` starts making ids in your live session's client +/// space `client`: [`WINDOW`] above the main file's and every branch's next id there (`None`: the +/// space is full). +fn live_window(s: &Session, main: u64, client: u32) -> Option { + let m = s.docs.iter().find(|d| d.uid == main)?; + let tabs = s.docs.iter().filter(|d| d.branch_of.as_ref().is_some_and(|(t, _)| *t == main)).map(|d| d.doc.next_id()); + let next = m.branches.iter().map(|b| b.head.next_id()).chain(tabs).chain([m.doc.next_id()]); + let top = next.filter(|n| space_of(n.0) == client).max()?; + let start = top.0.checked_add(WINDOW)?; + (space_of(start) == client).then_some(NodeId(start)) +} + +/// What merging into a live session's document would add that its server won't take from client +/// `client`: a new layer outside your id space (unless it is a layer of a base taken in this +/// session, `in_session`, that main deleted since: the server takes those back), or a new style, +/// variable, collection or comment in another client's space. `None`: everything goes through. +fn foreign(plan: &Plan, base: &Document, main: &Document, merged: &Document, client: u32, in_session: bool) -> Option { + let outside = |n: NodeId| space_of(n.0) != client && !(in_session && base.contains(n)); + if let Some(n) = merged.nodes().find(|n| !main.contains(n.id) && outside(n.id)) { + return Some(format!("layer “{}”", n.name)); + } + plan.changes(Side::Branch).iter().find_map(|c| match c { + Change::Put { entry, .. } + if entry.id_space().is_some_and(|sp| sp != 0 && sp != client) + && plan.base().value(&Target::Entry(entry.clone())).ok().flatten().is_none() => + { + Some(entry.describe()) + } + _ => None, + }) } fn create(s: &mut Session, p: &Value) -> Result { @@ -232,7 +271,7 @@ fn create(s: &mut Session, p: &Value) -> Result { if ds.branch_of.is_some() { return Err(disabled(CMD, "this tab is a branch: make branches from the main file")); } - refuse_live(s, CMD, ds.uid)?; + let live = s.live_client_id(CMD, ds.uid)?; if ds.branches.len() >= figcraft_format::MAX_BRANCHES { return Err(disabled(CMD, format!("the file has the most branches it can keep ({})", figcraft_format::MAX_BRANCHES))); } @@ -240,12 +279,22 @@ fn create(s: &mut Session, p: &Value) -> Result { string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Branch {}", ds.branches.len() + 1)); let name = figcraft_format::clip(&name, figcraft_format::MAX_VERSION_NAME); let base = ds.document_itself().clone(); - // Its own id space, above every space the file, its live sessions and its branches used. - let client = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0); - let client = client.checked_add(1).ok_or_else(|| disabled(CMD, "no id space left for a new branch"))?; + let no_space = || disabled(CMD, "no id space left for a new branch"); + // In a live session: your own client space, far above the main file's ids there. Otherwise its + // own id space, above every space the file, its live sessions and its branches used. + let (client, start) = match live { + Some(c) => (c, Some(live_window(s, ds.uid, c).ok_or_else(no_space)?)), + None => { + let top = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0); + (top.checked_add(1).ok_or_else(no_space)?, None) + } + }; let mut head = (*base).clone(); head.name = name.clone(); head.set_client_id(client); + if let Some(start) = start { + head.reserve_ids(NodeId(start.0 - 1)); + } let id = crate::cmd::file::unique_id(|id| ds.branches.iter().any(|b| b.id == id)); let uid = ds.uid; let time = crate::cmd::comment::now_secs(); @@ -259,7 +308,13 @@ fn create(s: &mut Session, p: &Value) -> Result { head: Arc::new(head), versions: Vec::new(), }); + if live.is_some() { + s.live_branch_base(uid, &id); + } let index = open_tab(s, uid, &id)?; + if let Some(tab) = s.docs.get_mut(index) { + crate::cmd::file::add_version(tab, CREATED, "", false); + } Ok(json!({"id": id, "name": name, "index": index})) } @@ -269,11 +324,22 @@ fn open_tab(s: &mut Session, main: u64, id: &str) -> Result { s.active = i; return Ok(i); } - let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; - let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; + let live = s.live_client_id("branch.open", main)?; + let m = s.docs.iter().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; + let b = m.branches.iter().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; let mut doc = (*b.head).clone(); // A branch read from a file has no client id yet. doc.set_client_id(b.client); + // In your live session, a branch that would make ids right where the main file makes them (one + // read from a file that made none yet) moves up. + if live == Some(b.client) + && doc.next_id().0.abs_diff(m.doc.next_id().0) < WINDOW + && let Some(start) = live_window(s, main, b.client) + { + doc.reserve_ids(NodeId(start.0 - 1)); + } + let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; + let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; // 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())); @@ -340,7 +406,13 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { } // The main file itself, not a version shown in its place. m.end_preview(); - refuse_live(s, cmd, main_uid)?; + // Merging edits the session document, which a view link (or a file on its way) can't. + if from == Side::Branch + && let Some(why) = s.live_read_only(main_uid) + { + return Err(disabled(cmd, why)); + } + let live = s.live_client_id(cmd, main_uid)?; let picks = resolutions(cmd, p)?; let (base, head, main) = documents(s, mi, bi).ok_or(EngineError::NoDocument)?; let plan = merge::plan(&base, &head, &main).map_err(|e| EngineError::Other(e.to_string()))?; @@ -351,6 +423,14 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { } let into = if from == Side::Branch { &main } else { &head }; let (mut doc, touched) = plan.merge(into, from, &pick).map_err(|e| EngineError::Other(format!("can't merge: {e}")))?; + if let Some(client) = live.filter(|_| from == Side::Branch) + && let Some(what) = foreign(&plan, &base, &main, &doc, client, s.live_branch_in_session(main_uid, &id)) + { + let why = format!( + "the branch adds {what}, made outside this live session, which the session can't take: merge it once the file has left the session" + ); + return Err(disabled(cmd, why)); + } doc.mark_dirty(touched); normalize(&mut doc, into); let doc = Arc::new(doc); @@ -383,11 +463,33 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { b.base = main; b.head = doc; } + if live.is_some() { + s.live_branch_base(main_uid, &id); + } + note(s, mi, bi, UPDATED); out["updated"] = json!(true); } Ok(out) } +/// The branch's own history entries for its creation and its updates from main. +const CREATED: &str = "Branch created"; +const UPDATED: &str = "Updated from main"; + +/// Add version `name` to the history of branch `bi` of the file in tab `mi`: its tab's when it is +/// open (it goes into the branch with [`sync`]), else the branch's. +fn note(s: &mut Session, mi: usize, bi: usize, name: &str) { + let Some((uid, id)) = s.docs.get(mi).and_then(|m| Some((m.uid, m.branches.get(bi)?.id.clone()))) else { return }; + if let Some(tab) = tab_of(s, uid, &id).and_then(|t| s.docs.get_mut(t)) { + crate::cmd::file::add_version(tab, name, "", false); + return; + } + let Some(b) = s.docs.get_mut(mi).and_then(|m| m.branches_mut().get_mut(bi)) else { return }; + let mut tab = DocState::new((*b.head).clone(), None).with_versions(std::mem::take(&mut b.versions)); + crate::cmd::file::add_version(&mut tab, name, "", false); + b.versions = tab.versions; +} + /// Show merged `doc` in tab `ds`. fn show(ds: &mut DocState, doc: Arc) { ds.doc = doc; diff --git a/crates/engine/src/history_behaviour_tests.rs b/crates/engine/src/history_behaviour_tests.rs index e20f1bd1..45e0c7bf 100644 --- a/crates/engine/src/history_behaviour_tests.rs +++ b/crates/engine/src/history_behaviour_tests.rs @@ -547,7 +547,11 @@ fn branches_and_the_version_history() { // On the branch tab. // 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"); + assert_eq!( + versions(&mut s).iter().map(|v| v["name"].clone()).collect::>(), + [json!("Branch created"), 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 64740d14..548f96ed 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1652,6 +1652,8 @@ mod booleans_tests; #[cfg(test)] mod bound_alpha_tests; #[cfg(test)] +mod branch_live_tests; +#[cfg(test)] mod branch_tests; #[cfg(test)] mod branch_versions_tests; diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index cd8a0f03..f33abd1c 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -37,7 +37,7 @@ //! short-lived presence the host passes on to everyone, never stored. A spotlight makes everyone //! follow the person who started it; moving your own view stops following, as with any follow. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::net::IpAddr; use std::sync::Arc; @@ -599,6 +599,9 @@ pub struct Live { pub spotlight: Option, /// You spotlight: everyone follows you. pub spotlighting: bool, + /// The branches of the session's file made or last updated from main in this session: every + /// layer of their base was in it (see [`crate::branches`]). + branch_bases: BTreeSet, } impl Live { @@ -1251,6 +1254,29 @@ impl Session { self.tab().and_then(|uid| self.live_read_only(uid)) } + /// Your client id in document `uid`'s live session (`None`: it isn't in one). Refused for + /// `cmd` while the session is on its way: there is no id yet. + pub(crate) fn live_client_id(&self, cmd: &str, uid: u64) -> Result> { + let Some(live) = self.live.doc(uid) else { return Ok(None) }; + match &live.client { + Some(c) => Ok(Some(c.id())), + None if live.status.active() => Err(EngineError::Disabled(cmd.into(), "wait until the file's live session has started".into())), + None => Ok(None), + } + } + + /// Branch `id` of document `uid` got its base from the session document just now. + pub(crate) fn live_branch_base(&mut self, uid: u64, id: &str) { + if let Some(live) = self.live.doc_mut(uid).filter(|l| l.syncing()) { + live.branch_bases.insert(id.to_string()); + } + } + + /// Whether branch `id` of document `uid` got its base in the live session it is in now. + pub(crate) fn live_branch_in_session(&self, uid: u64, id: &str) -> bool { + self.live.doc(uid).is_some_and(|l| l.syncing() && l.branch_bases.contains(id)) + } + /// Whether document `uid` is a live session's synced document: its edits sync and its undo /// history is the user's own. pub fn live_doc(&self, uid: u64) -> bool { diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs index fc4ca775..37f22ce7 100644 --- a/crates/engine/src/save_format_tests.rs +++ b/crates/engine/src/save_format_tests.rs @@ -252,7 +252,7 @@ fn fig_copy_from_branch_exports_active_document_without_saving_main() { let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle")); assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle")); - assert_eq!(persisted.branches[0].versions[0].name, "Branch version"); + assert_eq!(persisted.branches[0].versions.last().unwrap().name, "Branch version"); assert!(!s.ds().unwrap().is_dirty()); assert!(!s.docs[0].is_dirty()); } diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 061fd728..641a7040 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -99,6 +99,15 @@ fit FigCraft's architecture: collide with what a connected client makes, because a client's numbers continue above every id of its space already in the document. Removed entries are part of the saved session state (`SessionState::removed`). +- **Branches** of a file in a session stay with the person who made them; nothing about them goes + over the wire until a merge, which is an ordinary batch from that person (no message or format + change). So that the server takes the layers a branch made, a branch made in the session makes + its ids in its author's own client space, 2^24 above every id made there on the main file and the + author's other branches. Layers of the branch's base that main deleted during the session come + back with their ids (the server accepts re-creating deleted ids). Merging a branch whose new + layers, styles, variables or comments lie in another id space (one made offline or in an earlier + session) is refused by the app before anything is sent, since the server would refuse it (see + `crates/engine/src/branches.rs`). - Objects inside an entry are not separate objects: modes are part of their collection, a variable's values per mode part of the variable and replies part of their comment thread. Two people adding a mode to the same collection, editing different modes of one variable, or replying diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index ae503633..c9af478f 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; 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, @@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `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. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: 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, 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 | +| Branching and merging | yes (paid plans) | **done, also in live sessions**: 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; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 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, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged | | 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged | | 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged | | 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index 9f95b4f5..8fce3103 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-25 · **Change:** minor (gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; 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 checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | | 16 | Dev Mode: publishing mappings from the code repository; the editor extension is left to the MCP server (measurements kept on the canvas and a read-only layers panel landed 2026-10-24) | Feature | 1–2 | [dev-mode-parity](dev-mode-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) | +| 17 | Branching: text runs merged whole, branches only their author sees in a live session (branches in live sessions, export settings and guides item by item, and history entries for creation and updates landed 2026-10-24) | 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) | @@ -433,9 +433,26 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe 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). +- **Landed (2026-10-18, #798):** layout grids, export settings, guides, prototype interactions, + accessibility annotations and Dev Mode resources merge item by item like fills (code links merge + whole: one per language). +- **Landed (2026-10-24):** branches of a file in a live session: creating, editing, reviewing, + updating from main and merging work while the file is in a session. A branch stays with the + person who made it; merging it is one edit of the session document that everyone gets, and one + step of its author's own undo history. A branch made in the session makes its ids in its author's + own client space, 2^24 above every id made there on the main file, so the session server takes + the layers it adds; others' edits on main while it is open are main's changes against the + branch's own base, and conflicts with them resolve as offline (a layer a peer deleted comes back + with its id when the branch's edits win). A view link still refuses merging, and merging a branch + made outside the session that adds layers, styles, variables or comments (ids in another space) + is refused with a reason instead of being sent for the server to refuse. A branch's history gets + entries for its creation and each update from main. Engine tests through a session server with + several people, a fuzz test with random real commands (export settings and guides included, + offline and live) and a junk-parameter sweep of the branch commands in a session. +- **Missing:** text runs still conflict as a whole (runs are character ranges into the text, so + lining them up item by item would need a text diff, not positions); branches in a live session + are their author's only (others can't see or open them: that needs the server to keep them); + a branch made offline that adds layers merges only outside a session. - **Estimate:** 1–2 h. ### 18. Display P3 on screen (several windows landed) @@ -507,6 +524,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2026-10-24 | minor | Gap 24: a shared board timer and voting sessions landed (M12); 30–60 h → 26–54 h, ranking unchanged | | 2026-10-24 | minor | Gap 24: board templates (retrospective, brainstorm, weekly planner, flow chart, kanban board; `board.template`, a toolbar picker) and tables (`board.table`, `board.tableEdit`); 30–60 h → 26–54 h, rank unchanged | | 2026-10-24 | minor | Gap 6: plugin widgets take text input (editable fields, `widget.setText`) and a property menu (`widget.menu`, a bar over the selected widget), plugin API 4; 8–26 h → 6–22 h; ranking unchanged | +| 2026-10-24 | minor | Gap 17: branches of a file in a live session (create, edit, review, update from main and merge while the file is in a session; the branch stays with its author and makes ids in their own client space far above the main file's; merging is one synced edit and one step of the author's undo history; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers is refused with a reason); a branch's history gets entries for its creation and updates from main; the list-merge wording brought up to date with #798 (export settings and guides already merged item by item); text runs still merge whole. Ranking and hours unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index c630a85f..b930b881 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1168,7 +1168,8 @@ branches). Driven with `run_command`: - `branch.create {name?}` copies the file into a new branch tab and returns `{id, name, index}`. The branch remembers the file as it is now (its base) and makes new ids in its own id space, so - layers, styles and comments made on both sides never clash. + layers, styles and comments made on both sides never clash. Its history starts with a `Branch + created` version. - `branch.list` lists the branches of the file shown (or of the file a branch tab belongs to): `[{id, name, time, state: active|merged|archived, index?}]` (`index`: its open tab). - `branch.open {id}` opens a branch in a tab (or switches to it). Edit it like any document. @@ -1190,15 +1191,26 @@ branches). Driven with `run_command`: 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 - branch the same way (the new base is the main file as it is now). It is not undoable: the branch - tab's undo history starts over. + branch the same way (the new base is the main file as it is now) and adds an `Updated from main` + version to the branch's history. It is not undoable: the branch tab's undo history starts over. - `branch.archive {id?}` sets a branch aside without merging (it stays in the file and can still be opened, but not merged). 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. +or archived. + +In a live session branches work the same way and stay with the person who made them (others +don't see them). A branch made in the session makes its ids in your own client space, far above +the ids the main file makes, so the session server takes them. Everyone's edits on main while the +branch is open are main's changes in the review (against the branch's own base), and conflicts +with them are resolved as offline. `branch.merge` is one edit of the session document: everyone +gets it, and it is one step of your own undo history. It is refused on a view link, and for a +branch made outside the session (or in an earlier one) whose new layers, styles, variables or +comments lie in another id space, which the server would refuse; merge those once the file has +left the session. While a session is still starting, branch commands that need your client id +wait for it. ### Collaboration changes