diff --git a/ROADMAP.md b/ROADMAP.md index 407be191d..ecfa46b83 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (`.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (`.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -356,6 +356,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-30 | minor | M8, #1077: two quadratic costs in the `.fig` import's instance expansion. Variable and collection nodes sat in the layer tree, so every instance of a main walked the ones under it again, and as they never become layers the layer cap never stopped them; they are now left out of the tree (variables are read from the node list as before), so everything an expansion visits counts against the cap. A nested instance copied all of its outer instance's override rows, and its property values and slot contents; it now takes only the rows addressed below it (they sort together, so the rest aren't visited) and shares the outer values, keeping only its own. Normal files map as before. Crafted files: 20 000 variables under a main with 2 000 instances 13.8 s → 21 ms; 50 000 override rows over 2 000 nested instances 40.5 s → 98 ms (native release, the same output). Numbers unchanged. Tests: `expansion_cost_tests`. | +| 2026-10-30 | minor | M1: desktop recovery copies made while a version is previewed held the version shown instead of the file, so a crash during a preview brought the old version back as the unsaved file. What a recovery copy holds is now one shared helper, `figcraft_engine::branches::recovery_copy`: the document itself (`DocState::document_itself`, the same document the unsaved-changes check compares, not the version or live text preview shown in its place), its version history and its branches. The desktop copies and the browser's (#1580, which already took the document itself) both use it. Tests: `recovery_tests.rs` in the desktop and web apps (a copy made during a version preview recovers the file and its history). | +| 2026-10-30 | minor | M12, #1342: a FigJam `.jam` file opens as a board, our whiteboard file kind, instead of a design file: the `.fig` reader marks a canvas with FigJam's `fig-jam.` header as `FileKind::Board`, so the file opens with the board toolbar, the dot grid and the board panels, and saves as a board. A `.fig` stays a design file, and `file.import` of a `.jam` adds its pages to the open file without changing its kind. Its stickies and shapes with text are still frames with their generated layers and its connectors vectors, not native board objects. Tests: `board_import_tests.rs` (the kind, `.jam` against `.fig`), `jam_open_tests.rs` (open as a board, import into a design file). | +| 2026-10-30 | minor | M6, #1342: FigJam `.jam` files open. A `.jam` is the same archive as a `.fig` (its canvas header reads `fig-jam.`), and the `.fig` reader already took it when a copy was renamed; now File ▸ Open, its file filter, drag and drop (home screen and canvas), `file.import` and the command line take `.jam` too, and the home screen's recent files show their thumbnails. What comes in is what the `.fig` reader makes of FigJam layers: stickies and shapes with text as frames with their generated layers, tables, stamps and the like as frames with an import note, connectors as vectors, and a layer type it doesn't know left out with a note naming it. The file opens as a design file, not a board, and its objects carry no board metadata yet. Tests: a synthetic `fig-jam.` canvas, bare and zipped (`board_import_tests.rs`), a `.jam` made from our own `.fig` export opened and imported (`jam_open_tests.rs`), the extension checks (`home_tests.rs`). | | 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. | | 2026-10-30 | minor | M9, #1525: share links can comment. A third access level, `comment`, sits between `edit` and `view`: someone who joins with a comment link (or its join code) sees the file live, as with a view link, and adds comment threads, replies, reactions and resolves them; any other edit is refused, by their app and by the server, which accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones, and the Share popover offers Can edit, Can comment and Can view; the toolbar shows a "Can comment" badge with Move, Hand and Comment. `collab.share {access: "comment"}`, `collab.manageFile {action: createLink, access: "comment"}` and `figcraft-server links create FILE --access comment` make and pick it. Older apps are unaffected: one that joins with a comment link is welcomed as a viewer, links listed to it read as view links, and a level an app doesn't know reads as view; an older server makes no comment link and sharing goes on without one. Comment authorship is the name in Preferences, as before (no accounts), so a commenter can edit or remove other people's threads. Tests: `comment_access_tests.rs` (collab), `comment_link_tests.rs` (engine, server end to end), the Share popover and toolbar in `live_share_tests.rs` and `live_server_tests.rs`. | | 2026-10-30 | minor | M1, #1390: Preferences ▸ Scroll wheel zoom (`pref.scrollWheelZoom`, off by default, kept with the preferences). On, a mouse wheel zooms the canvas about the pointer at the rate Cmd/Ctrl+wheel zooms otherwise, and Cmd/Ctrl+wheel scrolls; Shift+wheel still scrolls sideways, and trackpad scrolls, pinches and touch screens are unchanged. A wheel is told from a trackpad by what the platform reports: macOS names the device; elsewhere a wheel scrolls by lines and a trackpad by pixels, so a mouse whose wheel reports pixels (Chromium-based browsers) keeps scrolling, and a trackpad that reports lines (some X11 setups) zooms. Numbers unchanged. Tests: `wheel_zoom_tests`. | diff --git a/apps/figcraft-web/src/recovery.rs b/apps/figcraft-web/src/recovery.rs index 48e5bb4d1..04d194038 100644 --- a/apps/figcraft-web/src/recovery.rs +++ b/apps/figcraft-web/src/recovery.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use base64::Engine as _; -use figcraft_engine::branches::{self, needs_copy, stamp}; +use figcraft_engine::branches::{needs_copy, recovery_copy, stamp}; use figcraft_engine::doc::Document; use figcraft_engine::{DocState, Session}; use figcraft_format::{Branch, Version}; @@ -26,8 +26,8 @@ struct Copy { impl Copy { fn of(s: &Session, d: &DocState) -> Self { - let (versions, branches) = (d.versions.clone(), branches::current(s, d.uid)); - Self { uid: d.uid, stamp: stamp(s, d), doc: d.document_itself().clone(), versions, branches } + let (doc, versions, branches) = recovery_copy(s, d); + Self { uid: d.uid, stamp: stamp(s, d), doc, versions, branches } } } diff --git a/apps/figcraft-web/src/recovery_tests.rs b/apps/figcraft-web/src/recovery_tests.rs index 1073ad2e1..f66a15711 100644 --- a/apps/figcraft-web/src/recovery_tests.rs +++ b/apps/figcraft-web/src/recovery_tests.rs @@ -96,3 +96,20 @@ fn the_stopped_page_says_what_was_kept() { says(2, 0, "too large for the browser to keep"); says(3, 1, "the browser had room for"); } + +#[test] +fn a_copy_taken_while_a_version_is_previewed_is_of_the_file() { + let mut s = edited("Poster"); + s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap(); + s.execute("file.rename", &json!({"name": "Later"})).unwrap(); + // Version v1 (named Poster) shows in place of the file. + s.execute("version.preview", &json!({"index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().name, "Poster", "the preview shows"); + let mut u = Unsaved::default(); + u.follow(&s); + let mut next = Session::new(); + assert_eq!(restore(&mut next, stored(&u)), 1); + let d = next.ds().unwrap(); + assert_eq!(d.doc.name, "Later", "the file comes back, not the version shown"); + assert_eq!(d.versions.iter().map(|v| v.name.as_str()).collect::>(), ["v1"]); +} diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index eb66c363a..750786d1c 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -25,7 +25,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use std::thread::JoinHandle; -use figcraft_engine::branches::{needs_copy, stamp}; +use figcraft_engine::branches::{needs_copy, recovery_copy, stamp}; use figcraft_engine::{DocState, Session}; use figcraft_format::{Branch, Version}; use serde_json::json; @@ -204,8 +204,7 @@ impl Recovery { if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() { continue; } - let doc = d.doc.clone(); - let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid)); + let (doc, versions, branches) = recovery_copy(s, d); // Which online file it is and what of the server's this very copy has (a line each: // the server, the file's id, then the session, client id, last change and the secret the // client id resumes with), while it diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 0322e2374..2eda9a940 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -388,3 +388,21 @@ fn a_background_copy_that_failed_is_tried_again_without_another_edit() { r.finish(); let _ = std::fs::remove_dir_all(&root); } + +#[test] +fn a_copy_made_while_a_version_is_previewed_is_of_the_file_not_the_version() { + let root = root("preview"); + let mut s = edited(); + s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + // The one-rectangle version shows in place of the two-rectangle file. + s.execute("version.preview", &json!({"index": 0})).unwrap(); + assert_eq!(rectangles(s.doc().unwrap()), 1, "the preview shows"); + assert!(s.ds().unwrap().is_dirty(), "the file itself still has unsaved changes"); + let mut r = Recovery::start(&root).unwrap(); + r.write(&s, true); + let next = crash_and_recover(r, &root); + assert_eq!(rectangles(next.doc().unwrap()), 2, "the file comes back, not the version shown"); + assert_eq!(next.docs[0].versions.iter().map(|v| v.name.as_str()).collect::>(), ["v1"]); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs index 819c4ba53..59afad98d 100644 --- a/crates/engine/src/cmd/file/open.rs +++ b/crates/engine/src/cmd/file/open.rs @@ -183,7 +183,7 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result { } Ok(out) } - "figcraft" | "json" | "fig" | "sketch" | "penpot" => { + "figcraft" | "json" | "fig" | "jam" | "sketch" | "penpot" => { let resolver = path.as_deref().map(rest_images); let images: figcraft_format::rest::ImageResolver = match &resolver { Some(r) => r, diff --git a/crates/engine/src/jam_open_tests.rs b/crates/engine/src/jam_open_tests.rs new file mode 100644 index 000000000..682bc2346 --- /dev/null +++ b/crates/engine/src/jam_open_tests.rs @@ -0,0 +1,55 @@ +//! FigJam `.jam` files (#1342) open and import like `.fig` files: File ▸ Open (`file.open`), the +//! command line (the same command) and File ▸ Import / drag and drop (`file.import`). The file is +//! made here from our own `.fig` export with the canvas header FigJam writes (`fig-jam.`). + +use std::io::{Read, Write}; + +use serde_json::json; + +use crate::Session; + +/// A `.jam` holding one page with a rectangle named `Card`, written to a temporary file. +fn jam_file(tag: &str) -> std::path::PathBuf { + let mut doc = figcraft_doc::Document::new("Synthetic board"); + let page = doc.first_page().unwrap(); + doc.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 10.0, 10.0, 80.0, 40.0).unwrap(); + let (fig, _) = figcraft_format::fig::export(&doc, None).unwrap(); + let mut from = zip::ZipArchive::new(std::io::Cursor::new(fig)).unwrap(); + let mut to = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for i in 0..from.len() { + let mut member = from.by_index(i).unwrap(); + let mut bytes = Vec::new(); + member.read_to_end(&mut bytes).unwrap(); + if member.name() == "canvas.fig" { + assert!(bytes.starts_with(b"fig-kiwi")); + bytes.splice(..8, *b"fig-jam."); + } + to.start_file(member.name(), zip::write::SimpleFileOptions::default()).unwrap(); + to.write_all(&bytes).unwrap(); + } + let path = std::env::temp_dir().join(format!("figcraft-jam-{tag}-{}.jam", std::process::id())); + std::fs::write(&path, to.finish().unwrap().into_inner()).unwrap(); + path +} + +fn cards(s: &Session) -> usize { + s.doc().unwrap().nodes().filter(|n| n.name == "Card").count() +} + +#[test] +fn a_jam_file_opens_and_imports_like_a_fig() { + let path = jam_file("open"); + let mut s = Session::new(); + s.execute("file.open", &json!({"path": path})).unwrap(); + assert_eq!(cards(&s), 1, "opened"); + assert!(crate::board::is_board(s.doc().unwrap()), "a whiteboard opens as a board"); + // Imported into a design file, it adds its pages to it (not an image of its bytes), which + // stays a design file. + s.execute("doc.switch", &json!({"index": 0})).unwrap(); + let before = s.doc().unwrap().pages.len(); + s.execute("file.import", &json!({"path": path})).unwrap(); + assert_eq!(s.doc().unwrap().pages.len(), before + 1); + assert_eq!(cards(&s), 1); + assert!(!crate::board::is_board(s.doc().unwrap())); + std::fs::remove_file(path).unwrap(); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b07aaad09..f3701f89a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -351,6 +351,8 @@ mod interaction_rollback_tests; #[cfg(test)] mod invite_links_tests; #[cfg(test)] +mod jam_open_tests; +#[cfg(test)] mod keymap_tests; #[cfg(test)] mod library_path_tests; diff --git a/crates/engine/src/sharing/branches.rs b/crates/engine/src/sharing/branches.rs index 56f0ca3e0..c4a5b7b09 100644 --- a/crates/engine/src/sharing/branches.rs +++ b/crates/engine/src/sharing/branches.rs @@ -192,6 +192,13 @@ pub fn needs_copy(s: &Session, d: &DocState) -> bool { d.branch_of.is_none() && (d.is_dirty() || tabs(s, d.uid).any(DocState::is_dirty)) } +/// What a recovery copy of `d` holds: the document itself (not a version or live text preview +/// shown in its place: a crash during a preview must bring the file back, not the version), +/// its version history and its branches as they are now ([`current`]). +pub fn recovery_copy(s: &Session, d: &DocState) -> (Arc, Vec, Vec) { + (d.document_itself().clone(), d.versions.clone(), current(s, d.uid)) +} + /// The revisions a recovery copy of `d` is of: its own and its branch tabs' (a branch tab's edits /// change only its own revision). A new copy is due when they change. pub fn stamp(s: &Session, d: &DocState) -> Vec { diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs index a17b1a8ee..a296f681e 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -53,7 +53,7 @@ use std::collections::BTreeMap; use std::io::Read; use std::sync::Arc; -use figcraft_doc::Document; +use figcraft_doc::{Document, FileKind}; pub use export::export; @@ -83,6 +83,9 @@ pub(crate) fn layer_budget(packed: usize) -> usize { (packed / PACKED_BYTES_PER_LAYER).clamp(crate::rest::MAX_NODES, MAX_FIG_NODES) } +/// The canvas header of a FigJam whiteboard (a `.jam`); a `.fig` has `fig-kiwi`. +pub const JAM_HEADER: &[u8] = b"fig-jam."; + /// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas. pub fn is_fig(bytes: &[u8]) -> bool { bytes.starts_with(b"fig-") || (bytes.starts_with(b"PK") && zip_has_canvas(bytes)) @@ -104,6 +107,8 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma // The budgets follow the compressed canvas, which the file itself bounds (its zip member may // inflate too). let packed = archive.canvas.len().min(bytes.len()); + // FigJam writes its whiteboards with this header: they open as boards. + let board = archive.canvas.starts_with(JAM_HEADER); // Each stage's input is dropped as soon as the next one is built: in a large file every one // of them takes gigabytes, and holding them all at once runs out of memory. let (schema, message) = decode_canvas(&std::mem::take(&mut archive.canvas))?; @@ -117,7 +122,10 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma let embedded = embedded_copies(&msg); drop(msg); let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec())); - let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; + let (mut doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; + if board { + doc.kind = FileKind::Board; + } // Both stages stop at the same cap and say so in the same words: one note (#981). for w in more.into_iter().filter_map(map::adapt_warning) { let base = w.rsplit_once(" (×").map_or(w.as_str(), |(b, _)| b); diff --git a/crates/format/src/fig/board_import_tests.rs b/crates/format/src/fig/board_import_tests.rs index 671140c6d..dcf52c46c 100644 --- a/crates/format/src/fig/board_import_tests.rs +++ b/crates/format/src/fig/board_import_tests.rs @@ -4,7 +4,12 @@ use super::*; /// The synthetic FigJam schema adds generated layout and text alignment records. fn board_schema() -> Schema { - Schema::parse(&SCHEMA.replace( + board_schema_of(SCHEMA) +} + +/// [`board_schema`] over another base schema. +fn board_schema_of(base: &str) -> Schema { + Schema::parse(&base.replace( "\nmessage Blob ", ", GeneratedData nodeGenerationData, GeneratedData derivedImmutableFrameData, string textAlignHorizontal, string textAlignVertical\nmessage GeneratedData NodeChange[] overrides, uint version\nmessage Blob ", )) @@ -97,6 +102,7 @@ fn imported(layer: Kv<'static>) -> (Document, Vec) { fn generated_stickies_and_shapes_keep_their_background_text_and_author() { for ty in ["STICKY", "SHAPE_WITH_TEXT"] { let (doc, warnings) = imported(board_node(ty)); + assert_eq!(doc.kind, figcraft_doc::FileKind::Design, "the same layers in a .fig stay a design file"); let frame = named(&doc, "Board object"); assert!(matches!(frame.kind, NodeKind::Frame)); assert_eq!(frame.opacity, 0.5); @@ -417,3 +423,33 @@ fn outer_sticky_body_survives_alongside_the_generated_author() { } } } + +/// A FigJam `.jam` file (#1342) is the same archive as a `.fig` with a `fig-jam.` header on its +/// canvas (synthetic here, never a real file): it imports the same way, as a board, notes and +/// shapes with text as their frames, a table as its stand-in, and a layer type the importer doesn't know is +/// left out with a note instead of failing the file. +#[test] +fn a_jam_file_imports_like_a_fig() { + let schema = board_schema_of(&SCHEMA.replace("CONNECTOR TABLE", "CONNECTOR TABLE SYNTHETIC_JAM_ONLY")); + let layers = vec![ + node(0, None, "DOCUMENT", "Synthetic board", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + board_node("STICKY"), + node(3, Some((1, "\"")), "TABLE", "Synthetic table", vec![("size", xy(200.0, 100.0)), ("transform", at(400.0, 0.0))]), + node(4, Some((1, "#")), "SYNTHETIC_JAM_ONLY", "Unknown object", vec![("size", xy(50.0, 50.0)), ("transform", at(0.0, 400.0))]), + ]; + let msg = o(vec![("nodeChanges", arr(layers)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(triangle_blob()))])]))]); + let mut jam = canvas(&schema, &msg); + jam.splice(..8, *b"fig-jam."); + for bytes in [jam.clone(), zip_file(&jam)] { + assert!(is_fig(&bytes), "read as the same archive"); + let (doc, warnings) = import(&bytes, "Synthetic board").unwrap(); + assert_eq!(doc.kind, figcraft_doc::FileKind::Board, "a FigJam whiteboard opens as a board"); + let note = named(&doc, "Board object"); + assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 3, "the sticky with its background, note and author"); + assert!(matches!(named(&doc, "Synthetic table").kind, NodeKind::Frame)); + assert!(doc.nodes().all(|n| n.name != "Unknown object"), "left out"); + assert!(warnings.iter().any(|w| w.contains("table layers import as")), "{warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("SYNTHETIC_JAM_ONLY")), "a note says what was left out: {warnings:?}"); + } +} diff --git a/crates/format/src/thumbnail.rs b/crates/format/src/thumbnail.rs index ae7c14cd0..530038095 100644 --- a/crates/format/src/thumbnail.rs +++ b/crates/format/src/thumbnail.rs @@ -82,7 +82,8 @@ impl FileKind { let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); match ext.as_str() { "figcraft" => FileKind::FigCraft, - "fig" => FileKind::Fig, + // A FigJam `.jam` is the same archive. + "fig" | "jam" => FileKind::Fig, "sketch" => FileKind::Sketch, "svg" => FileKind::Svg, "png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tif" | "tiff" => FileKind::Image, diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index f2b571939..0b41c0f5b 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -459,7 +459,7 @@ status = "draft — needs native review" "Draw" = "رسم" "Draw a frame" = "رسم إطار" "Drawing tools" = "أدوات الرسم" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "أفلت ملف .figcraft أو .fig أو .sketch أو .json أو .svg أو ملف صورة." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "أفلت ملف .figcraft أو .fig أو .jam أو .sketch أو .json أو .svg أو ملف صورة." "Drop to import" = "أفلت للاستيراد" "Drop to open" = "أفلت للفتح" "Drop shadow" = "ظل خارجي" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 7522cda67..f7e0cf4dd 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -437,7 +437,7 @@ status = "draft — needs native review" "Draw" = "আঁকুন" "Draw a frame" = "একটি ফ্রেম আঁকুন" "Drawing tools" = "অঙ্কন টুলস" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "একটি .figcraft, .fig, .sketch, .json, .svg বা ছবির ফাইল ড্রপ করুন।" +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "একটি .figcraft, .fig, .jam, .sketch, .json, .svg বা ছবির ফাইল ড্রপ করুন।" "Drop to import" = "ইম্পোর্ট করতে ড্রপ করুন" "Drop to open" = "খুলতে ড্রপ করুন" "Drop shadow" = "ড্রপ শ্যাডো" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 6a4f830bf..33c7e3960 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -420,7 +420,7 @@ status = "draft — needs native review" "Drag the stroke to add a width point" = "Ziehen Sie an der Kontur, um einen Breitenpunkt hinzuzufügen" "Draw" = "Zeichnen" "Drawing tools" = "Zeichenwerkzeuge" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Legen Sie eine .figcraft-, .fig-, .sketch-, .json-, .svg- oder Bilddatei ab." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "Legen Sie eine .figcraft-, .fig-, .jam-, .sketch-, .json-, .svg- oder Bilddatei ab." "Drop to import" = "Zum Importieren ablegen" "Drop to open" = "Zum Öffnen ablegen" "Drop shadow" = "Schlagschatten" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 2fa773cc2..e76aa0e13 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -461,7 +461,7 @@ name = "English" "Draw" = "Draw" "Draw a frame" = "Draw a frame" "Drawing tools" = "Drawing tools" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Drop a .figcraft, .fig, .sketch, .json, .svg or image file." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." "Drop to import" = "Drop to import" "Drop to open" = "Drop to open" "Drop shadow" = "Drop shadow" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 201f01ff3..f95ecb03e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -455,7 +455,7 @@ status = "draft — needs native review" "Draw" = "Dibujar" "Draw a frame" = "Dibuja un marco" "Drawing tools" = "Herramientas de dibujo" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Suelta un archivo .figcraft, .fig, .sketch, .json, .svg o de imagen." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "Suelta un archivo .figcraft, .fig, .jam, .sketch, .json, .svg o de imagen." "Drop to import" = "Suelta para importar" "Drop to open" = "Suelta para abrir" "Drop shadow" = "Sombra paralela" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index e9eebffd0..1a9af63e9 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -439,7 +439,7 @@ status = "draft — needs native review" "Draw" = "Piirrä" "Draw a frame" = "Piirrä kehys" "Drawing tools" = "Piirtotyökalut" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Pudota .figcraft-, .fig-, .sketch-, .json-, .svg- tai kuvatiedosto." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "Pudota .figcraft-, .fig-, .jam-, .sketch-, .json-, .svg- tai kuvatiedosto." "Drop shadow" = "Heittovarjo" "Drop to import" = "Tuo pudottamalla" "Drop to open" = "Avaa pudottamalla" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 7b70f0a81..932c1d726 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -438,7 +438,7 @@ status = "draft — needs native review" "Draw" = "Dessin" "Draw a frame" = "Tracer un cadre" "Drawing tools" = "Outils de dessin" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .sketch, .json, .svg ou une image." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .jam, .sketch, .json, .svg ou une image." "Drop shadow" = "Ombre portée" "Drop to import" = "Déposer pour importer" "Drop to open" = "Déposer pour ouvrir" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index 9da3bfb42..299d322d5 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -418,7 +418,7 @@ status = "draft — needs native review" "Draw" = "Disegna" "Draw a frame" = "Disegna un frame" "Drawing tools" = "Strumenti di disegno" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Rilascia un file .figcraft, .fig, .sketch, .json, .svg o un'immagine." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "Rilascia un file .figcraft, .fig, .jam, .sketch, .json, .svg o un'immagine." "Drop to import" = "Rilascia per importare" "Drop to open" = "Rilascia per aprire" "Drop shadow" = "Ombra esterna" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 8e7216c25..35f8825c5 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -443,7 +443,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Draw" = "ხატვა" "Draw a frame" = "დახატეთ ჩარჩო" "Drawing tools" = "ხატვის ხელსაწყოები" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "ჩააგდეთ .figcraft, .fig, .sketch, .json, .svg ან გამოსახულების ფაილი." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "ჩააგდეთ .figcraft, .fig, .jam, .sketch, .json, .svg ან გამოსახულების ფაილი." "Drop to import" = "ჩააგდეთ იმპორტისთვის" "Drop to open" = "ჩააგდეთ გასახსნელად" "Drop shadow" = "ჩრდილი" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 7a7ad7cb4..5524afc68 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1751,7 +1751,7 @@ status = "draft — needs native review" "Dec" = "12월" "Design system starter" = "디자인 시스템 시작 키트" "Draw a frame" = "프레임 그리기" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = ".figcraft, .fig, .sketch, .json, .svg 또는 이미지 파일을 놓으세요." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = ".figcraft, .fig, .jam, .sketch, .json, .svg 또는 이미지 파일을 놓으세요." "Drop to import" = "놓아서 가져오기" "Drop to open" = "놓아서 열기" "Edited {when}" = "{when} 편집" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 09371420c..4f9b27329 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -439,7 +439,7 @@ status = "draft — needs native review" "Draw" = "Tekenen" "Draw a frame" = "Teken een frame" "Drawing tools" = "Tekengereedschap" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Laat een .figcraft-, .fig-, .sketch-, .json-, .svg- of afbeeldingsbestand los." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "Laat een .figcraft-, .fig-, .jam-, .sketch-, .json-, .svg- of afbeeldingsbestand los." "Drop shadow" = "Slagschaduw" "Drop to import" = "Loslaten om te importeren" "Drop to open" = "Loslaten om te openen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index fb7ea2396..7b60c4c92 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -438,7 +438,7 @@ name = "Polski" "Draw" = "Rysuj" "Draw a frame" = "Narysuj ramkę" "Drawing tools" = "Narzędzia rysowania" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Upuść plik .figcraft, .fig, .sketch, .json, .svg lub plik obrazu." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "Upuść plik .figcraft, .fig, .jam, .sketch, .json, .svg lub plik obrazu." "Drop to import" = "Upuść, aby zaimportować" "Drop to open" = "Upuść, aby otworzyć" "Drop shadow" = "Cień" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index b478bd2ed..64d3dc131 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1748,7 +1748,7 @@ status = "draft — needs native review" "Dec" = "dez." "Design system starter" = "Ponto de partida para sistema de design" "Draw a frame" = "Desenhe um quadro" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Solte um arquivo .figcraft, .fig, .sketch, .json, .svg ou uma imagem." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "Solte um arquivo .figcraft, .fig, .jam, .sketch, .json, .svg ou uma imagem." "Drop to import" = "Solte para importar" "Drop to open" = "Solte para abrir" "Edited {when}" = "Editado {when}" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 39151ef24..9cb885913 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -438,7 +438,7 @@ name = "Русский" "Draw" = "Рисование" "Draw a frame" = "Нарисовать Frame" "Drawing tools" = "Инструменты рисования" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Перетащите файл .figcraft, .fig, .sketch, .json, .svg или изображение." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "Перетащите файл .figcraft, .fig, .jam, .sketch, .json, .svg или изображение." "Drop shadow" = "Внешняя тень" "Drop to import" = "Отпустите, чтобы импортировать" "Drop to open" = "Отпустите, чтобы открыть" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 7639f413d..7832111bf 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -453,7 +453,7 @@ name = "Svenska" "Draw" = "Rita" "Draw a frame" = "Rita en frame" "Drawing tools" = "Ritverktyg" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Släpp en .figcraft-, .fig-, .sketch-, .json- eller .svg-fil eller en bildfil." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "Släpp en .figcraft-, .fig-, .jam-, .sketch-, .json- eller .svg-fil eller en bildfil." "Drop to import" = "Släpp för att importera" "Drop to open" = "Släpp för att öppna" "Drop shadow" = "Slagskugga" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index c8cdd36ed..f17434ff6 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -437,7 +437,7 @@ status = "draft — needs native review" "Draw" = "วาด" "Draw a frame" = "วาดเฟรม" "Drawing tools" = "เครื่องมือวาด" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "วางไฟล์ .figcraft, .fig, .sketch, .json, .svg หรือรูปภาพ" +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "วางไฟล์ .figcraft, .fig, .jam, .sketch, .json, .svg หรือรูปภาพ" "Drop to import" = "วางเพื่อนำเข้า" "Drop to open" = "วางเพื่อเปิด" "Drop shadow" = "เงาตกกระทบ" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 964864c69..1f3ec5e96 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -419,7 +419,7 @@ status = "draft — needs native review" "Draw" = "Vẽ" "Draw a frame" = "Vẽ khung" "Drawing tools" = "Công cụ vẽ" -"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Thả tệp .figcraft, .fig, .sketch, .json, .svg hoặc hình ảnh vào đây." +"Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file." = "Thả tệp .figcraft, .fig, .jam, .sketch, .json, .svg hoặc hình ảnh vào đây." "Drop shadow" = "Đổ bóng" "Drop to import" = "Thả để nhập" "Drop to open" = "Thả để mở" diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index 06f93e306..2138ac180 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -75,7 +75,7 @@ impl FigApp { let exts: &[&str] = if place { &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"] } else { - &["penpot", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig", "sketch"] + &["penpot", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig", "jam", "sketch"] }; let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; self.pick_file(title, exts, true, |app, file| { diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 4d891f87a..9587c6623 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -183,7 +183,7 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { let files = ctx.input(|i| i.raw.dropped_files.clone()); for f in files { if !crate::opens(f.path()) { - app.toast(tr("Drop a .figcraft, .fig, .sketch, .json, .svg or image file.")); + app.toast(tr("Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file.")); continue; } crate::pending_open::open_dropped(app, &f); diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index 71225db4b..dcbbb3b6f 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -898,7 +898,7 @@ fn dropping_an_unsupported_file_on_the_home_screen_opens_nothing() { drop_file(&mut u, &path); assert!(u.app.session.docs.is_empty()); assert!(u.app.ui.home); - assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .sketch, .json, .svg or image file.")); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .jam, .sketch, .json, .svg or image file.")); } #[test] @@ -907,3 +907,13 @@ fn sketch_files_open_and_drop_as_documents() { assert!(crate::opens(p)); assert!(crate::is_document(p)); } + +#[test] +fn a_jam_file_is_a_document_open_and_drops_take() { + // FigJam `.jam` files (#1342) open like `.fig` files, from File ▸ Open, a drop or the + // command line, and a drop on the canvas opens one as a file of its own. + for name in ["Board.jam", "BOARD.JAM"] { + let path = std::path::Path::new(name); + assert!(crate::open::opens(path) && crate::open::is_document(path), "{name}"); + } +} diff --git a/crates/ui-egui/src/open.rs b/crates/ui-egui/src/open.rs index fb6b1ba27..b9ffb7c2c 100644 --- a/crates/ui-egui/src/open.rs +++ b/crates/ui-egui/src/open.rs @@ -5,19 +5,19 @@ use super::{OsStr, Path, theme}; /// The file types File ▸ Open and a drop on the home screen open. pub(crate) const OPEN_EXTENSIONS: &[&str] = - &["figcraft", "fig", "penpot", "sketch", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; + &["figcraft", "fig", "jam", "penpot", "sketch", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; /// Whether `path` names a file type that opens as a document (the extension, in any case). pub(crate) fn opens(path: &Path) -> bool { path.extension().and_then(OsStr::to_str).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } -/// Whether `path` is a whole document (`.figcraft`, `.fig`, `.penpot`, `.sketch`, file JSON) rather than an image or SVG: +/// Whether `path` is a whole document (`.figcraft`, `.fig`, `.jam`, `.penpot`, `.sketch`, file JSON) rather than an image or SVG: /// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one. pub(crate) fn is_document(path: &Path) -> bool { path.extension() .and_then(OsStr::to_str) - .is_some_and(|e| ["figcraft", "fig", "penpot", "sketch", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) + .is_some_and(|e| ["figcraft", "fig", "jam", "penpot", "sketch", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) } /// Reads the contents of dropped file `name` that has no file on disk to read by path. diff --git a/docs/boards-parity.md b/docs/boards-parity.md index ff94b0a02..fd2ce41cc 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-29 · **Change:** minor (board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -57,12 +57,13 @@ all end with the same geometry. | Audio and huddles | missing | | | AI features (summaries, sorting stickies, generating boards) | missing | | | Grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it. Dots by default, or lines, graph paper (a strong line every 2–12 cells over fainter ones, like Japanese section paper) or none; spacing (4–512), mark size and contrast are settings kept with the preferences (`board.grid`; Preferences ▸ Board grid). Zoomed out it thins out (dots and lines by halves, graph paper down to its strong lines) until the marks are at least 14 points apart (10 for lines), the marks the next step drops fading out first; dots stay about a point and a half across on screen (1 to 3 points), scaled by the size setting. Dark marks on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per mark, a crossing of two lines blended once; never in exports. FigCraft's own settings: the incumbent's board grid has none | -| `.jam` import and export | missing | see [`file-format-parity.md`](file-format-parity.md) | +| `.jam` import and export | **partial**: a `.jam` opens as a board, best effort (#1342): its stickies and shapes with text come in as frames with their generated layers and its connectors as vectors, not as native board objects; no export | see [`file-format-parity.md`](file-format-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M12, #1342: a FigJam `.jam` file opens as a board, our whiteboard file kind, instead of a design file: the `.fig` reader marks a canvas with FigJam's `fig-jam.` header as `FileKind::Board`, so the file opens with the board toolbar, the dot grid and the board panels, and saves as a board. A `.fig` stays a design file, and `file.import` of a `.jam` adds its pages to the open file without changing its kind. Its stickies and shapes with text are still frames with their generated layers and its connectors vectors, not native board objects. Tests: `board_import_tests.rs` (the kind, `.jam` against `.fig`), `jam_open_tests.rs` (open as a board, import into a design file). | | 2026-10-29 | minor | M12: board grid settings (`board.grid`): dots, lines, graph paper or none, with spacing, size and contrast, in Preferences; the default grid looks as before. Numbers unchanged (beyond the incumbent's behaviour) | | 2026-10-29 | minor | M12, #1387 (connector tool): while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | | 2026-10-24 | minor | M12: the dot grid on board pages (a render pass under the layers), dragging a selected connector's end onto another object or empty canvas (`board.reattach`), curved connectors, and elbows that go around the objects they join. Breadth ~35% → ~37%, ready ~20% → ~21%, ~30–60 h → ~28–56 h | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6199282a8..70c4a3fab 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -21,7 +21,8 @@ and prototype interactions are still unchecked there. | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.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 editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | | `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA | | `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%); the rules for master backgrounds, symbol clipping, mask opacity, bitmap fills, image overrides, stretching stacks, blur radii, colour adjustments, gradient interpolation and text growth checked against Sketch's renders of files built for each case (black-box, synthetic) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session). Known limitations: a plain group (not a frame) that fills a stack keeps its layers' size where Sketch stretches them; a font Sketch can't find draws in the installed family's own weight (Sketch falls back to a regular face); text in the system SF Pro comes out a few percent narrower than Sketch's; macOS's reserved system fonts (PingFang) aren't found; brightness past about +0.4 is approximated (image filters can't add); OKLab gradients become sRGB stops (FigCraft gradients mix in sRGB) | -| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | direct `.jam` opening is not supported (#1342); its `canvas.fig` can be read best effort after renaming a copy to `.fig`, with generated sticky/shape backgrounds and text kept (#1341), as ordinary design layers rather than native board objects (see [boards-parity](boards-parity.md)); writing `.jam` is not supported; `.deck` out of scope | — | +| `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with generated sticky and shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors, with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — | +| `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); image members are `images/`, and a key that can't be one — path text, a space, over 128 chars — is left out with a complaint instead of loaded (`image_tests`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | @@ -185,6 +186,8 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| | 2026-10-30 | minor | M8, #1077: `.fig` import: instance expansion no longer walks variable nodes per instance or copies every outer override row into each nested instance; crafted files that took 14 s and 40 s import in 21 ms and 98 ms, normal files map as before. Numbers unchanged. | +| 2026-10-30 | minor | M12, #1342: a FigJam `.jam` file opens as a board, our whiteboard file kind, instead of a design file: the `.fig` reader marks a canvas with FigJam's `fig-jam.` header as `FileKind::Board`, so the file opens with the board toolbar, the dot grid and the board panels, and saves as a board. A `.fig` stays a design file, and `file.import` of a `.jam` adds its pages to the open file without changing its kind. Its stickies and shapes with text are still frames with their generated layers and its connectors vectors, not native board objects. Tests: `board_import_tests.rs` (the kind, `.jam` against `.fig`), `jam_open_tests.rs` (open as a board, import into a design file). | +| 2026-10-30 | minor | M6, #1342: FigJam `.jam` files open. A `.jam` is the same archive as a `.fig` (its canvas header reads `fig-jam.`), and the `.fig` reader already took it when a copy was renamed; now File ▸ Open, its file filter, drag and drop (home screen and canvas), `file.import` and the command line take `.jam` too, and the home screen's recent files show their thumbnails. What comes in is what the `.fig` reader makes of FigJam layers: stickies and shapes with text as frames with their generated layers, tables, stamps and the like as frames with an import note, connectors as vectors, and a layer type it doesn't know left out with a note naming it. The file opens as a design file, not a board, and its objects carry no board metadata yet. Tests: a synthetic `fig-jam.` canvas, bare and zipped (`board_import_tests.rs`), a `.jam` made from our own `.fig` export opened and imported (`jam_open_tests.rs`), the extension checks (`home_tests.rs`). | | 2026-10-30 | minor | M8, #1550: SVG import reads CSS colour functions its SVG parser doesn't (it painted them black, or left strokes out, without a word): `color(srgb …)` and `color(display-p3 …)`, with alpha, converted to the document's profile, and `color-mix(in srgb|oklab|oklch, …)`, evaluated at import (percentages, alpha and nested mixes as CSS Color 5 has them), in colour attributes, `style` attributes and `