From af2df345bc5db3a8e07f8dcc23711f9eb4ed5936 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 00:51:46 +0700 Subject: [PATCH] M9: the Join dialog shows a join until its file is ready, with Cancel and Try again (#1381) (#1553) --- ROADMAP.md | 3 +- crates/engine/src/sharing/live/join.rs | 12 ++- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/home.rs | 9 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/live.rs | 109 ++++++++++++++++++++--- crates/ui-egui/src/live_joining_tests.rs | 99 ++++++++++++++++++++ crates/ui-egui/src/live_share_tests.rs | 11 +-- crates/ui-egui/src/live_tests.rs | 5 ++ docs/collaboration-parity.md | 5 +- docs/mcp.md | 3 +- docs/roadmap.md | 3 +- 12 files changed, 232 insertions(+), 30 deletions(-) create mode 100644 crates/ui-egui/src/live_joining_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index d05353d3..e8e9389d 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 (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 (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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address, with the server key and owner tokens alike (100 renames in a row from one address: 100 served before, 60 now; another address is unaffected), and finds join codes through an index of their hashes instead of opening every link's code. Same results, same protocol. Tests: `admin_budget_tests`. Numbers unchanged | | 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills (1,000 rectangles filled with one pattern: 1,000 tile drawings before, 1 now, same image fill), and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one, so patterns nested in patterns stop at the budget. Tests: `tile_tests` (svg). Numbers unchanged | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged | diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs index 1742382a..c9e54722 100644 --- a/crates/engine/src/sharing/live/join.rs +++ b/crates/engine/src/sharing/live/join.rs @@ -34,8 +34,14 @@ pub(crate) fn start_join( Ok(id) } -fn leave(s: &mut Session, _: &Value) -> Result { - let ended = s.live.shown(s.tab()).and_then(|id| s.live.end(id)); +fn leave(s: &mut Session, p: &Value) -> Result { + // A join on its way is called off by its id, whichever tab is in front (the Join dialog's + // Cancel); one that already ended is no error. + let id = match get(p, "session") { + Some(v) => Some(v.as_u64().ok_or_else(|| bad("collab.leave", "`session` must be a session id (`collab.peers` lists them)"))?), + None => s.live.shown(s.tab()), + }; + let ended = id.and_then(|id| s.live.end(id)); if ended.as_ref().is_some_and(|l| l.syncing() && l.role() == Some(Role::Guest)) { s.message = Some("You left the session; your copy stays open here".into()); } @@ -189,7 +195,7 @@ pub fn specs() -> Vec { spec!( "collab.leave", "Leave live session", - "{} (the open file's session; the synced file stays open as a local copy) → {ended}", + "{session? (a session's id from `collab.peers`'s `sessions`, e.g. a join still on its way; default: the open file's session)} → {ended}: the synced file stays open as a local copy", false, leave ), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3450db8e..14167386 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -727,6 +727,7 @@ name = "English" "Join with a code" = "Join with a code" "Joined" = "Joined" "Joined {file}" = "Joined {file}" +"Joining live session…" = "Joining live session…" "Joining…" = "Joining…" "Join|stroke" = "Join" "Jul" = "Jul" diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 05c8fcda..4d891f87 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -343,16 +343,11 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { if open { app.open_dialog(); } - // Join a file someone shared: the join field, and what the join is doing. + // Join a file someone shared (the Join dialog shows what the join is doing). subheading(ui, t, tr("Join with a code")); ui.scope(|ui| { ui.set_max_width(540.0); - if let Some(session) = crate::live::join_row(app, ui, 260.0) { - app.ui.live.home_join = Some(session); - } - if let Some(session) = app.ui.live.home_join { - crate::live::join_status(app, ui, t, session); - } + crate::live::join_row(app, ui, 260.0); }); opened_join(app); if !app.session.prefs.getting_started_dismissed { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0731c980..1d1a8d4b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -123,6 +123,8 @@ mod live_docs_tests; #[cfg(test)] mod live_join_tests; #[cfg(test)] +mod live_joining_tests; +#[cfg(test)] mod live_server_tests; #[cfg(test)] mod live_share_tests; diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index e16d2cfa..28b50f80 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -108,6 +108,9 @@ pub struct LiveUi { /// The join started from the home screen: what it is doing shows under the field, and its /// file's tab shows when it opens. pub home_join: Option, + /// A join started from a join field, with what was typed for it: the Join dialog shows it + /// from the click until its file is ready, or why it failed, to try again. + pub joining: Option<(SessionId, String)>, /// Remove from server… was clicked: its confirmation shows. pub confirm_remove: bool, /// What the server for online sharing said about its hosting (`collab.serverInfo`), and the @@ -685,17 +688,96 @@ pub fn join_field(app: &mut FigApp, ui: &mut Ui, typed: &mut String, id: egui::I if focus { resp.request_focus(); } - let filled = !typed.trim().is_empty(); + // One join at a time: the field waits while one is on its way. + let filled = !typed.trim().is_empty() && !join_pending(app); let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); let go = widgets::button(ui, tr("Join"), true, filled).clicked() || enter; - let joined = if go && filled { app.run("collab.join", json!({"code": typed.trim()})) } else { None }; - let session = joined.and_then(|j| j["session"].as_u64()); + let session = if go && filled { start_join(app, typed.trim()) } else { None }; if session.is_some() { typed.clear(); } session } +/// Join with `typed` (a code or an invite link); the Join dialog follows the join. Its session, if +/// it started. +fn start_join(app: &mut FigApp, typed: &str) -> Option { + let session = app.run("collab.join", json!({"code": typed})).and_then(|j| j["session"].as_u64())?; + app.ui.live.joining = Some((session, typed.to_string())); + Some(session) +} + +/// A join started from a join field is on its way (not ready and not failed). +fn join_pending(app: &FigApp) -> bool { + app.ui.live.joining.as_ref().and_then(|(id, _)| app.session.live.get(*id)).is_some_and(|l| !l.syncing() && l.status.active()) +} + +/// Call off the join the Join dialog follows (it may have failed: it is forgotten). +fn cancel_join(app: &mut FigApp) { + if let Some((id, _)) = app.ui.live.joining.take() { + app.run("collab.leave", json!({"session": id})); + } +} + +/// What the join the Join dialog follows is doing: `None` when there is none to show (its file +/// is ready, and then shows, or it ended elsewhere); else why it failed, or `None` inside while +/// it is on its way. +fn join_progress(app: &mut FigApp) -> Option> { + let (id, _) = app.ui.live.joining.as_ref()?; + let Some(live) = app.session.live.get(*id) else { + app.ui.live.joining = None; + return None; + }; + if live.syncing() { + app.ui.live.joining = None; + app.ui.home = false; + return None; + } + Some(match &live.status { + Status::Failed { reason } => Some(failure_text(reason).into_owned()), + _ => None, + }) +} + +/// The join started from a join field, from the click until its file is ready: joining (Cancel +/// calls it off), or why it failed (Try again joins with the same code). Whether it showed. +fn joining_dialog(app: &mut FigApp, ctx: &egui::Context) -> bool { + let Some(failed) = join_progress(app) else { return false }; + let t = Tokens::get(ctx); + let (mut open, mut cancel, mut retry) = (true, false, false); + crate::dialogs::window(tr("Join live session"), ctx).id(egui::Id::unique("Join live session")).open(&mut open).show(ctx, |ui| { + ui.set_width(380.0); + match &failed { + None => { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(14.0)); + widgets::label(ui, tr("Joining live session…"), t.text); + }); + } + Some(reason) => widgets::label(ui, reason, t.danger), + } + ui.horizontal(|ui| { + retry = failed.is_some() && widgets::button(ui, tr("Try again"), true, true).clicked(); + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); + }); + }); + if retry { + retry_join(app); + } else if cancel || !open { + cancel_join(app); + } + true +} + +/// Join again with what the failed join was started with; the failed one goes once the new one +/// is on its way. +fn retry_join(app: &mut FigApp) { + let Some((old, typed)) = app.ui.live.joining.clone() else { return }; + if start_join(app, &typed).is_some() { + app.run("collab.leave", json!({"session": old})); + } +} + /// What join `session` is doing, under the field it was started from (the home screen): on its /// way, or why it failed. pub fn join_status(app: &FigApp, ui: &mut Ui, t: &Tokens, session: SessionId) { @@ -1089,20 +1171,27 @@ pub fn open_invite(app: &mut FigApp, link: &str) { open_join(app, link.trim()); } -/// Escape closes the Join or Publish dialog (before the canvas's shortcuts see it: it doesn't -/// deselect). +/// Escape closes the Join or Publish dialog, and calls off the join the Join dialog follows +/// (before the canvas's shortcuts see it: it doesn't deselect). pub fn dialog_keys(app: &mut FigApp, ctx: &egui::Context) { - let live = &mut app.ui.live; - if (live.join.is_some() || live.publish.is_some()) && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { - live.join = None; - live.publish = None; + let live = &app.ui.live; + if (live.join.is_some() || live.publish.is_some() || live.joining.is_some()) + && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) + { + app.ui.live.join = None; + app.ui.live.publish = None; + cancel_join(app); } } -/// File ▸ Join live session: type a join code or paste an invite link. +/// File ▸ Join live session: type a join code or paste an invite link; then the join, until its +/// file is ready. pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) { // The editor takes Escape before its shortcuts; the home screen has none. dialog_keys(app, ctx); + if joining_dialog(app, ctx) { + return; + } let Some(mut link) = app.ui.live.join.take() else { return }; let t = Tokens::get(ctx); let mut open = true; diff --git a/crates/ui-egui/src/live_joining_tests.rs b/crates/ui-egui/src/live_joining_tests.rs new file mode 100644 index 00000000..a5d46b8e --- /dev/null +++ b/crates/ui-egui/src/live_joining_tests.rs @@ -0,0 +1,99 @@ +//! The Join dialog follows a join from the click until its file is ready: joining (with Cancel), +//! then the file in a tab of its own; or why it failed, with Try again (#1381). + +use egui::{Event, Key}; +use figcraft_engine::live::{Event as LiveEvent, Status}; +use serde_json::json; + +use crate::live_tests::{Harness, has_text, text_center}; + +const LINK: &str = "figcraft://join?host=127.0.0.1:47900&file=f&token=t"; + +/// Join from the Join dialog with the invite link: the session it started. +fn join(h: &mut Harness) -> u64 { + crate::live::open_invite(&mut h.app, LINK); + // A new window measures itself first. + let shapes = h.frames(3); + h.click(text_center(&shapes, "Join")); + let shapes = h.frames(1); + assert!(has_text(&shapes, "Joining live session…") && has_text(&shapes, "Cancel"), "the dialog shows the join at once"); + let (id, typed) = h.app.ui.live.joining.clone().unwrap(); + assert_eq!(typed, LINK); + id +} + +fn joining_shown(h: &mut Harness) -> bool { + has_text(&h.frames(2), "Joining live session…") +} + +#[test] +fn joining_shows_until_the_file_is_ready() { + let (mut h, _) = Harness::new(); + let id = join(&mut h); + // One join at a time: the home screen's field waits. + h.app.ui.home = true; + h.app.ui.live.code = LINK.into(); + let shapes = h.frames(2); + h.click(text_center(&shapes, "Join")); + assert_eq!(h.app.ui.live.joining.as_ref().map(|j| j.0), Some(id), "no second join"); + assert_eq!(h.app.session.live.iter().count(), 1); + // Welcomed, but the file isn't here yet: still joining. + h.welcome(); + assert!(joining_shown(&mut h)); + h.sync(); + assert!(!joining_shown(&mut h) && h.app.ui.live.joining.is_none(), "it goes once the file is ready"); + assert!(!h.app.ui.home, "and the joined file shows"); + assert_eq!(h.app.session.docs.len(), 2); +} + +#[test] +fn a_failed_join_says_why_and_tries_again() { + let (mut h, _) = Harness::new(); + let first = join(&mut h); + h.send(vec![LiveEvent::Status(Status::Failed { reason: "Couldn't reach 127.0.0.1:47900".into() })]); + let shapes = h.frames(2); + assert!(has_text(&shapes, "Couldn't reach 127.0.0.1:47900") && has_text(&shapes, "Try again")); + assert!(!has_text(&shapes, "Joining live session…")); + h.click(text_center(&shapes, "Try again")); + let second = h.app.ui.live.joining.as_ref().map(|j| j.0).unwrap(); + assert_ne!(second, first, "a new join with the same link"); + assert!(h.app.session.live.get(first).is_none(), "the failed one is gone"); + assert_eq!(h.app.session.live.get(second).map(|l| l.status.clone()), Some(Status::Connecting)); + assert!(joining_shown(&mut h)); + h.welcome(); + h.sync(); + assert!(h.app.ui.live.joining.is_none() && h.app.session.live.get(second).is_some_and(|l| l.syncing())); +} + +#[test] +fn cancel_and_escape_call_the_join_off() { + let (mut h, _) = Harness::new(); + let id = join(&mut h); + let shapes = h.frames(1); + h.click(text_center(&shapes, "Cancel")); + assert!(h.app.ui.live.joining.is_none() && h.app.session.live.get(id).is_none(), "the join ended"); + assert_eq!(h.app.session.live().status, Status::Off); + assert!(!joining_shown(&mut h)); + // Escape is Cancel, and so is a failed join's Cancel (it is forgotten). + let id = join(&mut h); + for pressed in [true, false] { + h.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }]); + } + assert!(h.app.ui.live.joining.is_none() && h.app.session.live.get(id).is_none()); + let id = join(&mut h); + h.send(vec![LiveEvent::Status(Status::Failed { reason: "No session with that code".into() })]); + let shapes = h.frames(2); + h.click(text_center(&shapes, "Cancel")); + assert!(h.app.ui.live.joining.is_none() && h.app.session.live.get(id).is_none()); + assert!(!has_text(&h.frames(2), "Try again")); +} + +#[test] +fn leave_takes_a_session_id() { + let (mut h, _) = Harness::new(); + let id = h.app.session.execute("collab.join", &json!({"link": LINK})).unwrap()["session"].as_u64().unwrap(); + assert!(h.app.session.execute("collab.leave", &json!({"session": "one"})).is_err()); + assert_eq!(h.app.session.execute("collab.leave", &json!({"session": id + 7})).unwrap()["ended"], false, "an unknown one is no error"); + assert_eq!(h.app.session.execute("collab.leave", &json!({"session": id})).unwrap()["ended"], true); + assert!(h.app.session.live.get(id).is_none()); +} diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index a2ac63c2..f2fbe11a 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -450,8 +450,8 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { key(&mut h, Key::Enter); assert_eq!(script.lock().unwrap().joined, ["figcraft://join?server=wss://share.example/&code=K7M2QX9TD"]); let shapes = h.frames(2); - assert!(has_text(&shapes, "Joining…") && h.app.ui.home); - // It failed: the reason shows under the field, and the home screen stays. + assert!(has_text(&shapes, "Joining live session…") && h.app.ui.home, "the Join dialog shows the join"); + // It failed: the dialog says why, and the home screen stays. script.lock().unwrap().events.push(LiveEvent::Status(Status::Failed { reason: "That code has expired or its sharing was stopped".into() })); let shapes = h.frames(3); assert!(has_text(&shapes, "That code has expired or its sharing was stopped") && h.app.ui.home); @@ -467,8 +467,8 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { assert_eq!(h.app.session.docs.len(), 2); } -/// Asserts the join dialog uses the same shared join field, focuses it, and closes once the -/// join starts. +/// Asserts the join dialog uses the same shared join field, focuses it, and shows the join once +/// it starts. #[test] fn the_join_dialog_is_the_same_field() { let (mut h, script) = harness(); @@ -480,7 +480,8 @@ fn the_join_dialog_is_the_same_field() { key(&mut h, Key::Enter); h.frames(1); assert_eq!(script.lock().unwrap().joined, ["figcraft://join?server=wss://share.example/&code=4FHJ8R2WN"]); - assert!(h.app.ui.live.join.is_none(), "it closes once the join started"); + assert!(h.app.ui.live.join.is_none(), "its field goes once the join started"); + assert!(has_text(&h.frames(1), "Joining live session…"), "and it shows the join"); } #[test] diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index 63c3c42a..cd8454af 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -114,6 +114,11 @@ impl Harness { /// Joined as Ada, with Hana (the host) already here. pub(crate) fn joined(&mut self) { self.app.session.execute("collab.join", &json!({"link": "figcraft://join?host=127.0.0.1:47900&file=f&token=t"})).unwrap(); + self.welcome(); + } + + /// The host's welcome for the join started last: you are Ada, with Hana (the host) here. + pub(crate) fn welcome(&mut self) { let info = SessionInfo { role: Role::Guest, me: 2, diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 91ac86f0..1091548a 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-29 · **Change:** minor (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); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); 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); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -17,7 +17,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, 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; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | -| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | +| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); 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. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). 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, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 | @@ -28,6 +28,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address and block (`Action::Admin`), on every server, with the server key and with an owner token alike; listing and looking aren't counted. Before, a managing connection with the key had no limit at all and an owner token only the message rate, so one address could ask for hundreds of 10,000-round code stretches and `links.json` writes a second (100 renames in a row: 100 served before, 60 now, then "too many changes to this server's files from your address…"; another address is unaffected). A typed or new join code is found through an index of code hashes to the files holding them, instead of opening every link's code on the server; results are the same, a copied file folder holding the same code included. The protocol is unchanged. Tests: `admin_budget_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged | | 2026-10-29 | minor | Bounded replay log (#1069, from a security audit): the batches a session keeps for apps that reconnect were bounded only by count (10,000 batches, 1,000,000 changes) on a desktop host and a private `figcraft-server`, so a long busy session could hold any number of bytes. `Limits::max_log_bytes` now defaults to 64 MiB, and `figcraft-server` takes it from a new `max_log_mb` setting (default 64; open to the public, also at most a quarter of `max_file_mb`, as before). An app further behind than the trimmed log catches up with the whole file, as it already did past the count limits. Nine 8 MiB renames with default limits kept all 75.5 MB before; now 58.7 MB, and a client that missed them gets a snapshot. The protocol is unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index e0d66b4c..3c0f8d5a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1377,7 +1377,8 @@ only: a host stops hosting it, a guest leaves it. session, a revoked link, an unreachable host, a `wss://` server whose certificate isn't trusted: see `FIGCRAFT_CA_FILE` in [`server.md`](server.md#behind-a-reverse-proxy-tls)). `collab.leave {}` leaves the active document's session (or ends a join started from it that is still on its way - or failed); the synced document stays open as your copy. + or failed), and `collab.leave {session}` the session with that id (from `sessions`), whichever + document is active; the synced document stays open as your copy. - `collab.publish {server, name?, key?}` puts the active document on a `figcraft-server` ([`server.md`](server.md)) and syncs it from there (as that document's session; others go on): `server` is the address `figcraft-server key` prints (`ws://host:47900/?key=…`; or give `key` separately), `name` the diff --git a/docs/roadmap.md b/docs/roadmap.md index 1082ae74..80aa065c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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 (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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged | | 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files a budget of 60 a minute per address, with the server key and owner tokens alike, and finds join codes through an index of their hashes. Tests: `admin_budget_tests` | | 2026-10-29 | minor | M8, #1080: SVG import draws a pattern's tile once per pattern and scale and reuses it for every other layer it fills, and the layers inside a tile count in the import's 100,000-layer budget instead of each tile starting a fresh one. Tests: `tile_tests` (svg) | | 2026-10-29 | patch | M4, #1478: a letter and its combining marks are shaped with one font that has them all. Text typed decomposed (NFD, `E` + U+0302 + U+0309 for Ể) in a font with the letters but not the marks drew missing-glyph boxes beside and above the letter, while the precomposed form fell back to a font that has it; each grapheme cluster now takes the first face (the style's own, then the fallbacks) that covers every char in it (joiners and variation selectors need no glyph), else the letter's own face as before. Fonts that cover Vietnamese were already right: mark positioning (GPOS `mark`/`mkmk`) stacks the marks over the letter on the canvas, in PNG, SVG and PDF. Tests in `crates/text/src/vietnamese_tests.rs`. Numbers unchanged |