From 13fdae3d44ea27780c2e273d4aa65d9b3379b59c Mon Sep 17 00:00:00 2001 From: Ashsajal Date: Sun, 18 Oct 2026 14:55:12 +0600 Subject: [PATCH 1/5] M9: the other item lists merge one by item when a branch merges (gap 17) Fills, strokes and effects already merged item by item, so both sides changing different items of one of those lists was no conflict. Every other list conflicted as a whole: both sides editing different export settings, or different guides, or different prototype interactions, or different accessibility annotations or Dev Mode links, each had to be resolved by hand for the whole list, though the items stand on their own. Export settings, guides, prototype interactions, accessibility annotations and the Dev Mode code links and resources now merge item by item, with the same rule: the same item changed both ways, an item removed on one side, or different items added on both sides still conflict. Properties that are keyed maps rather than item lists (bound, varModes, overrides, propRefs, pluginData) are deliberately left out: their items line up by key, not by position. Text runs are left whole too: they live in the node's text data, so merging them item by item means splitting a property the change format keeps whole. Replaces the test that documented the gap (other_lists_still_conflict_as_a_whole) with one that checks each new list merges, and adds one for the same item changed both ways still conflicting. --- crates/collab/src/merge.rs | 27 +++++++++--- crates/collab/src/merge_list_tests.rs | 61 +++++++++++++++++++++++---- docs/collaboration-parity.md | 5 ++- docs/gaps.md | 11 ++++- 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index bb18e661..381b4393 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -4,10 +4,12 @@ //! //! 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). 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 +//! (including adding layers to it or moving layers into or out of it). The layer lists whose items +//! stand on their own (fills, strokes, effects, export settings, guides, prototype interactions, +//! accessibility annotations and the Dev Mode links and resources) 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. @@ -159,8 +161,21 @@ fn by_key(changes: &[Change]) -> BTreeMap> { out } -/// List properties whose items merge one by one. -const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects]; +/// List properties whose items merge one by item: the items stand on their own, so both sides +/// changing different ones (or one side adding at its end) is no conflict. Properties that are +/// keyed maps rather than item lists (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) +/// are not here: their items line up by key, not by position. +const PER_ITEM: &[Prop] = &[ + Prop::Fills, + Prop::Strokes, + Prop::Effects, + Prop::Exports, + Prop::Guides, + Prop::Interactions, + Prop::Annotations, + Prop::CodeLinks, + Prop::DevResources, +]; /// 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. diff --git a/crates/collab/src/merge_list_tests.rs b/crates/collab/src/merge_list_tests.rs index 436e6b6f..8b237145 100644 --- a/crates/collab/src/merge_list_tests.rs +++ b/crates/collab/src/merge_list_tests.rs @@ -1,5 +1,7 @@ -//! Fills, strokes and effects merge item by item: different items changed on each side merge, -//! the same item changed both ways conflicts. +//! The layer lists whose items stand on their own (fills, strokes, effects, export settings, +//! guides, prototype interactions, accessibility annotations, Dev Mode links and resources) 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}; @@ -100,19 +102,60 @@ fn the_same_item_changed_both_ways_conflicts() { } #[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. +fn the_other_item_lists_merge_one_by_one_too() { + // Export settings, guides, prototype interactions, accessibility annotations and the Dev Mode + // links and resources are item lists like fills: both sides changing different items merges. 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 guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset }; + let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None }; + let link = + |name: &str| figcraft_doc::CodeLink { language: "react".into(), import_path: String::new(), name: name.into(), template: String::new() }; + let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() }; + let n = base.get_mut(r).unwrap(); + n.exports = vec![export("@1"), export("@2")]; + n.guides = vec![guide(true, 10.0), guide(false, 20.0)]; + n.annotations = vec![annot("a1"), annot("a2")]; + n.code_links = vec![link("L1"), link("L2")]; + n.dev_resources = vec![res("https://a.test"), res("https://b.test")]; 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"); + // The branch changes the first item of each list; main the second. + let b = branch.get_mut(r).unwrap(); + (b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.code_links[0].name, b.dev_resources[0].url) = + ("@b".into(), 11.0, "b1".into(), "LB".into(), "https://branch.test".into()); + let m = main.get_mut(r).unwrap(); + (m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.code_links[1].name, m.dev_resources[1].url) = + ("@m".into(), 22.0, "m2".into(), "LM".into(), "https://main.test".into()); + both_ways(&base, &branch, &main, |doc| { + let n = doc.node(r).unwrap(); + let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect(); + assert_eq!(suffixes, ["@b", "@m"], "export settings"); + let offsets: Vec = n.guides.iter().map(|g| g.offset).collect(); + assert_eq!(offsets, [11.0, 22.0], "guides"); + let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect(); + assert_eq!(labels, ["b1", "m2"], "annotations"); + let names: Vec<&str> = n.code_links.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["LB", "LM"], "code links"); + let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect(); + assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources"); + }); +} + +#[test] +fn the_same_item_of_another_list_changed_both_ways_conflicts() { + let (mut base, _, _, r) = files(); + base.get_mut(r).unwrap().exports = vec![ + figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() }, + figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() }, + ]; + 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].suffix = "@b".into(); + main.get_mut(r).unwrap().exports[0].suffix = "@m".into(); let p = plan(&base, &branch, &main).unwrap(); let keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); - assert_eq!(keys, [format!("{r}/exports")]); + assert_eq!(keys, [format!("{r}/exports")], "the same item changed both ways still conflicts"); } #[test] diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index ae503633..920a9621 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -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, 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, effects, export settings, guides, prototype interactions, accessibility annotations and the Dev Mode links and resources merge item by item. Left: branches in live sessions; text runs 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 | @@ -35,6 +35,9 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged | | 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | | 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change (Enter sends, Escape takes it back, 100 characters, control characters dropped); your avatar's menu (or `collab.spotlight`) asks everyone to follow your view and page, latecomers included, until they move their own view. Both travel as signals: ephemeral presence the host validates, rate-limits (20 a second per person) and passes only to clients that announce they read them, so older clients keep working; never in the document or the log. Live cursors / follow / spotlight row partial → done, 8–15 h → 2–4 h; cursor chat row no → partial (no audio or reactions), 4–8 h → 2–5 h; multiplayer 45–90 h → 42–84 h (it counted these rows only in part); remaining 55–110 h (stale: team libraries had already gone to 4–8 h) → 46–92 h; collaboration ~13% unchanged at this rounding | +| Date | Change | Summary | +|---|---|---| +| 2026-10-18 | minor | Branch merge, gap 17: the layer lists whose items stand on their own merge item by item, not just fills, strokes and effects. Export settings, guides, prototype interactions, accessibility annotations and the Dev Mode code links and resources now merge item by item, each conflicting only when the same item changed on both sides, an item was removed on one side, or both sides added different items. Text runs are left whole (they live in the node's text data). Numbers unchanged | | 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | | 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions; percentages and hours unchanged here (comments and versions are graded with feature area 16 in target-app-parity) | | 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | diff --git a/docs/gaps.md b/docs/gaps.md index 9b1a770c..dd42a75b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -397,8 +397,14 @@ 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 +- **Landed (2026-10-18):** the other layer lists whose items stand on their own merge item by item + too: export settings, guides, prototype interactions, accessibility annotations and the Dev Mode + code links and resources, each with the same rule as fills (the same item changed both ways still + conflicts). Properties that are keyed maps rather than item lists (`bound`, `varModes`, + `overrides`, `propRefs`, `pluginData`) deliberately do not: their items line up by key. +- **Missing:** branches of a file in a live session are refused; text runs still conflict as a + whole (they live in the node's text data, so merging them means splitting a property the change + format keeps 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. @@ -444,6 +450,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Gap 17: export settings, guides, prototype interactions, accessibility annotations and the Dev Mode code links and resources merge item by item like fills, strokes and effects (the same item changed both ways still conflicts). Text runs are left whole: they live in the node's text data, so merging them means splitting a property the change format keeps whole. Ranking and hours unchanged | | 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 | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | From de2915908c94fe0ab707af9e6d1ce29d84f05cc8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:01:02 +0700 Subject: [PATCH 2/5] #798: drop progress-doc edits (maintainer-owned) --- docs/collaboration-parity.md | 5 +---- docs/gaps.md | 11 ++--------- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 920a9621..ae503633 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -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, effects, export settings, guides, prototype interactions, accessibility annotations and the Dev Mode links and resources merge item by item. Left: branches in live sessions; text runs conflict as a whole | 1–2 | +| 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 | @@ -35,9 +35,6 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged | | 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | | 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change (Enter sends, Escape takes it back, 100 characters, control characters dropped); your avatar's menu (or `collab.spotlight`) asks everyone to follow your view and page, latecomers included, until they move their own view. Both travel as signals: ephemeral presence the host validates, rate-limits (20 a second per person) and passes only to clients that announce they read them, so older clients keep working; never in the document or the log. Live cursors / follow / spotlight row partial → done, 8–15 h → 2–4 h; cursor chat row no → partial (no audio or reactions), 4–8 h → 2–5 h; multiplayer 45–90 h → 42–84 h (it counted these rows only in part); remaining 55–110 h (stale: team libraries had already gone to 4–8 h) → 46–92 h; collaboration ~13% unchanged at this rounding | -| Date | Change | Summary | -|---|---|---| -| 2026-10-18 | minor | Branch merge, gap 17: the layer lists whose items stand on their own merge item by item, not just fills, strokes and effects. Export settings, guides, prototype interactions, accessibility annotations and the Dev Mode code links and resources now merge item by item, each conflicting only when the same item changed on both sides, an item was removed on one side, or both sides added different items. Text runs are left whole (they live in the node's text data). Numbers unchanged | | 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | | 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions; percentages and hours unchanged here (comments and versions are graded with feature area 16 in target-app-parity) | | 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | diff --git a/docs/gaps.md b/docs/gaps.md index dd42a75b..9b1a770c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -397,14 +397,8 @@ 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). -- **Landed (2026-10-18):** the other layer lists whose items stand on their own merge item by item - too: export settings, guides, prototype interactions, accessibility annotations and the Dev Mode - code links and resources, each with the same rule as fills (the same item changed both ways still - conflicts). Properties that are keyed maps rather than item lists (`bound`, `varModes`, - `overrides`, `propRefs`, `pluginData`) deliberately do not: their items line up by key. -- **Missing:** branches of a file in a live session are refused; text runs still conflict as a - whole (they live in the node's text data, so merging them means splitting a property the change - format keeps whole); the branch history has no entries of its own for +- **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. @@ -450,7 +444,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Gap 17: export settings, guides, prototype interactions, accessibility annotations and the Dev Mode code links and resources merge item by item like fills, strokes and effects (the same item changed both ways still conflicts). Text runs are left whole: they live in the node's text data, so merging them means splitting a property the change format keeps whole. Ranking and hours unchanged | | 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 | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | From a4c7e259abf38485434249551478f7a459ab17b6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:01:02 +0700 Subject: [PATCH 3/5] #798: layout grids merge item by item, code links merge whole (keyed by language); fix stale doc comments --- crates/collab/src/change.rs | 5 +++-- crates/collab/src/merge.rs | 21 +++++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 32a49184..0608ba67 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -87,8 +87,9 @@ 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 (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). + /// replaced whole (three-way merges line the lists whose items stand on their own, such as + /// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the + /// binary form numbers them). #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Prop { diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index 381b4393..5132ce75 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -5,13 +5,12 @@ //! 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). The layer lists whose items -//! stand on their own (fills, strokes, effects, export settings, guides, prototype interactions, -//! accessibility annotations and the Dev Mode links and resources) merge item by item: both sides +//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype +//! interactions, accessibility annotations and Dev Mode resources) 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. +//! 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. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -161,19 +160,21 @@ fn by_key(changes: &[Change]) -> BTreeMap> { out } -/// List properties whose items merge one by item: the items stand on their own, so both sides -/// changing different ones (or one side adding at its end) is no conflict. Properties that are -/// keyed maps rather than item lists (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) -/// are not here: their items line up by key, not by position. +/// List properties whose items merge one by one: the items stand on their own, so both sides +/// changing different ones (or one side adding at its end) is no conflict. Sets and keyed maps +/// (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) are not here: their items line up +/// by key, not by position. const PER_ITEM: &[Prop] = &[ Prop::Fills, Prop::Strokes, Prop::Effects, + Prop::Grids, Prop::Exports, Prop::Guides, Prop::Interactions, Prop::Annotations, - Prop::CodeLinks, + // Not `CodeLinks`: there is one per language, and lining them up by position could leave two + // for one language (the second hidden), so they merge whole. Prop::DevResources, ]; From 53c8308b66072aad100eba6626da7b33d29a406e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:01:03 +0700 Subject: [PATCH 4/5] #798: test layout grids, code links and interactions (add, change, delete, conflicts) --- crates/collab/src/merge_list_tests.rs | 89 +++++++++++++++++++++------ 1 file changed, 71 insertions(+), 18 deletions(-) diff --git a/crates/collab/src/merge_list_tests.rs b/crates/collab/src/merge_list_tests.rs index 8b237145..e92f6bbb 100644 --- a/crates/collab/src/merge_list_tests.rs +++ b/crates/collab/src/merge_list_tests.rs @@ -1,10 +1,10 @@ -//! The layer lists whose items stand on their own (fills, strokes, effects, export settings, -//! guides, prototype interactions, accessibility annotations, Dev Mode links and resources) merge -//! item by item: different items changed on each side merge, the same item changed both ways +//! The layer lists whose items stand on their own (fills, strokes, effects, layout grids, export +//! settings, guides, prototype interactions, accessibility annotations and Dev Mode resources) +//! 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 figcraft_doc::{Document, Effect, LayoutGrid, NodeId, NodeKind, proto}; use serde_json::{Value, json}; use crate::merge::{Key, Side, merge_items, plan}; @@ -103,30 +103,29 @@ fn the_same_item_changed_both_ways_conflicts() { #[test] fn the_other_item_lists_merge_one_by_one_too() { - // Export settings, guides, prototype interactions, accessibility annotations and the Dev Mode - // links and resources are item lists like fills: both sides changing different items merges. + // Layout grids, export settings, guides, accessibility annotations and Dev Mode resources are + // item lists like fills: both sides changing different items merges. let (mut base, _, _, r) = files(); let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }; let guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset }; let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None }; - let link = - |name: &str| figcraft_doc::CodeLink { language: "react".into(), import_path: String::new(), name: name.into(), template: String::new() }; + let grid = |size: f64| LayoutGrid { size, ..Default::default() }; let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() }; let n = base.get_mut(r).unwrap(); n.exports = vec![export("@1"), export("@2")]; n.guides = vec![guide(true, 10.0), guide(false, 20.0)]; n.annotations = vec![annot("a1"), annot("a2")]; - n.code_links = vec![link("L1"), link("L2")]; + n.grids = vec![grid(10.0), grid(20.0)]; n.dev_resources = vec![res("https://a.test"), res("https://b.test")]; let (mut branch, mut main) = (base.clone(), base.clone()); branch.set_client_id(base.highest_id_space() + 1); // The branch changes the first item of each list; main the second. let b = branch.get_mut(r).unwrap(); - (b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.code_links[0].name, b.dev_resources[0].url) = - ("@b".into(), 11.0, "b1".into(), "LB".into(), "https://branch.test".into()); + (b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.grids[0].size, b.dev_resources[0].url) = + ("@b".into(), 11.0, "b1".into(), 12.0, "https://branch.test".into()); let m = main.get_mut(r).unwrap(); - (m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.code_links[1].name, m.dev_resources[1].url) = - ("@m".into(), 22.0, "m2".into(), "LM".into(), "https://main.test".into()); + (m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.grids[1].size, m.dev_resources[1].url) = + ("@m".into(), 22.0, "m2".into(), 24.0, "https://main.test".into()); both_ways(&base, &branch, &main, |doc| { let n = doc.node(r).unwrap(); let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect(); @@ -135,8 +134,8 @@ fn the_other_item_lists_merge_one_by_one_too() { assert_eq!(offsets, [11.0, 22.0], "guides"); let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect(); assert_eq!(labels, ["b1", "m2"], "annotations"); - let names: Vec<&str> = n.code_links.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, ["LB", "LM"], "code links"); + let sizes: Vec = n.grids.iter().map(|g| g.size).collect(); + assert_eq!(sizes, [12.0, 24.0], "layout grids"); let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect(); assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources"); }); @@ -145,17 +144,71 @@ fn the_other_item_lists_merge_one_by_one_too() { #[test] fn the_same_item_of_another_list_changed_both_ways_conflicts() { let (mut base, _, _, r) = files(); - base.get_mut(r).unwrap().exports = vec![ + let link = |language: &str| figcraft_doc::CodeLink { language: language.into(), ..Default::default() }; + let n = base.get_mut(r).unwrap(); + n.exports = vec![ figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() }, figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() }, ]; + n.code_links = vec![link("react"), link("vue")]; 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].suffix = "@b".into(); main.get_mut(r).unwrap().exports[0].suffix = "@m".into(); + // Code links are one per language, not an item list: different links changed still conflict. + branch.get_mut(r).unwrap().code_links[0].name = "B".into(); + main.get_mut(r).unwrap().code_links[1].name = "M".into(); let p = plan(&base, &branch, &main).unwrap(); - let keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); - assert_eq!(keys, [format!("{r}/exports")], "the same item changed both ways still conflicts"); + let mut keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); + keys.sort(); + let mut want = [format!("{r}/codeLinks"), format!("{r}/exports")]; + want.sort(); + assert_eq!(keys, want); +} + +#[test] +fn interactions_merge_item_by_item() { + let ia = |duration_ms: u32| proto::Interaction { + trigger: Default::default(), + actions: vec![proto::Action::Back], + transition: Default::default(), + duration_ms, + easing: Default::default(), + delay_ms: 0, + }; + let durations = |doc: &Document, r: NodeId| doc.node(r).unwrap().interactions.iter().map(|i| i.duration_ms).collect::>(); + let (mut base, _, _, r) = files(); + base.get_mut(r).unwrap().interactions = vec![ia(100), ia(200)]; + let fork = || { + let (mut branch, main) = (base.clone(), base.clone()); + branch.set_client_id(base.highest_id_space() + 1); + (branch, main) + }; + // The branch changes the first interaction and adds one; main changes the second. + let (mut branch, mut main) = fork(); + branch.get_mut(r).unwrap().interactions[0].duration_ms = 150; + branch.get_mut(r).unwrap().interactions.push(ia(300)); + main.get_mut(r).unwrap().interactions[1].duration_ms = 250; + both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [150, 250, 300])); + // The branch deletes one while main leaves them alone. + let (mut branch, mut main) = fork(); + branch.get_mut(r).unwrap().interactions.remove(0); + main.get_mut(r).unwrap().fills[0] = red(); + both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [200])); + // The same interaction changed both ways, or one deleted while the other side changed the + // list, conflicts. + for delete in [false, true] { + let (mut branch, mut main) = fork(); + if delete { + branch.get_mut(r).unwrap().interactions.remove(0); + } else { + branch.get_mut(r).unwrap().interactions[1].duration_ms = 220; + } + main.get_mut(r).unwrap().interactions[1].duration_ms = 250; + let p = plan(&base, &branch, &main).unwrap(); + let keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); + assert_eq!(keys, [format!("{r}/interactions")], "delete: {delete}"); + } } #[test] From adb23f0ff19470f43bb7872be2409a117b6ef477 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sun, 18 Oct 2026 17:01:03 +0700 Subject: [PATCH 5/5] #798: branch.review docs name the lists that merge item by item --- docs/mcp.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 786fde0a..4f149503 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -972,10 +972,11 @@ branches). Driven with `run_command`: name, property, branch, main, deletedBy?}]}`. `layers` are the changed layers with their property changes formatted like Inspect (`fill #2563EB → #DC2626`); `document` lists styles, variables, comments and file settings. A conflict is the same property changed on both sides to different - values (key `/`, e.g. `4/fills`; 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 + values (key `/`, e.g. `4/fills`; fills, strokes, effects, layout grids, + export settings, guides, prototype interactions, annotations and Dev Mode resources 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; other lists, such as code links, compare whole; a layer's parent + and position are one property, `parent`), a layer deleted on one side while the other edited it or what's inside it (`/layer`, with `deletedBy`), or a document-level entry or setting changed on both sides (`style/`, `variable/`, `comment/`, `doc/`…). - `branch.merge {id?, resolutions?}` merges the branch into the main file as one undo step,