From 1caf771e8285c1b3707097ed2cbd01f86e5aa404 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 01:29:50 +0700 Subject: [PATCH] M8: comments and version history behaviour suite and fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A behaviour suite for comments and version history written from the public documentation (`crates/engine/src/history_behaviour_tests.rs`, 15 tests, about 80 table rows plus stepwise checks), each row with a one-line derivation; where the documentation is silent the row says so and pins today's behaviour. 4 of the 15 tests failed on the old code. Fixes, all in shared engine code: - **Live sessions: comment threads made in the session couldn't be used.** Their ids come from the creator's id space (e.g. 4294967297) and every thread command (`comment.reply`, `edit`, `react`, `resolve`, `move`, `delete`, `markRead`, `open`, `link`) refused them as "out of range". Comment ids are now parsed as ids, not small indexes. - **Restoring a version lost newer comments.** Comments are a conversation about the file, kept apart from its design history: restoring now keeps the current comments; a comment on a layer the version doesn't have stays where it was (on the nearest surviving parent, or the canvas). - **Restoring now keeps the replaced design in the history** as an autosave first, unless a version of it was just saved or restored (so work isn't only in the session's undo history). The checkpoint marker is now "none yet" for a freshly opened file and compares contents when normalising copied the document. - **A titled autosave becomes a named version** (`version.rename` clears `auto`), so it is no longer dropped when autosaves make room. - **Merging a branch adds to the main file's history:** the main file as it was (an autosave, when it changed since its last version), then `Merged “”`. - `version.restore` refuses up front for a view-only live session (nothing goes into the history). Pinned where the documentation is silent: pins go to the innermost frame under them (not to a leaf layer); copies, duplicates and duplicated pages come without comments; replying reopens a resolved thread; only authors delete their messages (no accounts); stepping past the last thread stays on it; comments show on the canvas by default; versions stay with whoever saved them in a live session. `crates/engine/src/collab_live_tests.rs`: the test `Room` is `pub(crate)` so the suite can run a live session. `crates/engine/src/cmd/props.rs`: one line reformatted by `cargo fmt` (it was unformatted on the base, which failed the fmt check). `crates/render/src/effects_behaviour_tests.rs`: the paint suite expected an offset drop shadow to grow the export on every side, but the later export suite made exports grow only where the shadow falls (40 x 50 for a shadow 10 down); the base failed the gate on it, so the expectation now says "grows" (wider or taller). No new commands. Docs: collaboration-parity, target-app-parity (area 16 85%/60% -> 85%/62%, 2-5 h -> 2-4 h; full parity ~306-595 h -> ~306-594 h; percentages unchanged at this rounding), ROADMAP (also corrects the stale area 16 row, 80%/58%/4-8 h), gaps (gap 4's landed list), mcp.md. --- ROADMAP.md | 11 +- crates/engine/src/branches.rs | 4 + crates/engine/src/cmd/comment.rs | 5 +- crates/engine/src/cmd/file.rs | 76 ++- crates/engine/src/collab_live_tests.rs | 14 +- crates/engine/src/history_behaviour_tests.rs | 612 +++++++++++++++++++ crates/engine/src/lib.rs | 19 +- crates/engine/src/version_tests.rs | 5 +- docs/collaboration-parity.md | 7 +- docs/gaps.md | 6 +- docs/mcp.md | 15 +- docs/target-app-parity.md | 26 +- 12 files changed, 734 insertions(+), 66 deletions(-) create mode 100644 crates/engine/src/history_behaviour_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 375cf13..745ef59 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~170–305 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -19,13 +19,13 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~49%** | estimated: weekly areas 71.0% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~170–305 h** one agent | estimated | -| Remaining to full parity | **~306–595 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~306–594 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~51% | ~296–570 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~51% | ~296–569 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~49% | ~131–245 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | @@ -56,7 +56,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Dimension | Ready | Hours | Doc | |---|---|---|---| -| Features (depth) | 61% (core editor 70%) | 46–94 | [target-app-parity](docs/target-app-parity.md) | +| Features (depth) | 61% (core editor 70%) | 46–93 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 59% (`.fig` read best effort with editable vectors, no write) | 5–10 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) | @@ -87,7 +87,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Prototyping | 90% | 74% | 3–6 | [prototyping-parity](docs/prototyping-parity.md) | | Import/export | 84% | 57% | 5–10 | [file-format-parity](docs/file-format-parity.md) | | Dev Mode / inspect | 93% | 70% | 1–3 | [dev-mode-parity](docs/dev-mode-parity.md) | -| Comments, versions, misc | 80% | 58% | 4–8 | [collaboration-parity](docs/collaboration-parity.md) | +| Comments, versions, misc | 85% | 62% | 2–4 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 45% | 28% | 15–40 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | @@ -213,6 +213,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h (this table still showed 80%/58%/4–8 h from before branches landed); percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | | 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h (this table still showed 95%/72%/3–6 h from before the outlines landed); percentages unchanged at this rounding; full parity ~320–615 h → ~319–614 h | | 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53% (the row still showed the figures from before `.fig` import); Dev Mode 90%/64% → 93%/70%; mainstream weekly areas 68.1% → 68.4%; percentages and full parity hours unchanged at this rounding | | 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | diff --git a/crates/engine/src/branches.rs b/crates/engine/src/branches.rs index b4c98ad..6383345 100644 --- a/crates/engine/src/branches.rs +++ b/crates/engine/src/branches.rs @@ -331,7 +331,11 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { if from == Side::Branch { let m = s.docs.get_mut(mi).ok_or(EngineError::NoDocument)?; let (selection, page) = (m.selection.clone(), m.page); + // The main file's history keeps it as it was before the merge, then the merge itself. + crate::cmd::file::keep_in_history(m); show(m, doc); + let name = m.branches.get(bi).map(|b| b.name.clone()).unwrap_or_default(); + crate::cmd::file::add_version(m, &format!("Merged “{name}”"), "", false); if let Some(b) = m.branches_mut().get_mut(bi) { b.state = BranchState::Merged; } diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index fddc83a..d1d360f 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -220,7 +220,8 @@ pub fn follow_anchors(doc: &mut Document, before: &Document) { fn req_comment(cmd: &str, s: &Session, p: &Value) -> crate::Result { let id = match get(p, "link").and_then(Value::as_str) { Some(l) => parse_link(l).ok_or_else(|| bad(cmd, format!("not a comment link: {l}")))?, - None => req_index(cmd, p, "id")? as u64, + // Ids made in a live session sit in their creator's id space, far above small indexes. + None => id(p, "id").map(|i| i.0).ok_or_else(|| bad(cmd, "`id` must be a comment id"))?, }; if s.doc()?.comment(id).is_none() { return Err(bad(cmd, format!("no comment {id}"))); @@ -553,7 +554,7 @@ pub fn specs() -> Vec { let (page, filter, sort, text) = list_params("comment.next", s, p)?; let ids: Vec = query(doc, &Query { page, frame: id(p, "frame"), filter, sort, text: &text }, &who).iter().map(|c| c.id).collect(); - let at = num(p, "id").and_then(|i| ids.iter().position(|x| *x as f64 == i)); + let at = id(p, "id").and_then(|i| ids.iter().position(|x| *x == i.0)); let next = match at { Some(i) if dir < 0.0 => ids.get(i.saturating_sub(1)), Some(i) => ids.get(i + 1).or(ids.get(i)), diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 58ff1b4..af33d4e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -140,11 +140,11 @@ pub fn specs() -> Vec { spec!( "version.restore", "Restore version", - "{id | index} replace the document with the version (one undo step; the file name stays)", + "{id | index} replace the design with the version (one undo step; the file name and the comments stay; the design as it was is kept in the history first, unless a version of it was just saved or restored)", true, restore_version ), - spec!("version.rename", "Rename version", "{id | index, name, description?}", false, |s, p| { + spec!("version.rename", "Rename version", "{id | index, name, description?} a titled autosave becomes a named version", false, |s, p| { const CMD: &str = "version.rename"; let name = req_string(CMD, p, "name")?; if name.trim().is_empty() { @@ -155,6 +155,8 @@ pub fn specs() -> Vec { let description = string(p, "description"); let v = ds.versions_mut().get_mut(i).ok_or_else(|| bad(CMD, "no such version"))?; v.name = figcraft_format::clip(name.trim(), figcraft_format::MAX_VERSION_NAME); + // A titled autosave is a named version (never dropped to make room). + v.auto = false; if let Some(d) = description { v.description = figcraft_format::clip(d.trim(), figcraft_format::MAX_VERSION_DESCRIPTION); } @@ -254,22 +256,42 @@ fn autosave(s: &mut Session, _: &Value) -> Result { if ds.preview.is_some() || ds.branch_of.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() { return Ok(json!({"saved": false})); } - // Make room: the oldest autosave goes first; user versions are never dropped. + Ok(match add_version(ds, AUTOSAVE, "", true) { + Some(v) => json!({"saved": true, "version": v}), + None => json!({"saved": false}), + }) +} + +/// What checkpoints are called. +const AUTOSAVE: &str = "Autosave"; + +/// Add a version of `ds`'s document as it is now (`auto`: a checkpoint) and return it. The oldest +/// autosave makes room when the autosaves or the history are full; versions the user named are +/// never dropped, so a history full of them takes nothing (None). +pub(crate) fn add_version(ds: &mut DocState, name: &str, description: &str, auto: bool) -> Option { let autos = ds.versions.iter().filter(|v| v.auto).count(); - if (autos >= MAX_AUTOSAVES || ds.versions.len() >= figcraft_format::MAX_VERSIONS) + if ((auto && autos >= MAX_AUTOSAVES) || ds.versions.len() >= figcraft_format::MAX_VERSIONS) && let Some(oldest) = ds.versions.iter().position(|v| v.auto) { ds.versions_mut().remove(oldest); } if ds.versions.len() >= figcraft_format::MAX_VERSIONS { - return Ok(json!({"saved": false})); + return None; } - let mut v = snapshot_version(ds, "Autosave", ""); - v.auto = true; + let mut v = snapshot_version(ds, name, description); + v.auto = auto; let out = version_json(ds.versions.len(), &v); ds.versions_mut().push(v); ds.mark_checkpoint(); - Ok(json!({"saved": true, "version": out})) + Some(out) +} + +/// Before the document is replaced (a restore, a merge): keep it in the history as it is, unless +/// it is what the last version saved or restored holds. +pub(crate) fn keep_in_history(ds: &mut DocState) { + if ds.branch_of.is_none() && ds.changed_since_checkpoint() { + add_version(ds, AUTOSAVE, "", true); + } } /// A version of `ds`'s document as it is now, opening on the page shown. @@ -331,33 +353,39 @@ fn save_version(s: &mut Session, p: &Value) -> Result { if ds.branch_of.is_some() { return Err(EngineError::Disabled("file.saveVersion".into(), "versions are saved on the main file, not on a branch".into())); } - if ds.versions.len() >= figcraft_format::MAX_VERSIONS { - return Err(EngineError::Disabled( - "file.saveVersion".into(), - format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), - )); - } let name = string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Version {}", ds.versions.len() + 1)); let description = string(p, "description").unwrap_or_default(); // The version opens on the page shown when it was saved. - let v = snapshot_version(ds, &name, description.trim()); - let out = version_json(ds.versions.len(), &v); - ds.versions_mut().push(v); - ds.mark_checkpoint(); - Ok(out) + add_version(ds, &name, description.trim(), false).ok_or_else(|| { + EngineError::Disabled( + "file.saveVersion".into(), + format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), + ) + }) } fn restore_version(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "version.restore"; + // Refused before anything goes into the history. + if let Some(why) = s.live_read_only(s.ds()?.uid) { + return Err(EngineError::Disabled(CMD.into(), why.into())); + } let ds = s.ds_mut()?; - let i = version_index("version.restore", ds, p)?; - let v = ds.versions.get(i).ok_or_else(|| bad("version.restore", "no such version"))?; - let mut doc = read_version(v)?; - let out = version_json(i, v); + let i = version_index(CMD, ds, p)?; + let v = ds.versions.get(i).ok_or_else(|| bad(CMD, "no such version"))?; + let (id, mut doc) = (v.id.clone(), read_version(v)?); + // Restoring never loses work: the document as it is goes into the history first. + keep_in_history(ds); + let out = ds.versions.iter().enumerate().find(|(_, v)| v.id == id).map_or(Value::Null, |(i, v)| version_json(i, v)); let d = Arc::make_mut(&mut doc); d.name = ds.doc.name.clone(); // What the file published belongs to the file, not to the version. d.published = ds.doc.published.clone(); + // Comments are a conversation about the file, kept apart from its design history: they stay, + // and those on layers the version doesn't have stay where they were. + d.comments = ds.doc.comments.clone(); + super::comment::follow_anchors(d, &ds.doc); // The snapshot is already consistent: nothing to re-normalise. d.take_dirty(); // Stay on the current page when the version has it. @@ -370,6 +398,8 @@ fn restore_version(s: &mut Session, p: &Value) -> Result { ds.selection.clear(); ds.text_edit = None; ds.vector_edit = None; + // The restored design is in the history already: restoring it again keeps nothing new. + ds.mark_checkpoint(); Ok(out) } diff --git a/crates/engine/src/collab_live_tests.rs b/crates/engine/src/collab_live_tests.rs index 203fe3e..5c9b6d9 100644 --- a/crates/engine/src/collab_live_tests.rs +++ b/crates/engine/src/collab_live_tests.rs @@ -32,8 +32,8 @@ struct Person { } /// A session server (the host's, minus the sockets) and several people joined as guests. -struct Room { - server: Server, +pub(crate) struct Room { + pub(crate) server: Server, people: Vec, rng: Rng, } @@ -43,7 +43,7 @@ fn welcome_of(out: &[Outgoing], id: u32) -> ServerMsg { } impl Room { - fn new(doc: Document, n: usize, seed: u64) -> Room { + pub(crate) fn new(doc: Document, n: usize, seed: u64) -> Room { let mut room = Room { server: Server::new(doc, "t", Limits::default()), people: Vec::new(), rng: Rng::new(seed) }; for _ in 0..n { let mut s = Session::new(); @@ -62,7 +62,7 @@ impl Room { room } - fn s(&mut self, i: usize) -> &mut Session { + pub(crate) fn s(&mut self, i: usize) -> &mut Session { &mut self.people[i].s } @@ -118,7 +118,7 @@ impl Room { true } - fn settle(&mut self) { + pub(crate) fn settle(&mut self) { let mut n = 0; while self.step() { n += 1; @@ -151,7 +151,7 @@ impl Room { } /// Everyone holds the server's document, in the session and on the canvas. - fn check(&self) { + pub(crate) fn check(&self) { for (i, p) in self.people.iter().enumerate() { let c = p.s.live.client().unwrap(); assert_eq!(c.pending(), 0, "person {i}"); @@ -162,7 +162,7 @@ impl Room { } /// The shared file's first frame and one of its rectangles. -fn frame_and_rect(doc: &Document) -> (NodeId, NodeId) { +pub(crate) fn frame_and_rect(doc: &Document) -> (NodeId, NodeId) { let page = doc.first_page().unwrap(); let f = doc.node(page).unwrap().children[0]; (f, doc.node(f).unwrap().children[0]) diff --git a/crates/engine/src/history_behaviour_tests.rs b/crates/engine/src/history_behaviour_tests.rs new file mode 100644 index 0000000..6bc789d --- /dev/null +++ b/crates/engine/src/history_behaviour_tests.rs @@ -0,0 +1,612 @@ +//! Comments and version history behaviour suite, written from the public documentation of design +//! tools: each rule is a table row with what it implies and a one-line derivation. Where the +//! documentation is silent the row says so and pins today's behaviour. + +// The tables read best as tuples. +#![allow(clippy::type_complexity)] + +use figcraft_doc::{Comment, NodeId}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::keymap::{Context, Mods, Platform, lookup}; +use crate::params::id_of; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn id(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn frame(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option) -> NodeId { + let mut p = json!({"x": x, "y": y, "width": w, "height": h}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(&ex(s, "frame.create", p)) +} + +fn rect(s: &mut Session, x: f64, y: f64, parent: Option) -> NodeId { + let mut p = json!({"x": x, "y": y, "width": 40, "height": 40}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(&ex(s, "shape.rectangle", p)) +} + +/// A comment by `author` at a page point, pinned the way the Comment tool pins it. +fn add(s: &mut Session, x: f64, y: f64, text: &str, author: &str) -> u64 { + ex(s, "comment.add", json!({"x": x, "y": y, "text": text, "author": author, "anchor": true}))["id"].as_u64().unwrap() +} + +fn comment(s: &Session, c: u64) -> Comment { + s.doc().unwrap().comment(c).unwrap().clone() +} + +fn at(s: &Session, c: u64) -> (f64, f64) { + let (x, y) = comment(s, c).position(s.doc().unwrap()); + ((x * 1000.0).round() / 1000.0, (y * 1000.0).round() / 1000.0) +} + +fn ids(s: &mut Session, cmd: &str, p: Value) -> Vec { + ex(s, cmd, p)["comments"].as_array().unwrap().iter().map(|c| c["id"].as_u64().unwrap()).collect() +} + +fn versions(s: &mut Session) -> Vec { + ex(s, "version.list", json!({})).as_array().unwrap().clone() +} + +fn temp_path(name: &str) -> (std::path::PathBuf, String) { + let dir = std::env::temp_dir().join(format!("figcraft-history-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{name}.figcraft")).to_string_lossy().to_string(); + (dir, path) +} + +// --------------------------------------------------------------------------------------------- +// Comments. + +/// Frame `f` (0,0 400×300) with rectangle `r` (20,20) and nested frame `n` (200,20 150×150); +/// hidden frame `h` (600,0 100×100); loose rectangle `p` (800,0). +struct Scene { + f: NodeId, + n: NodeId, + r: NodeId, + h: NodeId, +} + +fn scene(s: &mut Session) -> Scene { + let f = frame(s, 0.0, 0.0, 400.0, 300.0, None); + let r = rect(s, 20.0, 20.0, Some(f)); + let n = frame(s, 200.0, 20.0, 150.0, 150.0, Some(f)); + let h = frame(s, 600.0, 0.0, 100.0, 100.0, None); + ex(s, "object.toggleVisible", json!({"ids": [h], "visible": false})); + rect(s, 800.0, 0.0, None); + ex(s, "select.none", json!({})); + Scene { f, n, r, h } +} + +#[test] +fn comments_pin_to_what_is_under_them() { + let mut s = Session::new(); + let k = scene(&mut s); + // (point, `anchor` param, pinned to, derivation) + let rows: Vec<((f64, f64), Option, Option, &str)> = vec![ + ((900.0, 500.0), Some(json!(true)), None, "a comment on empty canvas stays at its spot on the canvas"), + ((100.0, 200.0), Some(json!(true)), Some(k.f), "a comment dropped on a frame is pinned to it, so it moves with the frame"), + ((250.0, 50.0), Some(json!(true)), Some(k.n), "on a frame inside a frame, the innermost frame takes the pin"), + ((30.0, 30.0), Some(json!(true)), Some(k.f), "silent: over a layer inside a frame the frame takes the pin, not the layer (pinned)"), + ((820.0, 20.0), Some(json!(true)), None, "silent: a loose layer on the canvas is not a frame, the pin stays on the canvas (pinned)"), + ((650.0, 50.0), Some(json!(true)), None, "silent: a hidden frame takes no pin (pinned)"), + ((30.0, 30.0), Some(json!(k.r)), Some(k.r), "a comment can be pinned to a given layer and follows that layer"), + ((30.0, 30.0), Some(json!(k.h.0 + 10_000)), None, "a layer that doesn't exist pins nothing"), + ((100.0, 200.0), None, None, "without an anchor the pin stays on the canvas"), + ]; + for ((x, y), anchor, want, why) in rows { + let mut p = json!({"x": x, "y": y, "text": "Here"}); + if let Some(a) = anchor { + p["anchor"] = a; + } + let c = ex(&mut s, "comment.add", p)["id"].as_u64().unwrap(); + assert_eq!(comment(&s, c).anchor, want, "{why}"); + assert_eq!(at(&s, c), (x, y), "{why}: the pin sits where it was dropped"); + } +} + +#[test] +fn pins_follow_their_layer() { + let mut s = Session::new(); + let k = scene(&mut s); + let on_f = add(&mut s, 100.0, 200.0, "On the screen", "You"); + let on_n = add(&mut s, 250.0, 50.0, "On the card", "You"); + let loose = add(&mut s, 900.0, 500.0, "On the canvas", "You"); + let page = s.page().unwrap(); + // (command, params, expected pins of on_f, on_n, loose, derivation) + let rows: Vec<(&str, Value, [(f64, f64); 3], &str)> = vec![ + ( + "transform.move", + json!({"ids": [k.f], "dx": 50, "dy": 10}), + [(150.0, 210.0), (300.0, 60.0), (900.0, 500.0)], + "moving a frame moves its comments, and those of the frames inside it", + ), + ( + "transform.resize", + json!({"ids": [k.f], "width": 600, "anchor": "tl"}), + [(150.0, 210.0), (300.0, 60.0), (900.0, 500.0)], + "silent: resizing from the top-left keeps the pins at their offset from the frame's corner (pinned)", + ), + ( + "node.reparent", + json!({"ids": [k.n], "parent": page}), + [(150.0, 210.0), (300.0, 60.0), (900.0, 500.0)], + "taking a frame out of its parent keeps its pins on it, in place", + ), + ( + "transform.move", + json!({"ids": [k.n], "dx": 0, "dy": 100}), + [(150.0, 210.0), (300.0, 160.0), (900.0, 500.0)], + "the pin goes with its own frame, not with the one it used to be in", + ), + ( + "edit.delete", + json!({"ids": [k.n]}), + [(150.0, 210.0), (300.0, 160.0), (900.0, 500.0)], + "deleting the layer leaves its comment where it was (the conversation isn't lost)", + ), + ( + "transform.move", + json!({"ids": [k.f], "dx": -50, "dy": -10}), + [(100.0, 200.0), (300.0, 160.0), (900.0, 500.0)], + "a comment whose layer was deleted no longer moves with anything", + ), + ]; + for (cmd, p, want, why) in rows { + ex(&mut s, cmd, p); + assert_eq!([at(&s, on_f), at(&s, on_n), at(&s, loose)], want, "{why}"); + } + assert!(s.undo().unwrap() && s.undo().unwrap()); + assert_eq!(comment(&s, on_n).anchor, Some(k.n), "undoing the delete pins the comment to the layer again"); +} + +#[test] +fn threads_replies_and_resolving() { + let mut s = Session::new(); + let c = add(&mut s, 0.0, 0.0, "Can the button be bigger?", "Ada"); + // (command, params, check, derivation) + let rows: Vec<(&str, Value, fn(&Comment) -> bool, &str)> = vec![ + ( + "comment.reply", + json!({"id": c, "text": "Sure", "author": "Sam"}), + |c| c.replies.len() == 1, + "a reply joins the thread under the first message", + ), + ( + "comment.reply", + json!({"id": c, "text": "Done", "author": "Ada"}), + |c| c.replies.iter().map(|m| m.text.as_str()).eq(["Sure", "Done"]), + "replies stay in the order they were written", + ), + ("comment.resolve", json!({"id": c, "author": "Sam"}), |c| c.resolved, "anyone in the file can resolve a thread, not just its author"), + ("comment.resolve", json!({"id": c, "resolved": false}), |c| !c.resolved, "a resolved thread can be reopened"), + ("comment.resolve", json!({"id": c}), |c| c.resolved, "resolving again marks it resolved"), + ( + "comment.reply", + json!({"id": c, "text": "One more thing", "author": "Lin"}), + |c| !c.resolved && c.replies.len() == 3, + "silent: replying to a resolved thread reopens it (pinned)", + ), + ("comment.reply", json!({"id": c, "text": " "}), |c| c.replies.len() == 3, "an empty reply is not sent"), + ]; + for (cmd, p, check, why) in rows { + let _ = s.execute(cmd, &p); + assert!(check(&comment(&s, c)), "{why}"); + } + assert_eq!(comment(&s, c).participants(), vec!["Ada", "Sam", "Lin"], "everyone who wrote is in the thread"); + // Resolving is one undo step. + ex(&mut s, "comment.resolve", json!({"id": c})); + assert!(s.undo().unwrap()); + assert!(!comment(&s, c).resolved); +} + +#[test] +fn only_authors_edit_and_delete() { + // (command, params, allowed, derivation) + let rows: Vec<(&str, Value, bool, &str)> = vec![ + ("comment.edit", json!({"text": "Fixed typo", "author": "Ada"}), true, "you can edit your own comment"), + ("comment.edit", json!({"text": "Hijack", "author": "Sam"}), false, "you can't edit someone else's comment"), + ("comment.edit", json!({"reply": 0, "text": "Sure!", "author": "Sam"}), true, "replies are edited by whoever wrote them"), + ("comment.edit", json!({"reply": 0, "text": "No", "author": "Ada"}), false, "the thread's author can't edit others' replies"), + ("comment.delete", json!({"reply": 0, "author": "Ada"}), false, "silent without accounts: only a reply's author deletes it (pinned)"), + ("comment.delete", json!({"author": "Sam"}), false, "silent without accounts: only the thread's author deletes the thread (pinned)"), + ("comment.delete", json!({"reply": 0, "author": "Sam"}), true, "you can delete your own reply; the thread stays"), + ]; + let mut s = Session::new(); + let c = add(&mut s, 0.0, 0.0, "Fix tpyo", "Ada"); + ex(&mut s, "comment.reply", json!({"id": c, "text": "Sure", "author": "Sam"})); + for (cmd, mut p, allowed, why) in rows { + p["id"] = json!(c); + assert_eq!(s.execute(cmd, &p).is_ok(), allowed, "{why}"); + } + let t = comment(&s, c); + assert_eq!(t.first.text, "Fixed typo"); + assert!(t.first.edited > 0, "an edited comment says so"); + assert!(t.replies.is_empty()); + // Deleting the first message deletes the whole thread. + ex(&mut s, "comment.reply", json!({"id": c, "text": "Hm", "author": "Sam"})); + ex(&mut s, "comment.delete", json!({"id": c, "author": "Ada"})); + assert!(s.doc().unwrap().comment(c).is_none(), "deleting the first comment deletes the thread with its replies"); + assert!(s.undo().unwrap(), "deleting is one undo step"); + assert_eq!(comment(&s, c).replies.len(), 1); +} + +#[test] +fn mentions_and_reactions() { + let mut s = Session::new(); + let a = add(&mut s, 0.0, 0.0, "Looks good", "Sam"); + let b = add(&mut s, 50.0, 0.0, "@Ada can you check the spacing?", "Sam"); + let c = add(&mut s, 90.0, 0.0, "Note to self", "Lin"); + // (who, threads that are theirs, derivation) + let rows: Vec<(&str, Vec, &str)> = vec![ + ("Ada", vec![b], "a thread that @mentions you is one of yours"), + ("Sam", vec![b, a], "threads you started are yours"), + ("Lin", vec![c], "and nobody else's threads are"), + ("Nobody", vec![], "a mention is text: naming someone needs no account"), + ]; + for (who, want, why) in rows { + assert_eq!(ids(&mut s, "comment.list", json!({"filter": "mine", "author": who})), want, "{why}"); + } + // Reactions: (author, emoji, users after, derivation) + let rows: Vec<(&str, &str, Vec<&str>, &str)> = vec![ + ("Ada", "👍", vec!["Ada"], "anyone can react to a comment with an emoji"), + ("Lin", "👍", vec!["Ada", "Lin"], "the same emoji from someone else adds them to it"), + ("Ada", "👍", vec!["Lin"], "reacting again with the same emoji takes yours back"), + ("Lin", "👍", vec![], "the last one gone removes the reaction"), + ]; + for (who, emoji, users, why) in rows { + ex(&mut s, "comment.react", json!({"id": a, "emoji": emoji, "author": who})); + let r = comment(&s, a).first.reactions; + assert_eq!(r.first().map(|r| r.users.clone()).unwrap_or_default(), users, "{why}"); + } +} + +#[test] +fn comment_shortcuts() { + let m = |shift, alt, cmd, ctrl| Mods { shift, alt, cmd, ctrl }; + // (platform, key, modifiers, command, derivation) + let rows: Vec<(Platform, &str, Mods, &str, &str)> = vec![ + (Platform::Mac, "C", m(false, false, false, false), "tool.comment", "C picks the Comment tool"), + (Platform::Other, "C", m(false, false, false, false), "tool.comment", "C picks the Comment tool on Windows and Linux too"), + (Platform::Mac, "C", m(true, false, false, false), "view.comments", "Shift+C shows and hides comments on the canvas"), + (Platform::Mac, "S", m(false, true, true, false), "file.saveVersion", "Alt+Cmd+S saves to version history"), + (Platform::Other, "S", m(false, true, false, true), "file.saveVersion", "Ctrl+Alt+S saves to version history"), + ]; + for (p, key, mods, want, why) in rows { + assert_eq!(lookup(p, Context::Canvas, key, mods).map(|b| b.id), Some(want), "{why}"); + } + // A click with the Comment tool starts a new comment there; Shift+C's flag toggles. + let mut s = Session::new(); + ex(&mut s, "tool.comment", json!({})); + let down = crate::tools::PointerEvent::at(crate::tools::PointerKind::Down, 120.0, 80.0, 1.0); + s.pointer(&down).unwrap(); + let req = s.ui_requests.iter().rev().find(|r| r.kind == "comment.new").expect("a new comment is asked for"); + assert_eq!((req.data["x"].as_f64(), req.data["y"].as_f64()), (Some(120.0), Some(80.0))); + assert!(s.ds().unwrap().view.comments, "silent: comments show on the canvas by default (pinned)"); + ex(&mut s, "view.comments", json!({})); + assert!(!s.ds().unwrap().view.comments, "the toggle hides them"); + ex(&mut s, "view.comments", json!({})); + assert!(s.ds().unwrap().view.comments, "and shows them again"); +} + +/// Threads on two pages: `a` (oldest) and `b` on page 1, `c` on page 2 (newest); `b` resolved. +fn threads(s: &mut Session) -> (u64, u64, u64, NodeId, NodeId) { + let p1 = s.page().unwrap(); + let a = add(s, 0.0, 0.0, "Spacing", "Sam"); + let b = add(s, 900.0, 900.0, "Colour", "Ada"); + let p2 = id(&ex(s, "page.add", json!({"name": "Two"}))); + ex(s, "page.select", json!({"id": p2})); + let c = add(s, 10.0, 10.0, "Copy on page two", "Sam"); + ex(s, "page.select", json!({"id": p1})); + // Distinct times: the newest activity is listed first. + for (i, t) in [(a, 100), (b, 200), (c, 300)] { + s.doc_mut().unwrap().comment_mut(i).unwrap().first.created = t; + } + ex(s, "comment.resolve", json!({"id": b})); + (a, b, c, p1, p2) +} + +#[test] +fn filtering_and_searching_comments() { + let mut s = Session::new(); + let (a, b, c, _, p2) = threads(&mut s); + // (params, listed, derivation) + let rows: Vec<(Value, Vec, &str)> = vec![ + (json!({}), vec![b, a], "the list shows the current page, newest activity first"), + (json!({"page": "all"}), vec![c, b, a], "it can show every page"), + (json!({"page": p2}), vec![c], "or another page"), + (json!({"filter": "open"}), vec![a], "resolved threads can be hidden"), + (json!({"filter": "resolved"}), vec![b], "or shown on their own"), + (json!({"page": "all", "sort": "page"}), vec![b, a, c], "sorting by page groups threads in page order"), + (json!({"page": "all", "query": "colour"}), vec![b], "search finds a thread by its text, in any case"), + (json!({"page": "all", "query": "sam"}), vec![c, a], "and by who wrote it"), + ]; + for (p, want, why) in rows { + assert_eq!(ids(&mut s, "comment.list", p), want, "{why}"); + } +} + +#[test] +fn stepping_between_comments() { + let mut s = Session::new(); + let (a, b, c, p1, p2) = threads(&mut s); + let opened = |s: &mut Session, p: Value| ex(s, "comment.next", p)["id"].as_u64().unwrap(); + // (params, opened, page shown after, derivation) + let rows: Vec<(Value, u64, NodeId, &str)> = vec![ + (json!({}), b, p1, "stepping starts at the newest thread on the page"), + (json!({"id": b}), a, p1, "next goes on down the list"), + (json!({"id": a}), a, p1, "silent: past the last thread it stays on the last (pinned)"), + (json!({"id": a, "dir": -1}), b, p1, "previous goes back up the list"), + (json!({"id": b, "filter": "open"}), a, p1, "the list's filter decides what is stepped through"), + (json!({"page": "all"}), c, p2, "across all pages the view goes to the thread's page"), + ]; + for (p, want, page, why) in rows { + assert_eq!(opened(&mut s, p), want, "{why}"); + assert_eq!(s.page().unwrap(), page, "{why}"); + } + ex(&mut s, "comment.markRead", json!({"author": "Sam"})); + assert!(s.execute("comment.next", &json!({"filter": "unread", "author": "Sam"})).is_err(), "nothing to step through says so"); +} + +#[test] +fn comments_survive_saving_and_stay_put_on_copy_paste() { + let mut s = Session::new(); + let k = scene(&mut s); + let c = add(&mut s, 100.0, 200.0, "Pinned", "Ada"); + ex(&mut s, "comment.reply", json!({"id": c, "text": "Agreed @Ada", "author": "Sam"})); + ex(&mut s, "comment.react", json!({"id": c, "emoji": "🎉", "author": "Lin"})); + ex(&mut s, "comment.resolve", json!({"id": c})); + let loose = add(&mut s, 900.0, 900.0, "Loose", "Ada"); + let before = s.doc().unwrap().comments.clone(); + + // (command, params, derivation): none of these copies or moves a comment. + let rows: Vec<(&str, Value, &str)> = vec![ + ("edit.copy", json!({"ids": [k.f]}), "copying a frame copies its layers"), + ("edit.paste", json!({"x": 1000, "y": 0}), "silent: a pasted frame comes without the original's comments (pinned)"), + ("edit.duplicate", json!({"ids": [k.f]}), "silent: a duplicated frame comes without them too (pinned)"), + ("page.duplicate", json!({}), "silent: a duplicated page comes without its comments (pinned)"), + ]; + for (cmd, p, why) in rows { + ex(&mut s, cmd, p); + assert_eq!(s.doc().unwrap().comments, before, "{why}"); + } + + let (dir, path) = temp_path("comments"); + ex(&mut s, "file.save", json!({"path": path})); + let mut t = Session::empty(); + ex(&mut t, "file.open", json!({"path": path})); + assert_eq!(t.doc().unwrap().comments, before, "threads, replies, reactions, resolved state and pins are saved in the file"); + assert_eq!(comment(&t, c).anchor, Some(k.f)); + // A pin reopened still follows its frame. + ex(&mut t, "transform.move", json!({"ids": [k.f], "dx": 10, "dy": 0})); + assert_eq!((at(&t, c), at(&t, loose)), ((110.0, 200.0), (900.0, 900.0))); + let _ = std::fs::remove_dir_all(&dir); +} + +// --------------------------------------------------------------------------------------------- +// Version history. + +#[test] +fn autosaves_and_named_versions() { + let mut s = Session::new(); + // (command, params, versions after as (name, auto), derivation) + let rows: Vec<(&str, Value, Vec<(&str, bool)>, &str)> = vec![ + ("version.autosave", json!({}), vec![], "no checkpoint while nothing has changed"), + ("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}), vec![], "editing alone saves no version"), + ("version.autosave", json!({}), vec![("Autosave", true)], "after changes the file is checkpointed automatically"), + ("version.autosave", json!({}), vec![("Autosave", true)], "no new checkpoint without new changes"), + ( + "file.saveVersion", + json!({"name": "Hand-off", "description": "for review"}), + vec![("Autosave", true), ("Hand-off", false)], + "a version can be saved by hand with a title and a description", + ), + ( + "file.saveVersion", + json!({}), + vec![("Autosave", true), ("Hand-off", false), ("Version 3", false)], + "silent: a version saved without a title is numbered (pinned)", + ), + ( + "version.rename", + json!({"index": 0, "name": "Before the redesign"}), + vec![("Before the redesign", false), ("Hand-off", false), ("Version 3", false)], + "giving an autosave a title makes it a named version", + ), + ]; + for (cmd, p, want, why) in rows { + ex(&mut s, cmd, p); + let got: Vec<(String, bool)> = versions(&mut s).iter().map(|v| (v["name"].as_str().unwrap().to_string(), v["auto"] == true)).collect(); + let want: Vec<(String, bool)> = want.into_iter().map(|(n, a)| (n.to_string(), a)).collect(); + assert_eq!(got, want, "{why}"); + } + assert_eq!(versions(&mut s)[1]["description"], "for review"); + // A named autosave is kept when autosaves make room for new ones. + for i in 0..(crate::cmd::file::MAX_AUTOSAVES + 2) { + ex(&mut s, "shape.rectangle", json!({"x": i * 20, "y": 50, "width": 10, "height": 10})); + ex(&mut s, "version.autosave", json!({})); + } + assert_eq!(versions(&mut s)[0]["name"], "Before the redesign", "versions you named are never dropped to make room"); + // The history lists versions in the order they were saved. + let times: Vec = versions(&mut s).iter().map(|v| v["time"].as_u64().unwrap()).collect(); + assert!(times.windows(2).all(|w| w[0] <= w[1]), "oldest first in the list (the panel shows it newest first)"); +} + +#[test] +fn restoring_a_version() { + let mut s = Session::new(); + ex(&mut s, "file.rename", json!({"name": "Shop"})); + let a = rect(&mut s, 0.0, 0.0, None); + let v1 = ex(&mut s, "file.saveVersion", json!({"name": "One box"})); + let b = rect(&mut s, 100.0, 0.0, None); + let c = + ex(&mut s, "comment.add", json!({"x": 120, "y": 20, "text": "Is this box needed?", "author": "Sam", "anchor": b}))["id"].as_u64().unwrap(); + assert_eq!(comment(&s, c).anchor, Some(b)); + ex(&mut s, "comment.reply", json!({"id": c, "text": "Yes", "author": "Ada"})); + ex(&mut s, "file.rename", json!({"name": "Shop v2"})); + let n = versions(&mut s).len(); + + ex(&mut s, "version.restore", json!({"id": v1["id"]})); + let doc = s.doc().unwrap(); + // (holds, derivation) + let rows: Vec<(bool, &str)> = vec![ + (doc.contains(a) && !doc.contains(b), "restoring puts the design back as it was in that version"), + (doc.name == "Shop v2", "silent: the file keeps its current name (pinned)"), + (doc.comment(c).is_some_and(|c| c.replies.len() == 1), "comments are kept apart from the design's history: restoring leaves them"), + (doc.comment(c).is_some_and(|c| c.anchor.is_none()), "a comment on a layer the version doesn't have stays on the canvas"), + ]; + for (holds, why) in rows { + assert!(holds, "{why}"); + } + assert_eq!(at(&s, c), (120.0, 20.0), "where it was"); + let l = versions(&mut s); + assert_eq!(l.len(), n + 1, "restoring never loses work: the state before it is kept in the history first"); + let kept = l.last().unwrap(); + assert_eq!(kept["auto"], true); + ex(&mut s, "version.preview", json!({"id": kept["id"]})); + assert!(s.doc().unwrap().contains(b), "the kept checkpoint holds what was there before restoring"); + ex(&mut s, "version.exitPreview", json!({})); + // Restoring again straight away doesn't checkpoint the restored state a second time. + ex(&mut s, "version.restore", json!({"id": v1["id"]})); + assert_eq!(versions(&mut s).len(), n + 1); + // Restoring is one undo step (twice here). + assert!(s.undo().unwrap() && s.undo().unwrap()); + assert!(s.doc().unwrap().contains(b)); +} + +#[test] +fn duplicating_and_viewing_versions() { + let mut s = Session::new(); + ex(&mut s, "file.rename", json!({"name": "App"})); + let a = rect(&mut s, 0.0, 0.0, None); + let v = ex(&mut s, "file.saveVersion", json!({"name": "Start"})); + let b = rect(&mut s, 100.0, 0.0, None); + // Viewing a version shows it read-only. + ex(&mut s, "version.preview", json!({"id": v["id"]})); + assert!(!s.doc().unwrap().contains(b), "viewing a version shows the file as it was then"); + assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err(), "a version being viewed can't be edited"); + ex(&mut s, "version.exitPreview", json!({})); + assert!(s.doc().unwrap().contains(b), "going back shows the current file, unchanged"); + // Duplicating a version opens it as a new file. + let d = ex(&mut s, "version.duplicate", json!({"id": v["id"]})); + assert_eq!(s.active, d["index"].as_u64().unwrap() as usize); + let doc = s.doc().unwrap(); + assert!(doc.contains(a) && !doc.contains(b), "a version can be duplicated into a new file"); + assert!(s.ds().unwrap().versions.is_empty(), "the new file starts its own history"); + assert!(s.ds().unwrap().path.is_none()); + // So does duplicating the whole file. + ex(&mut s, "doc.switch", json!({"index": 0})); + ex(&mut s, "file.duplicate", json!({})); + assert!(s.ds().unwrap().versions.is_empty(), "silent: a duplicated file starts its own history (pinned)"); +} + +#[test] +fn versions_survive_saving() { + let mut s = Session::new(); + rect(&mut s, 0.0, 0.0, None); + ex(&mut s, "version.autosave", json!({})); + rect(&mut s, 50.0, 0.0, None); + ex(&mut s, "file.saveVersion", json!({"name": "Named", "description": "two boxes"})); + rect(&mut s, 100.0, 0.0, None); + ex(&mut s, "version.autosave", json!({})); + ex(&mut s, "version.rename", json!({"index": 2, "name": "Three"})); + let before = versions(&mut s); + let (dir, path) = temp_path("versions"); + ex(&mut s, "file.save", json!({"path": path})); + let mut t = Session::empty(); + ex(&mut t, "file.open", json!({"path": path})); + assert_eq!(versions(&mut t), before, "names, descriptions, times, order and the autosave flag are saved in the file"); + // Save as keeps the history too. + let (dir2, path2) = temp_path("versions-as"); + ex(&mut t, "file.saveAs", json!({"path": path2})); + let mut u = Session::empty(); + ex(&mut u, "file.open", json!({"path": path2})); + assert_eq!(versions(&mut u), before); + ex(&mut u, "version.restore", json!({"index": 0})); + assert_eq!(u.doc().unwrap().node_count(), t.doc().unwrap().node_count() - 2, "a version read from the file restores"); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dir2); +} + +#[test] +fn branches_and_the_version_history() { + let mut s = Session::with_document(crate::collab_tests::base()); + ex(&mut s, "file.saveVersion", json!({"name": "Before branching"})); + let b = ex(&mut s, "branch.create", json!({"name": "Explore"})); + let branch = b["id"].as_str().unwrap().to_string(); + // On the branch tab. + assert!(s.execute("file.saveVersion", &json!({})).is_err(), "not yet: a branch has no version history of its own (gap 17)"); + 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); + let n = versions(&mut s).len(); + ex(&mut s, "branch.merge", json!({"id": branch})); + let l = versions(&mut s); + // (holds, derivation) + let rows: Vec<(bool, &str)> = vec![ + (l.len() == n + 2, "merging a branch adds to the main file's history: what was there before, then the merge"), + (l.get(n).is_some_and(|v| v["auto"] == true), "the main file as it was before the merge is kept"), + (l.get(n + 1).is_some_and(|v| v["name"] == "Merged “Explore”" && v["auto"] != true), "the merge is a named version, after the branch"), + ]; + for (holds, why) in rows { + assert!(holds, "{why}: {l:?}"); + } + let before_merge = l[n]["id"].clone(); + ex(&mut s, "version.preview", json!({"id": before_merge})); + assert!(s.doc().unwrap().contains(made_on_main) && s.doc().unwrap().node_count() > 0); + ex(&mut s, "version.exitPreview", json!({})); + // The file can go back to before the merge by restoring that version. + let count = s.doc().unwrap().node_count(); + ex(&mut s, "version.restore", json!({"id": before_merge})); + assert_eq!(s.doc().unwrap().node_count(), count - 1, "restoring the version before the merge takes the branch's work out"); +} + +// --------------------------------------------------------------------------------------------- +// Live sessions. + +#[test] +fn comments_and_restores_in_a_live_session() { + use crate::collab_live_tests::{Room, frame_and_rect}; + let mut room = Room::new(crate::collab_tests::base(), 2, 7); + let (f, _) = frame_and_rect(room.server.document()); + let c = ex(room.s(0), "comment.add", json!({"x": 50, "y": 50, "text": "Thoughts?", "author": "Ada", "anchor": true}))["id"].as_u64().unwrap(); + room.settle(); + // (person, command, params, derivation) + let rows: Vec<(usize, &str, Value, &str)> = vec![ + (1, "comment.reply", json!({"id": c, "text": "Bigger", "author": "Sam"}), "a reply from someone else reaches everyone"), + (1, "comment.react", json!({"id": c, "emoji": "👍", "author": "Sam"}), "so does a reaction"), + (0, "comment.resolve", json!({"id": c}), "and resolving"), + ]; + for (who, cmd, p, why) in rows { + ex(room.s(who), cmd, p); + room.settle(); + room.check(); + let mine = room.s(0).doc().unwrap().comment(c).cloned(); + assert_eq!(mine.as_ref(), room.s(1).doc().unwrap().comment(c), "{why}"); + } + assert_eq!(room.s(1).doc().unwrap().comment(c).unwrap().anchor, Some(f), "the pin is on the same frame for everyone"); + // A version is the saving person's own (versions aren't shared in a session, by design for now). + let v = ex(room.s(0), "file.saveVersion", json!({"name": "Shared?"})); + assert!(versions(room.s(1)).is_empty(), "silent here: versions stay with whoever saved them (shared history needs accounts)"); + ex(room.s(1), "transform.move", json!({"ids": [f], "dx": 40, "dy": 0})); + let d = ex(room.s(1), "comment.add", json!({"x": 900, "y": 900, "text": "Later", "author": "Sam"}))["id"].as_u64().unwrap(); + room.settle(); + // Restoring in a session changes the design for everyone and keeps everyone's comments. + ex(room.s(0), "version.restore", json!({"id": v["id"]})); + room.settle(); + room.check(); + let doc = room.s(1).doc().unwrap(); + let x = doc.world_bounds(f).unwrap().x0; + assert_eq!(x, 0.0, "the restore reached everyone"); + assert!(doc.comment(c).is_some() && doc.comment(d).is_some(), "comments made since the version are kept"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d629f54..401fae0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -296,8 +296,9 @@ pub struct DocState { /// The document under a live text preview (a hovered text style or font instance, a dragged /// axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. pub text_preview: Option>, - /// The document as of the last version saved (or as opened): autosaves only follow changes. - checkpoint: Arc, + /// The document as of the last version saved or restored (none yet): autosaves, restores and + /// merges only add a checkpoint for changes since. + checkpoint: Option>, /// Dev Mode focus view: the frame or section shown alone on the canvas (see [`Self::focused`]). pub focus: Option, /// The ruler guide clicked last: its page or frame and index (see [`Self::selected_guide`]). @@ -334,7 +335,7 @@ impl DocState { let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0)); let doc = Arc::new(doc); let mut st = Self { - checkpoint: doc.clone(), + checkpoint: None, saved_doc: doc.clone(), doc, selection: Vec::new(), @@ -398,14 +399,16 @@ impl DocState { &mut self.versions } - /// The document changed since the last version was saved (or since it was opened). + /// The document changed since the last version was saved or restored (always, before one). pub fn changed_since_checkpoint(&self) -> bool { - !Arc::ptr_eq(self.preview.as_ref().map_or(&self.doc, |p| &p.doc), &self.checkpoint) + let doc = self.preview.as_ref().map_or(&self.doc, |p| &p.doc); + // Normalising after a command can copy a document without changing it: compare contents then. + self.checkpoint.as_ref().is_none_or(|c| !Arc::ptr_eq(doc, c) && **doc != **c) } - /// Note that a version of the current document was just saved. + /// Note that a version of the current document was just saved (or restored). pub(crate) fn mark_checkpoint(&mut self) { - self.checkpoint = self.doc.clone(); + self.checkpoint = Some(self.doc.clone()); } /// Push a snapshot undo step: the document was `before` (shown with `selection` on `page`). @@ -1294,6 +1297,8 @@ mod fx_behaviour_tests; #[cfg(test)] mod gesture_preview_tests; #[cfg(test)] +mod history_behaviour_tests; +#[cfg(test)] mod html_codegen_tests; #[cfg(test)] mod import_export_tests; diff --git a/crates/engine/src/version_tests.rs b/crates/engine/src/version_tests.rs index 1f61546..8312d94 100644 --- a/crates/engine/src/version_tests.rs +++ b/crates/engine/src/version_tests.rs @@ -56,7 +56,7 @@ fn save_list_restore_and_undo() { // By index. s.execute("version.restore", &json!({"index": 1})).unwrap(); assert_eq!(count(&s), one + 1); - assert_eq!(list(&mut s).len(), 2, "restoring keeps the history"); + assert_eq!(list(&mut s).len(), 3, "restoring keeps the history (plus a checkpoint of what the first restore replaced)"); } #[test] @@ -168,6 +168,7 @@ fn versions_persist_in_the_file() { assert!(t.ds().unwrap().is_dirty()); t.execute("file.save", &json!({})).unwrap(); t.execute("file.revert", &json!({})).unwrap(); - assert!(list(&mut t).is_empty()); + // Only the checkpoint the restore made of the file as opened is left. + assert_eq!(list(&mut t).iter().map(|v| v["auto"] == true).collect::>(), vec![true]); let _ = std::fs::remove_dir_all(&dir); } diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index aa947e8..6db367d 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-11 · **Change:** minor (hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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, @@ -16,8 +16,8 @@ cursors, selections and text carets shown live ([`collab-protocol.md`](collab-pr | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **partial, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; no spotlight | 8–15 | | Cursor chat, audio, high-fives | yes | no | 4–8 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. No accounts, teams or projects | 8–17 | -| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences | (shared once multiplayer lands) | -| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file | — | +| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | +| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived), update from main, archive. Left: a branch's own version history, list properties (fills, text runs) merged item by item, branches in live sessions | 1–3 | | Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 | | Notifications, activity | yes | no | with sharing | @@ -26,6 +26,7 @@ cursors, selections and text carets shown live ([`collab-protocol.md`](collab-pr | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions; percentages and hours unchanged here (comments and versions are graded with feature area 16 in target-app-parity) | | 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | | 2026-10-11 | minor | Team libraries on `figcraft-server` (gap 10): a file's library is published to the server under the file and enabled in other files by a share link, updates are noticed in the background and accepted with overrides kept, the server counts which files use which components, and a library can be swapped for another by names; team libraries 10–20 h → 4–8 h; ready ~12% → ~13% | | 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | diff --git a/docs/gaps.md b/docs/gaps.md index 9e6656d..7682cce 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-11 · **Change:** minor (canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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 @@ -93,7 +93,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe (e.g. Ctrl to ignore snapping on macOS), hover states, popovers, menus, snapping thresholds. - **Landed (2026-10-11):** behaviour suites written from the public documentation for auto layout, selection and transform, text, and canvas navigation with rulers, guides, layout grids, outline - mode and measuring (`crates/engine/src/*_behaviour_tests.rs`), each with its fixes. + mode and measuring, then (2026-10-12) comments and version history + (`crates/engine/src/*_behaviour_tests.rs`), each with its fixes. - **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). @@ -293,6 +294,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Comments and version history behaviour suite and fixes added to gap 4's landed list (gap 17 unchanged: a branch's own version history is still left); ranking and hours unchanged | | 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | | 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Gap 1 30–60 h → 8–15 h (vector networks, other layer kinds, real-file checks; export 15–30 h on top, as before) | | 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | diff --git a/docs/mcp.md b/docs/mcp.md index f25ce0b..7bbbbc3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -695,7 +695,8 @@ sort? (date|page), query?, frame?}` returns the threads with their position, `fr `figcraft://comment/`: `comment.link {id}` returns one, `comment.open {id | link, view?}` goes to its page, zooms to its frame (`fit`, the default; `center` keeps the zoom; `keep` doesn't move) and opens it in the app, and `comment.next {id?, dir?, …list params}` steps to the next or previous -thread of a list the same way. `comment.zoom {ids}` zooms in on a group of pins. +thread of a list the same way. `comment.zoom {ids}` zooms in on a group of pins. Comment ids made in +a live session are large numbers (their creator's id space): pass them back as given. ### Version history @@ -703,8 +704,11 @@ Named snapshots of the document, kept in the `.figcraft` file (they persist once a new, renamed or deleted version marks the file as changed). Driven with `run_command`: `file.saveVersion {name?, description?}` (returns `{id, index, name, description, time}`; untitled versions are named "Version N"), `version.list` (oldest first; `time` is Unix seconds), -`version.restore {id | index}` (replaces the document with the version as one undo step; the file -name stays), `version.rename {id | index, name, description?}`, `version.delete {id | index}` and +`version.restore {id | index}` (replaces the design with the version as one undo step; the file +name and the comments stay, comments on layers the version lacks staying where they were, and the +design it replaces is first kept as an autosave unless a version of it was just saved or restored), +`version.rename {id | index, name, description?}` (a titled autosave becomes a named version), +`version.delete {id | index}` and `version.duplicate {id | index}` (opens the version as a new document tab). `version.preview {id | index}` shows a version in place of the document, read-only: edits, undo and gestures are refused until `version.restore` keeps it or `version.exitPreview` goes back. Saving, @@ -713,7 +717,10 @@ closing or saving a version ends the preview first, and `version.list` marks the (a negative or out-of-range index is an error). A file keeps at most 100 versions. `version.autosave` saves an automatic checkpoint (`auto: true`, named "Autosave") when the document has unsaved changes since its last version; the app runs it every 30 minutes for each open document, -and a file keeps the newest 20 autosaves (user versions are never dropped for them). `file.versionHistory` returns the same list and opens the panel in the app. +and a file keeps the newest 20 autosaves (user versions are never dropped for them). Merging a +branch adds the main file as it was (an autosave, when it changed since its last version) and then +`Merged “”`. `file.versionHistory` returns the same list and opens the panel in the app. +Versions stay with the person who saved them: in a live session they aren't shared. ### Branches diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ad6ad9f..d315548 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) | | Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 71.0% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~170–305 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~306–595 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~306–594 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -35,7 +35,7 @@ tool; presence was being graded as behaviour in places. | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~51% | ~296–570 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~51% | ~296–569 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~49% | ~131–245 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | @@ -109,20 +109,20 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 13 | Prototyping | 6 | 90% | 74% | Video triggers and media actions, gamepad triggers, delays on mouse triggers, manual overlay offsets, real prototype QA (behaviour suite from the documentation, key combinations, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 3–6 | | 14 | Import/export | 6 | 84% | 57% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | | 15 | Dev Mode / inspect | 2 | 93% | 70% | Dev resources links, import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect and plugins in Dev Mode landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–3 | -| 16 | Comments, versions, find, misc | 3 | 85% | 60% | Comments are local only; a branch's own version history (branches with review and merge landed) | 2–5 | +| 16 | Comments, versions, find, misc | 3 | 85% | 62% | Comments are local only; a branch's own version history; comparing two versions (branches with review and merge, and a comments and version history behaviour suite from the documentation landed) | 2–4 | | 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat ([collaboration-parity](collaboration-parity.md)) | 45–90 | | 18 | Plugins & widgets | 4 | 45% | 28% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples), then plugin panels called back per button or changed value and per-plugin settings (ABI v2). Left: distribution, widgets; panels one at a time without a colour picker; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 15–40 | | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 80% | 55% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses). Missing: multi-edit, pressure from macOS/Linux tablets | 2–5 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~82%** | **~61%** | | **148–309** | -| | Core editor only (1–16, ÷94) | 94 | ~93% | ~70% | | 46–95 | +| | **Weighted (÷113)** | 113 | **~82%** | **~61%** | | **148–308** | +| | Core editor only (1–16, ÷94) | 94 | ~93% | ~70% | | 46–94 | ## By dimension (ready for real work) | Dimension | Weight | Ready | Hours | Evidence | Doc | |---|---|---|---|---|---| -| Features (depth) | 35 | 61% | 46–94 (areas 1–13, 15, 16, 20) | The table above; core editor ~70% | this file | +| Features (depth) | 35 | 61% | 46–93 (areas 1–13, 15, 16, 20) | The table above; core editor ~70% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 59% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~51%** | **~306–595** | | | +| **Ready for real work** | 100 | **~51%** | **~306–594** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration and is left out as an area, but its effect on working with Figma users is counted in discount 3. -**2. Average depth** ("Ready" column, weighted): **71.0%**. +**2. Average depth** ("Ready" column, weighted): **71.1%**. **3. Discounts for what still stops real work:** @@ -197,7 +197,7 @@ and is left out as an area, but its effect on working with Figma users is counte | Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way | | File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) but writes nothing Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) | -71.0% × 0.90 × 0.90 × 0.85 = **~49%** (estimated). +71.1% × 0.90 × 0.90 × 0.85 = **~49%** (estimated). **4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is @@ -457,7 +457,10 @@ Hours in this narrative are the builders' estimates; the tables above supersede Shift+C: read and reply). All of it through `comment.*` commands. Version history landed (named versions saved in the `.figcraft` file; restore, rename, duplicate, delete; panel; view a version on the canvas read-only before restoring it; autosave checkpoints every 30 - minutes, folded in the panel). Real-time collaboration is designed (`docs/collab-protocol.md`: + minutes, folded in the panel). A behaviour suite written from the public documentation covers + both: restoring keeps the comments and first keeps the replaced design as an autosave, a titled + autosave becomes a named version, merging a branch records the main file before and after, and + threads made in a live session can be replied to and resolved. Real-time collaboration is designed (`docs/collab-protocol.md`: WebSocket sessions the app can host itself, server-ordered per-property changes, live cursors) and its core landed (`crates/collab`, no networking yet): client-scoped node ids, every saved layer field and the document-level data (styles, variables, comments, images…) as synced @@ -602,6 +605,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, find, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h; mainstream weekly areas 71.0% → 71.1%; percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | | 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h; feature breadth (~80%), ready for real work (~49%), core editor (~68%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~320–615 h → ~319–614 h | | 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53%; Dev Mode 90%/64% → 93%/70%; file formats 55% → 56%; mainstream weekly areas 68.1% → 68.4%; feature breadth (~80%), ready for real work (~49%), essentials (~57%), mainstream (~46%) and full parity hours unchanged at this rounding | | 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h |