diff --git a/ROADMAP.md b/ROADMAP.md index 460889a7..50fa01b0 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 (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,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | +| 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/app.rs b/crates/ui-egui/src/app.rs index 44a17658..fcd1b788 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -305,6 +305,7 @@ impl FigApp { layers::left_panel(self, ui) }); } + self.ui.right_panel_rect = None; if right_open { if !narrow { edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0)); diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 7a27aff3..128cd029 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -34,11 +34,41 @@ impl FigApp { /// Clicking the control that opened the colour picker closes it again; clicking another opens /// the picker for that one. - pub fn toggle_picker(&mut self, target: crate::colorpicker::PickerTarget, _top: Option) { + pub fn toggle_picker(&mut self, target: crate::colorpicker::PickerTarget, button: Option) { if self.ui.picker.as_ref() == Some(&target) { crate::colorpicker::close(self); } else { - self.ui.picker = Some(target); + self.open_picker(target, button); + } + } + + /// Start a fresh picker beside its control, committing edits to the previous target first. + pub fn open_picker(&mut self, target: crate::colorpicker::PickerTarget, button: Option) { + if self.ui.picker.is_some() { + crate::colorpicker::close(self); + } + self.ui.picker_popover = None; + self.ui.picker_button_id = None; + self.ui.picker = Some(target); + self.ui.picker_button = button; + } + + /// Follow only the widget that opened this target, even when several controls edit it. + pub fn refresh_picker_control(&mut self, target: &crate::colorpicker::PickerTarget, response: &egui::Response) { + if self.ui.picker.as_ref() == Some(target) && self.ui.picker_button_id.is_none_or(|id| id == response.id) { + self.ui.picker_button = response.interact_rect.is_positive().then_some(response.rect); + self.ui.picker_button_id = Some(response.id); + } + } + + /// Drawn colour controls refresh their held rectangle and toggle the picker on a click. + pub fn picker_control(&mut self, target: crate::colorpicker::PickerTarget, response: &egui::Response) { + self.refresh_picker_control(&target, response); + if response.clicked() { + self.toggle_picker(target, Some(response.rect)); + if self.ui.picker.is_some() { + self.ui.picker_button_id = Some(response.id); + } } } diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs index 85c8d291..1e0fe8d1 100644 --- a/crates/ui-egui/src/colorpicker_selection_tests.rs +++ b/crates/ui-egui/src/colorpicker_selection_tests.rs @@ -58,16 +58,16 @@ fn clicking_the_swatch_again_closes_the_picker() { let (mut u, a, _) = two(); u.app.run("fill.add", json!({"ids": [a], "color": "#00FF00"})); let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }; - u.app.toggle_picker(target.clone(), Some(200.0)); + u.app.toggle_picker(target.clone(), Some(egui::Rect::from_min_size(egui::pos2(1250.0, 200.0), egui::vec2(16.0, 16.0)))); u.frame(vec![]); u.frame(vec![]); assert_eq!(u.app.ui.picker, Some(target.clone()), "the swatch opens the picker"); - u.app.toggle_picker(target.clone(), Some(200.0)); + u.app.toggle_picker(target.clone(), Some(egui::Rect::from_min_size(egui::pos2(1250.0, 200.0), egui::vec2(16.0, 16.0)))); u.frame(vec![]); assert!(u.app.ui.picker.is_none(), "the same swatch toggles it shut"); // Another paint of the same layer opens the picker for that one. let second = PickerTarget::Paint { fills: true, index: 1, ids: vec![a] }; - u.app.toggle_picker(second.clone(), Some(200.0)); + u.app.toggle_picker(second.clone(), Some(egui::Rect::from_min_size(egui::pos2(1250.0, 200.0), egui::vec2(16.0, 16.0)))); u.frame(vec![]); assert_eq!(u.app.ui.picker, Some(second), "another paint opens the picker for it"); } @@ -120,3 +120,98 @@ fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() { ); } } + +#[test] +fn opening_the_first_picker_preserves_the_active_text_undo_interaction() { + let mut s = Session::new(); + let text = NodeId(s.execute("text.create", &json!({"text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + let mut app = crate::FigApp::new(s, Box::new(crate::NoServices)); + figcraft_engine::textedit::begin(&mut app.session, text, Some(2), false).unwrap(); + figcraft_engine::textedit::insert(&mut app.session, "a").unwrap(); + let depth = app.session.undo_depth(); + app.open_picker(PickerTarget::TextRange { id: text, start: 0, end: 2 }, None); + assert!(app.session.ds().unwrap().interaction.is_some()); + assert_eq!(app.session.undo_depth(), depth); + figcraft_engine::textedit::insert(&mut app.session, "b").unwrap(); + figcraft_engine::textedit::end(&mut app.session).unwrap(); + app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters, "Hi"); +} + +#[test] +fn the_picker_uses_the_fitted_sidebar_edge() { + use egui::{Pos2, RawInput, Rect, vec2}; + let (mut u, a, _) = two(); + u.app.ui.right_width = 480.0; + u.app.open_picker(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }, None); + for _ in 0..6 { + u.frame_raw(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 900.0))), ..Default::default() }); + } + let sidebar = u.app.ui.right_panel_rect.unwrap(); + assert!(sidebar.width() < u.app.ui.right_width, "the requested width does not fit"); + let pop = u.app.ui.picker_popover.as_ref().unwrap(); + assert!((pop.rect.max.x - (sidebar.min.x - 8.0)).abs() < 1.0, "{:?} beside {sidebar:?}", pop.rect); +} + +#[test] +fn moved_page_effect_and_text_range_swatches_still_toggle_the_picker_closed() { + use egui::{Id, Pos2}; + for kind in ["page", "effect", "text"] { + let (mut u, a, _) = two(); + let target = match kind { + "page" => { + u.app.session.execute("select.none", &json!({})).unwrap(); + PickerTarget::PageBackground + } + "effect" => { + u.app.session.execute("effect.add", &json!({"id":a,"type":"dropShadow"})).unwrap(); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("fx_open", 0usize)), true)); + PickerTarget::Effect { ids: vec![a], index: 0, secondary: false } + } + _ => { + let text = NodeId(u.app.session.execute("text.create", &json!({"text":"Hello"})).unwrap()["id"].as_u64().unwrap()); + figcraft_engine::textedit::begin(&mut u.app.session, text, None, false).unwrap(); + PickerTarget::TextRange { id: text, start: 0, end: 5 } + } + }; + u.app.open_picker(target, None); + for _ in 0..5 { + u.frame(vec![]); + } + let before = u.app.ui.picker_button.expect(kind); + u.app.ui.right_width = 360.0; + for _ in 0..5 { + u.frame(vec![]); + } + let after = u.app.ui.picker_button.expect(kind); + assert!((after.min.x - before.min.x).abs() > 50.0, "{kind}: swatch moves"); + u.button(after.center(), true); + u.button(after.center(), false); + u.frame(vec![egui::Event::PointerMoved(Pos2::new(1430.0, 890.0))]); + assert!(u.app.ui.picker.is_none(), "{kind}: the moved swatch closes it"); + } +} + +#[test] +fn a_press_in_the_gradient_variable_picker_keeps_the_colour_picker_open() { + use egui::{Pos2, RawInput, Rect, pos2, vec2}; + let (mut u, a, _) = two(); + u.app.session.execute("fill.set", &json!({"id":a,"paint":{"type":"linearGradient","stops":["#ff0000","#0000ff"]}})).unwrap(); + u.app.open_picker(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }, None); + for _ in 0..5 { + u.frame(vec![]); + } + // Open the same child picker used by a gradient stop, away from the parent's card. + let _ = u.ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }, |ui| { + crate::variable_ui::open(ui, &[a], "fills/0/stops/0/color", figcraft_doc::VariableType::Color, pos2(500.0, 180.0)); + }); + for _ in 0..5 { + u.frame(vec![]); + } + let child = crate::variable_ui::picker_rect(&u.ctx).unwrap(); + let at = child.min + vec2(30.0, 20.0); + assert!(!u.app.ui.picker_popover.as_ref().unwrap().rect.contains(at)); + u.button(at, true); + assert!(u.app.ui.picker.is_some(), "the child variable picker is part of the interaction"); + u.button(at, false); +} diff --git a/crates/ui-egui/src/dialogs/plugins_ui.rs b/crates/ui-egui/src/dialogs/plugins_ui.rs index 339573ff..57491cea 100644 --- a/crates/ui-egui/src/dialogs/plugins_ui.rs +++ b/crates/ui-egui/src/dialogs/plugins_ui.rs @@ -252,7 +252,7 @@ fn panel_windows(app: &mut FigApp, ctx: &Context) { app.run("plugin.close", json!({"id": id})); } Some(Used::Set(control, value)) => run(app, "plugin.event", json!({"id": id, "control": control, "value": value})), - Some(Used::Pick(control)) => app.ui.picker = Some(PickerTarget::PluginField { plugin: id, control, pending: None }), + Some(Used::Pick(control)) => app.open_picker(PickerTarget::PluginField { plugin: id, control, pending: None }, None), } } } @@ -590,7 +590,7 @@ fn form_window(app: &mut FigApp, ctx: &Context) { }); app.ui.plugin_form = (open && !submit && !cancel).then_some(form.clone()); if let Some(control) = pick { - app.ui.picker = Some(PickerTarget::PluginField { plugin: form.id.clone(), control, pending: None }); + app.open_picker(PickerTarget::PluginField { plugin: form.id.clone(), control, pending: None }, None); } if submit { run(app, "plugin.run", json!({"id": form.id, "command": form.command, "params": form.values})); diff --git a/crates/ui-egui/src/dialogs/variables_panel.rs b/crates/ui-egui/src/dialogs/variables_panel.rs index 9939cfc9..710c0db5 100644 --- a/crates/ui-egui/src/dialogs/variables_panel.rs +++ b/crates/ui-egui/src/dialogs/variables_panel.rs @@ -839,9 +839,8 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C ui.painter().rect_filled(body, CornerRadius::same(5), t.field); let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0)); widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c)); - if ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).tip(tr("Edit colour")).clicked() { - app.ui.picker = Some(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() }); - } + let response = ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).tip(tr("Edit colour")); + app.picker_control(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() }, &response); let hex_r = Rect::from_min_max(pos2(sw.max.x + 8.0, body.min.y + 4.0), pos2(body.max.x - 44.0, body.max.y - 4.0)); if let Some(h) = widgets::text_field_inline(ui, id.with("hex"), &c.to_hex(), hex_r, regular(11.0)) && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h) diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 692673e4..e1d17c27 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -503,14 +503,18 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { let [r0, .., a0] = rgba(&u); u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), 1usize)); u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); - // The picker window takes a frame to size itself. - for _ in 0..3 { + // Settle sizing and the anchored position before reading field hit rectangles. + for _ in 0..5 { u.frame(vec![]); } let steps = undo_steps(&u); let from = lead(&u, Id::unique(("cp_f", "r"))); drag(&mut u, from, -20.0, Modifiers::NONE); assert_eq!((rgba(&u)[0], undo_steps(&u)), (r0 - 20, steps + 1), "R"); + // Committing adds a recent colour row and can lift the anchored picker. Settle its layout. + for _ in 0..3 { + u.frame(vec![]); + } // The alpha field: a scrub moves it too. let from = lead(&u, Id::unique("cp_a")); drag(&mut u, from, -30.0, Modifiers::NONE); @@ -531,7 +535,7 @@ fn lch_and_oklch_picker_fields_edit_lightness_keeping_hue() { assert_eq!(before.to_hex(), "0D99FF"); u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), model)); u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); - for _ in 0..3 { + for _ in 0..5 { u.frame(vec![]); } let steps = undo_steps(&u); @@ -551,14 +555,16 @@ fn type_into_picker(u: &mut Ui, id: &str, text: &str) { click(u, rect.center()); u.frame(vec![Event::Text(text.into())]); key(u, Key::Enter, Modifiers::NONE); - u.frame(vec![]); + for _ in 0..3 { + u.frame(vec![]); + } } /// A picker on layer `a` in model `model` (an index into the model menu), drawn and settled. fn open_picker(u: &mut Ui, a: NodeId, model: usize) { u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), model)); u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); - for _ in 0..3 { + for _ in 0..5 { u.frame(vec![]); } } 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 c12eaf17..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; @@ -347,6 +349,8 @@ pub struct UiState { /// small window draws). pub left_width: f32, pub right_width: f32, + /// The right sidebar as drawn this frame, after fitting the window. + pub right_panel_rect: Option, pub right_tab: RightTab, /// The tab of the previous frame, to see the right panel change mode. pub last_right_tab: RightTab, @@ -424,6 +428,8 @@ pub struct UiState { /// The control that opened the picker, refreshed every frame it is drawn (the panel scrolls): /// a press in it is held out of the popover's closing, so its own click toggles the picker. pub picker_button: Option, + /// The opening widget, so only that control refreshes the held rectangle as it moves. + pub picker_button_id: Option, pub context_menu: Option<(egui::Pos2, Vec)>, /// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the /// focused layer row or the canvas selection and takes the keyboard. @@ -560,6 +566,7 @@ impl Default for UiState { Self { left_width: PANEL_DEFAULT, right_width: PANEL_DEFAULT, + right_panel_rect: None, right_tab: RightTab::Design, last_right_tab: RightTab::Design, left_tab: LeftTab::File, @@ -605,6 +612,7 @@ impl Default for UiState { crop: None, picker_popover: None, picker_button: None, + picker_button_id: None, context_menu: None, key_menu: false, key_box: None, 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/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index 50de5bf9..ac1afdd1 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -1,6 +1,6 @@ //! Adding and removing fill, effect and layout grid rows in the Design panel, driven through egui headless: -//! adding a paint opens its colour picker; an open colour picker and open settings stay with their items, -//! and go when their item goes. +//! adding a paint opens its colour picker beside the sidebar; outside presses dismiss it, while +//! open effect and grid settings stay with their items and go when their item goes. use egui::{Event, Id, Pos2}; use figcraft_doc::NodeId; @@ -70,10 +70,9 @@ fn flag(u: &Ui, salt: &'static str, i: usize) -> bool { u.ctx.data(|d| d.get_temp::(Id::unique((salt, i)))).unwrap_or(false) } -/// Asserts removing a fill keeps the open picker on the surviving paint (with its index -/// shifted), and removing the paint being edited closes the picker. +/// Removing a fill acts on the paint and dismisses the picker on the outside press. #[test] -fn removing_a_fill_keeps_the_picker_on_its_paint() { +fn removing_a_fill_dismisses_the_picker() { let mut s = Session::new(); let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap(); @@ -82,7 +81,7 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { s.execute("select.set", &json!({"ids": [r]})).unwrap(); let mut u = Ui::new(s); u.ctx.enable_accesskit(); - // Green's swatch, then red's Remove: the picker follows green to index 0. + // Green's swatch, then red's Remove: the outside press dismisses the picker. let sw = u.ctx.read_response(Id::unique(("swatch", true, 1usize))).unwrap().rect.center(); click(&mut u, sw); assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 1, ids: vec![r] })); @@ -90,15 +89,15 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { click(&mut u, rm); let fills: Vec = u.app.session.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect(); assert_eq!(fills, ["00FF00", "0000FF"]); - assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![r] })); - // Removing the paint being edited closes the picker. + assert_eq!(u.app.ui.picker, None); + // Removing the surviving paint works with the picker dismissed. let rm = remove_button(&mut u, "fills", 0); click(&mut u, rm); assert_eq!(u.app.ui.picker, None); } -/// Asserts removing effects or grids moves open settings and an open colour picker to the -/// surviving row, or closes them when their own row is removed. +/// Removing effects or grids moves open settings to the surviving row; the outside press +/// dismisses the colour picker. Removing the open item closes its settings. #[test] fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut s = Session::new(); @@ -118,7 +117,7 @@ fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { click(&mut u, rm); assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().effects.len(), 2); assert!(flag(&u, "fx_open", 1) && !flag(&u, "fx_open", 2)); - assert_eq!(u.app.ui.picker, Some(PickerTarget::Effect { ids: vec![f], index: 1, secondary: false })); + assert_eq!(u.app.ui.picker, None); // Removing the open effect closes its settings and the picker. let rm = remove_button(&mut u, "effects", 1); click(&mut u, rm); @@ -225,3 +224,51 @@ fn the_swatch_that_opened_the_colour_picker_closes_it_again() { click(&mut u, sw); assert_eq!(u.app.ui.picker, None, "the same swatch closes it again"); } + +/// Blank space in the sidebar is outside the picker too, including after adding a paint. +#[test] +fn added_paints_open_beside_the_sidebar_and_close_on_a_sidebar_press() { + for (label, fills) in [("Add fill", true), ("Add stroke", false)] { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"width": 50, "height": 50})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + let add = button(&mut u, label); + click(&mut u, add); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(matches!(u.app.ui.picker, Some(PickerTarget::Paint { fills: f, .. }) if f == fills)); + let pop = u.app.ui.picker_popover.as_ref().unwrap(); + let sidebar_left = u.app.ui.right_panel_rect.unwrap().min.x; + assert!((pop.rect.max.x - (sidebar_left - 8.0)).abs() < 1.0, "{:?}", pop.rect); + assert!(pop.rect.min.y <= add.y, "the picker aligns with its opener or lifts to fit"); + assert!(pop.rect.max.y <= 900.0 - 8.0); + u.button(Pos2::new(1420.0, 850.0), true); + assert!(u.app.ui.picker.is_none(), "a sidebar press dismisses the picker before release"); + } +} + +#[test] +fn page_colour_picker_closes_on_an_outside_press_and_reopens_at_its_control() { + let mut u = Ui::new(Session::new()); + let button = egui::Rect::from_min_size(Pos2::new(1230.0, 220.0), egui::vec2(16.0, 16.0)); + u.app.toggle_picker(PickerTarget::PageBackground, Some(button)); + for _ in 0..3 { + u.frame(vec![]); + } + let old = u.app.ui.picker_popover.take().unwrap().at(Pos2::new(300.0, 100.0)); + u.app.ui.picker_popover = Some(old); + u.frame(vec![]); + u.button(Pos2::new(1420.0, 850.0), true); + assert!(u.app.ui.picker.is_none()); + u.button(Pos2::new(1420.0, 850.0), false); + u.app.toggle_picker(PickerTarget::PageBackground, Some(button)); + for _ in 0..3 { + u.frame(vec![]); + } + let rect = u.app.ui.picker_popover.as_ref().unwrap().rect; + assert!((rect.max.x - (u.app.ui.right_panel_rect.unwrap().min.x - 8.0)).abs() < 1.0); + assert!((rect.min.y - u.app.ui.picker_button.unwrap().min.y).abs() < 1.0); +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index e2439d40..f4fd453e 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -108,6 +108,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { // The panel's edge, drawn here: the frame has no stroke (a stroke counts as margin and would // take 1 pt off each side of the panel (240 pt by default)). let edge = ui.max_rect(); + app.ui.right_panel_rect = Some(edge); ui.painter().line_segment([edge.left_top(), edge.left_bottom()], Stroke::new(1.0, t.border)); let w = ui.available_width(); // Top bar: people (you and the others in a live session), present, share. @@ -309,24 +310,16 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { _ => figcraft_color::Color::DEFAULT_PAGE, }; widgets::section_header(ui, tr("Page"), &[], false); - let mut open_picker = false; - let mut swatch_top = None; let mut new_hex = None; row(ui, 32.0, |ui| { let p = Paint::solid(bg); let sw = widgets::swatch(ui, &p, 16.0); - if sw.clicked() { - open_picker = true; - swatch_top = Some(sw.rect.min.y); - } + app.picker_control(crate::colorpicker::PickerTarget::PageBackground, &sw); if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") { new_hex = Some(h); } widgets::label(ui, "100 %", t.text); }); - if open_picker { - app.toggle_picker(crate::colorpicker::PickerTarget::PageBackground, swatch_top); - } if let Some(h) = new_hex && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) { diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs index 2d458de9..528d19bf 100644 --- a/crates/ui-egui/src/panels/right/effects.rs +++ b/crates/ui-egui/src/panels/right/effects.rs @@ -334,9 +334,7 @@ fn tweaked(e: &Effect, f: impl FnOnce(&mut EffectKind)) -> Effect { fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, secondary: bool, edit: &ColorEdit<'_>) { let Some(c) = e.color(secondary) else { return }; let sw = widgets::swatch(ui, &Paint::solid(c), 16.0); - if sw.clicked() { - app.toggle_picker(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, Some(sw.rect.min.y)); - } + app.picker_control(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, &sw); if let Some(h) = widgets::text_field(ui, Id::unique(("fx_c", i, secondary)), &c.to_hex(), 80.0, "") && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h) { diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 095649ec..11b368d1 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -254,9 +254,7 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: Some(p) => { widgets::paint_swatch(ui.painter(), sw, p); let range_swatch = ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click()); - if range_swatch.clicked() { - app.toggle_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(range_swatch.rect.min.y)); - } + app.picker_control(crate::colorpicker::PickerTarget::TextRange { id, start, end }, &range_swatch); if let Some(c) = p.solid_color() { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); if let Some(h) = @@ -271,9 +269,8 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: } None => { ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, tr("Mixed"), regular(11.0), t.text_secondary); - if ui.interact(r, Id::unique("range_mixed"), Sense::click()).clicked() { - app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end }); - } + let response = ui.interact(r, Id::unique("range_mixed"), Sense::click()); + app.picker_control(crate::colorpicker::PickerTarget::TextRange { id, start, end }, &response); } } // Back to the layer's fill. @@ -314,7 +311,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some() && let Some(l) = &list { - app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: l.len(), ids: ids.clone() }); + app.open_picker(crate::colorpicker::PickerTarget::Paint { fills, index: l.len(), ids: ids.clone() }, Some(header.rect)); } } else { style_picker::open(app, ui, &ids, slot); @@ -454,18 +451,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0)); widgets::paint_swatch(ui.painter(), sw, p); let sresp = ui.interact(sw.expand(2.0), Id::unique(("swatch", fills, i)), Sense::click()); - // While this paint is the one the picker edits, its swatch is held out of the popover's - // closing, so a press on it again reaches the toggle below. - let editing = matches!( - &app.ui.picker, - Some(crate::colorpicker::PickerTarget::Paint { fills: f, index: k, ids: held }) if *f == fills && *k == i && *held == ids - ); - if editing { - app.ui.picker_button = Some(sresp.rect); - } - if sresp.clicked() { - app.toggle_picker(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, Some(sresp.rect.min.y)); - } + app.picker_control(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, &sresp); let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); // The variable bound to this paint's colour (its key on the layers: `fill` or `fills//color`). diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs index 3fdada05..9d7a058c 100644 --- a/crates/ui-egui/src/panels/selection_colors.rs +++ b/crates/ui-egui/src/panels/selection_colors.rs @@ -47,9 +47,14 @@ fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacti colors } -pub(crate) fn toggle(app: &mut FigApp, targets: Vec, top: Option) { +pub(crate) fn toggle(app: &mut FigApp, targets: Vec, response: Option<&egui::Response>) { let Ok(ds) = app.session.ds() else { return }; - app.toggle_picker(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }, top); + let target = PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }; + if let Some(response) = response { + app.picker_control(target, response); + } else { + app.toggle_picker(target, None); + } } /// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity @@ -71,9 +76,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId ui.push_id((&group.targets, "selection_color"), |ui| { crate::right::row(ui, 32.0, |ui| { let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour")); - if swatch.clicked() { - toggle(app, group.targets.clone(), Some(swatch.rect.min.y)); - } + toggle(app, group.targets.clone(), Some(&swatch)); let width = (ui.available_width() - 116.0).max(40.0); let linked_name = group .style @@ -81,9 +84,8 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId .and_then(|id| doc.style(id).map(|s| s.name.as_str())) .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str()))); if let Some(name) = linked_name { - if widgets::dropdown(ui, name, width, None).clicked() { - toggle(app, group.targets.clone(), None); - } + let response = widgets::dropdown(ui, name, width, None); + toggle(app, group.targets.clone(), Some(&response)); } else if let Some(color) = group.paint.solid_color() { if let Some(hex) = widgets::text_field_named(ui, ui.scope_id().with("hex"), &color.to_hex(), width, tr("Color")) { if let Some(color) = Color::from_hex(&hex) { @@ -99,9 +101,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId } } else { let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None); - if response.clicked() { - toggle(app, group.targets.clone(), Some(response.rect.min.y)); - } + toggle(app, group.targets.clone(), Some(&response)); } let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0)); if let Some(value) = widgets::text_field_named(ui, ui.scope_id().with("opacity"), &alpha, 44.0, tr("Opacity")) @@ -312,6 +312,23 @@ mod tests { assert_eq!(colors.len(), 2); } + #[test] + fn a_linked_name_control_tracks_itself_and_toggles_closed() { + let (mut p, a, _) = Panel::new(); + p.app.run("style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"})); + p.app.run("style.apply", json!({"id":a,"style":"Brand","target":"fill"})); + let name = p.frame(vec![]).into_iter().find(|(s, _)| s == "Brand").unwrap().1; + p.click(name); + assert!(p.app.ui.picker.is_some()); + let opening = p.app.ui.picker_button.unwrap(); + let opening_id = p.app.ui.picker_button_id.unwrap(); + p.frame(vec![]); + assert_eq!(p.app.ui.picker_button_id, Some(opening_id), "the swatch must not replace the name control"); + assert_eq!(p.app.ui.picker_button, Some(opening)); + p.click(name); + assert!(p.app.ui.picker.is_none()); + } + #[test] fn styles_menu_applies_a_color_variable_to_the_row() { let (mut p, a, b) = Panel::new(); diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs index dbcf3bdb..14d1e133 100644 --- a/crates/ui-egui/src/popover.rs +++ b/crates/ui-egui/src/popover.rs @@ -140,8 +140,7 @@ impl Popover { self } - /// Whether a press outside it closes it (the default). The colour picker stays open, so a - /// press on a row beside it keeps editing the paint it is on. + /// Whether a press outside it closes it (the default). Disable while sampling with an eyedropper. pub fn close_on_outside(mut self, yes: bool) -> Self { self.outside = yes; self @@ -231,6 +230,9 @@ impl Popover { .into(), ]; ui.painter().set(bg, egui::Shape::Vec(shapes)); + if self.rect != panel { + ctx.request_repaint(); + } self.rect = panel; self.header = header; self.slot = slot; diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index c3bd47c5..99af5f0f 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -864,7 +864,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { PickerTarget::PluginField { plugin, .. } => crate::plugins_ui::field_window(app, ctx, plugin).map(|r| pos2(r.min.x - 248.0, r.min.y)), _ => None, }; - let pos = beside.unwrap_or(pos2(screen.max.x - app.ui.right_width - 260.0, 140.0)); + let open = beside.map(Open::At).unwrap_or_else(|| { + let top = app.ui.picker_button.map_or(140.0, |r| r.min.y); + let edge = app.ui.right_panel_rect.map_or(screen.max.x - app.ui.right_width, |r| r.min.x); + Open::Anchor(pos2(edge - 8.0, top)) + }); // The colour picker's own header — title 16 in, the X 8 in from the right — is the shared // popover's chrome now; the picker keeps its body. Its area id is [`window_id`], the one the // style picker looks up to tell a press in here from one outside. @@ -873,13 +877,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { // colour it just sampled — is the press, not a press outside: neither may close the picker. let picking = app.session.tool.pick_only || app.session.tool.picked.is_some(); pop = pop.close_on_outside(!picking); - // Closing is the shared widget's: the X, Escape and a press outside it. Presses in the panel - // beside it keep it (swatches and fields there switch the colour it edits); the control that - // opened it is held too, so pressing it again reaches its own toggle. - let panel = Rect::from_min_max(pos2(screen.max.x - app.ui.right_width, screen.min.y), screen.max); - let mut held: Vec = vec![panel]; - held.extend(app.ui.picker_button); - let closed = pop.show(ctx, Open::At(pos), Hold { rects: &held, escape: false }, |ui| { + // Only the opening control is held out: any other sidebar or canvas press dismisses it. + let mut held: Vec = app.ui.picker_button.into_iter().collect(); + held.extend(crate::variable_ui::picker_rect(ctx)); + let closed = pop.show(ctx, open, Hold { rects: &held, escape: false }, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...). if let Some(c) = app.session.tool.picked.take() { @@ -1043,6 +1044,7 @@ pub fn close(app: &mut FigApp) { app.ui.picker = None; app.ui.picker_popover = None; app.ui.picker_button = None; + app.ui.picker_button_id = None; } /// Swatches wrapped in rows inset 16 points from both sides of the picker. diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index b1001a0e..11708388 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -1149,7 +1149,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { if let Some(e) = &p.edit { commit_edit(app, &doc, e); } - app.ui.picker = Some(PickerTarget::Style { id, index }); + app.open_picker(PickerTarget::Style { id, index }, None); } Some(Act::Unbind(id, field)) => { app.run("style.bind", json!({"style": id, "field": field})); 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/gaps.md b/docs/gaps.md index 2c04ec5b..3d820782 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -562,6 +562,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Colour picker review follow-up: opening the first picker preserves a text undo interaction; placement follows the rendered sidebar; opening controls track their widget ids while moving; linked selection-colour names toggle closed; gradient variable-picker presses keep the parent open. Regression tests cover each case; estimates unchanged. | +| 2026-10-29 | minor | Colour pickers dismiss on presses outside their card, including the sidebar, and open 8 pt beside the sidebar aligned to their control (lifted to fit the screen); switching targets resets a dragged position and commits pending edits. Eyedropper sampling stays open. UI regression tests cover added fills/strokes and page colours; headline estimates unchanged. | | 2026-10-29 | minor | Gap 7: the browser draws a zoom's full render a band at a time (eight at most), so a deep zoom no longer holds the page for the whole render; pans and edits meanwhile are patched in once it is drawn. Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 2026-10-28 | minor | Gap 7: the browser refines a zoom's preview only once the zoom has paused for 120 ms, so a wheel zoom no longer runs a blocking full render between every two steps. Numbers 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 195a6a59..f3c1ba42 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 (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); 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 @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| | 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged | +| 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e0a07b1d..282259ef 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -30,6 +30,7 @@ interaction feel not yet compared. | Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) | | Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) | | Colour picker models | done | Hex, RGB, HSL, HSB, LCH, OKLCH and CSS; LCH and OKLCH keep lightness and hue when a colour leaves sRGB (chroma comes down); the values typed stay while the colour is theirs (a hue typed on a grey, a chroma sRGB can't hold); CSS reads `lch()` and `oklch()` with any hue unit and `none` (`figcraft_color::{Lch, Oklch}`). Values are the stored channels read as sRGB | +| Colour picker placement and dismissal | done | Opens 8 pt beside the sidebar at the opening control, lifted to fit the screen; outside presses (including the sidebar), Escape and X close it. Reopening resets a dragged position; eyedropper sampling keeps it open. | | Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) | | Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas | @@ -174,6 +175,8 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Colour picker review follow-up: opening the first picker preserves a text undo interaction; placement follows the rendered sidebar; opening controls track their widget ids while moving; linked selection-colour names toggle closed; gradient variable-picker presses keep the parent open. Regression tests cover each case; estimates unchanged. | +| 2026-10-29 | minor | Colour pickers dismiss on presses outside their card, including the sidebar, and open 8 pt beside the sidebar aligned to their control (lifted to fit the screen); switching targets resets a dragged position and commits pending edits. Eyedropper sampling stays open. UI regression tests cover added fills/strokes and page colours; headline estimates unchanged. | | 2026-10-29 | minor | M1: Corner smoothing has an original icon and a 24 pt drag target, using the shared field gesture (live changes, fractional values preserved across consecutive drags, Shift/Alt rates, 0–100% clamping, one undo step). The field now has an accessible name (`corner_individual_tests`). Numbers unchanged | | 2026-10-29 | minor | Crop image (#1464): Object ▸ Crop image, the right-click menu and a crop button on the image fill row enter crop mode on the canvas (`image.crop`; the image dimmed outside the layer, corner brackets and edge bars on its box; Enter, Escape or a click outside finishes). The row stays done | | 2026-10-29 | minor | Toolbar (#1210): every state of the pill (tools, vector edit, shape builder, view only) is laid out on one 32 pt cell, 8 pt in from its ends and 8 pt between its items, with a 1 pt divider and 14 pt mode-switcher marks; the Resources button is gone, as in UI3 (the left rail's Assets tab, Shift+I, Alt+2 and View ▸ Panels ▸ Assets open the Assets panel); a narrow window still scrolls the pill (#1209) (`resources_tests`, `toolbar` tests). Numbers unchanged |