From be9223a9ed495753552cd37b368604672cb4f885 Mon Sep 17 00:00:00 2001 From: samhyun Date: Fri, 30 Oct 2026 05:58:28 +0900 Subject: [PATCH 01/26] M8: stacked layer blurs keep the margin they reach together Layer blurs and texture apply one after another, so together they reach further than any one of them, and drop shadows are cast from the blurred layer. effect_margin took the largest single effect, so a layer cut at a view's or band's edge missed content that still reached in: four blurs of 25 drawn in 64-row bands differed from a render in one piece by 8 levels, and a blurred rectangle above the view cast its shadow into it 143 levels off. The margin is now the layer effects' reaches added up, plus the largest of the others; each reach counts up to 1e12 so the sum stays finite. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/draw.rs | 28 +++++++--- crates/render/src/retained_tests.rs | 81 +++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 4ef427e4..a30c7bb0 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -472,17 +472,32 @@ pub(crate) fn bounds_within<'a>( (r, levels) } +/// The most one effect counts for in [`effect_margin`] (document units): far past any view, and +/// sums of any number of them stay finite. +const MAX_REACH: f64 = 1e12; + /// How far (document units) effects that read outside a node's paint bounds reach: blurs, glass, /// drop shadows and texture. Culling and partial renders keep this much margin. +/// +/// Layer blurs and texture apply one after another, each moving what the one before it moved, so +/// their reaches add up. What the other effects paint goes through them or is cast from their +/// result (drop shadows from the blurred layer, a backdrop blurred with it), so the largest of +/// those reaches comes on top. Each reach counts up to [`MAX_REACH`], so the sum stays finite. pub fn effect_margin(n: &Node) -> f64 { - n.effects - .iter() - .filter(|e| e.visible) + let visible = || n.effects.iter().filter(|e| e.visible); + let finite = |m: f64| if m.is_finite() { m.clamp(0.0, MAX_REACH) } else { 0.0 }; + let layer: f64 = visible() .map(|e| match &e.kind { EffectKind::LayerBlur(b) => b.max_radius() * 2.0, + EffectKind::Texture(t) => t.radius.abs(), + _ => 0.0, + }) + .map(finite) + .sum(); + let others = visible() + .map(|e| match &e.kind { EffectKind::BackgroundBlur { .. } | EffectKind::Glass(_) => e.backdrop_radius() * 2.0, EffectKind::DropShadow(s) => s.radius * 2.0 + s.spread.abs() * figcraft_doc::SPREAD_MITER_LIMIT + s.offset.0.abs() + s.offset.1.abs(), - EffectKind::Texture(t) => t.radius.abs(), // Inner shadows cast from around a layer's pixels (groups, image fills with alpha; any // image counts here): a layer cut off at the view would cast one from the cut. EffectKind::InnerShadow(s) @@ -492,8 +507,9 @@ pub fn effect_margin(n: &Node) -> f64 { } _ => 0.0, }) - .filter(|m| m.is_finite()) - .fold(0.0, f64::max) + .map(finite) + .fold(0.0, f64::max); + layer + others } /// Everything node `id` can paint, in page coordinates: its paint bounds (glyphs of overflowing diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index 518033ae..bdc1be19 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -373,3 +373,84 @@ fn stacked_backdrop_blurs_draw_the_same_in_bands() { assert_same(&pix, &render_view(&doc, page, &shown, &Options::default()).unwrap(), &format!("bands of {band_h} rows")); } } + +/// Layer blurs stacked on one layer apply one after another, so they reach as far as all of them +/// together: a layer cut at a band's edge keeps that much margin, and the bands (the browser's, +/// the desktop's) match a render in one piece, as does a view cut across the blur. +#[test] +fn sequential_layer_blurs_draw_the_same_in_bands() { + fonts(); + for (count, radius) in [(4, 25.0), (4, 100.0), (8, 10.0)] { + let mut d = Document::new("sequential"); + let page = d.first_page().unwrap(); + for i in 0..40 { + let r = d.create(page, NodeKind::Rectangle, "S", 0.0, f64::from(i) * 10.0, 400.0, 5.0).unwrap(); + solid(d.get_mut(r).unwrap(), if i % 2 == 0 { "E03030" } else { "3070E0" }); + } + let b = d.create(page, NodeKind::Rectangle, "B", 100.0, 150.0, 200.0, 100.0).unwrap(); + solid(d.get_mut(b).unwrap(), "20C040"); + for _ in 0..count { + d.get_mut(b).unwrap().effects.push(Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(radius)), visible: true }); + } + let doc = Arc::new(d); + let what = format!("{count} blurs of {radius}"); + let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 }; + for band_h in [64, 100] { + let mut r = Retained::default(); + let (shown, pix, n) = stepped(&mut r, &doc, page, &v, band_h); + assert!(n >= 4, "{what}: {n} bands of {band_h} rows"); + let one = crate::render_view_single(&doc, page, &shown, &Options::default()).unwrap(); + assert_within(&pix, &one, 0, 2, &format!("{what}, bands of {band_h} rows")); + } + // The desktop splits a view this large into parallel bands. + let big = View { origin: (0.0, 0.0), scale: 1.0, width: 800, height: 800 }; + let one = crate::render_view_single(&doc, page, &big, &Options::default()).unwrap(); + assert_within(&render_view(&doc, page, &big, &Options::default()).unwrap(), &one, 0, 2, &format!("{what}, parallel bands")); + // A view starting halfway down the blur: its rows as in the whole view. + let cut = View { origin: (0.0, 200.0), scale: 1.0, width: 800, height: 600 }; + let part = crate::render_view_single(&doc, page, &cut, &Options::default()).unwrap(); + assert_within(&part, &one, 200, 2, &format!("{what}, view cut across the blur")); + } +} + +/// A drop shadow is cast from the blurred layer: a layer cut at the view keeps the blur's reach on +/// top of the shadow's, or a rectangle above the view casts a shadow into it from a cut blur. +#[test] +fn a_shadow_cast_from_a_blurred_layer_draws_the_same_in_a_cut_view() { + fonts(); + let mut d = Document::new("shadowed blur"); + let page = d.first_page().unwrap(); + let b = d.create(page, NodeKind::Rectangle, "B", 100.0, 0.0, 200.0, 100.0).unwrap(); + solid(d.get_mut(b).unwrap(), "20C040"); + let shadow = Shadow { color: Color::new(0.0, 0.0, 0.0, 1.0), offset: (0.0, 300.0), radius: 0.0, ..Shadow::default() }; + d.get_mut(b).unwrap().effects = vec![ + Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(50.0)), visible: true }, + Effect { kind: EffectKind::DropShadow(shadow), visible: true }, + ]; + let whole = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 600 }; + let one = crate::render_view_single(&d, page, &whole, &Options::default()).unwrap(); + // The view starts below the rectangle, near the bottom of its shadow: a margin of the shadow's + // reach alone would cut the layer across the rectangle. + let cut = View { origin: (0.0, 390.0), scale: 1.0, width: 400, height: 210 }; + let part = crate::render_view_single(&d, page, &cut, &Options::default()).unwrap(); + assert_within(&part, &one, 390, 2, "the shadow of a blurred layer above the view"); +} + +/// Reaches too large to add (texture of 1e308, twice) still keep a margin, not none. +#[test] +fn effect_margins_too_large_to_add_stay_large() { + let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "T", 10.0, 10.0); + let t = figcraft_doc::Texture { radius: 1e308, ..Default::default() }; + n.effects = vec![Effect { kind: EffectKind::Texture(t.clone()), visible: true }, Effect { kind: EffectKind::Texture(t), visible: true }]; + let m = crate::draw::effect_margin(&n); + assert!(m.is_finite() && m > 1e6, "{m}"); +} + +/// Every channel of `a` within `tol` of the same channel of `b` from row `rows_down` on. +fn assert_within(a: &Pixmap, b: &Pixmap, rows_down: u32, tol: u8, what: &str) { + assert_eq!(a.width(), b.width(), "{what}: widths"); + let row = a.width() as usize * 4; + let below = b.data().get(rows_down as usize * row..).unwrap(); + let worst = a.data().iter().zip(below).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); + assert!(worst <= tol, "{what}: differs by {worst} levels"); +} From 1fabb9aa534af2907327715d2a390da92e1e3be9 Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Fri, 30 Oct 2026 03:16:40 +0500 Subject: [PATCH 02/26] M1: move frame actions and dev status into header --- crates/ui-egui/src/icons.rs | 1 + crates/ui-egui/src/icons/draw.rs | 8 +++ crates/ui-egui/src/panels/right.rs | 23 ++++-- crates/ui-egui/src/panels/section_panel.rs | 83 +++++++++++++++------- 4 files changed, 86 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index a891abed..f8480127 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -30,6 +30,7 @@ pub use draw::{paint, paint_outlines}; icons! { Move, Scale, + FitContents, Hand, Frame, Section, diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs index 953ef2de..1d392550 100644 --- a/crates/ui-egui/src/icons/draw.rs +++ b/crates/ui-egui/src/icons/draw.rs @@ -285,6 +285,14 @@ fn draw(p: &P, icon: Icon) { p.line(6.0, 10.0, 13.0, 3.0); p.poly(&[(9.5, 2.5), (13.5, 2.5), (13.5, 6.5)], false); } + FitContents => { + // Four frame corners around the content: a compact mark for fitting the frame. + p.poly(&[(2.5, 6.0), (2.5, 2.5), (6.0, 2.5)], false); + p.poly(&[(10.0, 2.5), (13.5, 2.5), (13.5, 6.0)], false); + p.poly(&[(2.5, 10.0), (2.5, 13.5), (6.0, 13.5)], false); + p.poly(&[(10.0, 13.5), (13.5, 13.5), (13.5, 10.0)], false); + p.rect(5.5, 5.5, 10.5, 10.5, 0.0); + } Hand => { hand(p); } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index f4fd453e..2eeccea5 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -384,16 +384,23 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }; let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); - let lr = Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 12.0), vec2(120.0, 24.0)); + // Keep the type menu and frame actions together. A short label must not leave + // the orientation icon floating in the middle of the header. + // The handoff icon also fits beside the frame controls at the panel minimum. + let max_label_width = if is_frame { (w - 202.0).clamp(40.0, 120.0) } else { 92.0 }; + let shown = if is_frame || is_group { widgets::elide_font(ui, &label, max_label_width, semibold(13.0)) } else { label.clone() }; + let label_width = if is_frame || is_group { ui.painter().layout_no_wrap(shown.clone(), semibold(13.0), t.text).size().x + 28.0 } else { 120.0 }; + let lr = Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 12.0), vec2(label_width, 24.0)); let resp = ui.interact(lr, ui.scope_id().with("typedd"), Sense::click()); if resp.hovered() { ui.painter().rect_filled(lr, CornerRadius::same(5), t.hover); } - ui.painter().text(pos2(lr.min.x + 8.0, lr.center().y), Align2::LEFT_CENTER, &label, semibold(13.0), t.text); + let resp = if shown == label { resp } else { resp.on_hover_text(&label) }; + let label_rect = ui.painter().text(pos2(lr.min.x + 8.0, lr.center().y), Align2::LEFT_CENTER, &shown, semibold(13.0), t.text); if is_frame || is_group { icons::paint( ui.painter(), - Rect::from_center_size(pos2(lr.min.x + 18.0 + label.len() as f32 * 6.5, lr.center().y), vec2(10.0, 10.0)), + Rect::from_center_size(pos2(label_rect.right() + 8.0, lr.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary, ); @@ -402,7 +409,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { menu_popup(&resp).width(180.0).show(|ui| kind_items(app, ui, false)); } if is_frame { - let sr = Rect::from_min_size(pos2(lr.max.x + 4.0, r.center().y - 12.0), vec2(24.0, 24.0)); + let sr = Rect::from_min_size(pos2(lr.max.x + 2.0, r.center().y - 12.0), vec2(24.0, 24.0)); let sresp = ui.interact(sr, ui.scope_id().with("swap_orient"), Sense::click()).tip(tr("Swap orientation")); if sresp.hovered() { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); @@ -411,6 +418,14 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { if sresp.clicked() { app.run("frame.swapOrientation", json!({"ids": sel})); } + let fit_r = sr.translate(vec2(26.0, 0.0)); + let fit_tip = crate::shortcuts::tip(tr("Fit to contents"), "object.resizeToFit"); + if widgets::icon_button_at(ui, ui.scope_id().with("frame_fit_contents"), fit_r, Icon::FitContents, fit_tip) { + app.run("object.resizeToFit", json!({"ids": sel})); + } + if figcraft_engine::devinspect::has_status(doc, sel[0]) { + crate::section_panel::status_icon(app, ui, doc, sel, fit_r.translate(vec2(26.0, 0.0))); + } let ids: Vec = sel.to_vec(); menu_popup(&resp).width(260.0).show(|ui| { egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { diff --git a/crates/ui-egui/src/panels/section_panel.rs b/crates/ui-egui/src/panels/section_panel.rs index f3240d57..daa793db 100644 --- a/crates/ui-egui/src/panels/section_panel.rs +++ b/crates/ui-egui/src/panels/section_panel.rs @@ -1,14 +1,14 @@ //! Design panel extras for frames and sections: section colours, hiding a section's contents, //! handoff status (with resolving a change to a ready-for-dev layer) and resize to fit. -use egui::{CornerRadius, Sense, Stroke, Ui, vec2}; +use egui::{CornerRadius, Rect, Sense, Stroke, Ui, vec2}; use figcraft_doc::{DevStatus, Document, NodeId, NodeKind}; use serde_json::json; use crate::FigApp; use crate::i18n::{tr, tr_noop, trf}; -use crate::icons::Icon; -use crate::right::{common, menu_item, menu_popup, row}; +use crate::icons::{self, Icon}; +use crate::right::{common, menu_check, menu_popup, row}; use crate::widgets; /// Section fill presets: light tints that keep dark text readable. @@ -29,25 +29,52 @@ fn is_section(doc: &Document, id: NodeId) -> bool { pub use figcraft_engine::devinspect::has_status; -/// The handoff status of `ids` as a dropdown `width` wide ("Changed" when edited since marked ready, -/// "Mixed" when they differ). -pub fn status_dropdown(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], width: f32) { - let label = match common(doc, ids, |n| (n.dev_status, n.dev_changed)) { +/// The handoff status shown by either control ("Changed" when edited since marked ready, +/// "Mixed" when the selection differs). +fn status_label(doc: &Document, ids: &[NodeId]) -> String { + match common(doc, ids, |n| (n.dev_status, n.dev_changed)) { Some((DevStatus::ReadyForDev, true)) => tr("Changed"), Some((d, _)) => tr(d.label()), None => tr("Mixed"), - }; - let resp = widgets::dropdown(ui, label, width, Some(Icon::Code)); - menu_popup(&resp).show(|ui| { - for d in DevStatus::ALL { - if menu_item(ui, tr(d.label()), "") { - app.set_props(ids, json!({"devStatus": d.id()})); - ui.close(); - } + } + .to_string() +} + +fn status_menu(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], doc: &Document) { + let current = common(doc, ids, |n| n.dev_status); + for d in DevStatus::ALL { + if menu_check(ui, current == Some(d), tr(d.label()), "") { + app.set_props(ids, json!({"devStatus": d.id()})); + ui.close(); } + } +} + +pub fn status_dropdown(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], width: f32) { + let resp = widgets::dropdown(ui, &status_label(doc, ids), width, Some(Icon::Code)); + menu_popup(&resp).show(|ui| { + status_menu(app, ui, ids, doc); }); } +/// Compact handoff control in a single frame's selection header. +pub fn status_icon(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], rect: Rect) { + let t = crate::theme::Tokens::get(ui.ctx()); + let tip = format!("{}: {}", tr("Status"), status_label(doc, ids)); + let resp = ui.interact(rect, ui.scope_id().with("frame_dev_status"), Sense::click()).on_hover_text(tip.clone()); + crate::a11y::menu_button(&resp, &tip, crate::a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover); + } + let color = match common(doc, ids, |n| (n.dev_status, n.dev_changed)) { + Some((DevStatus::ReadyForDev, true)) => t.warning, + Some((DevStatus::None, _)) | None => t.icon, + Some(_) => t.accent_text, + }; + icons::paint(ui.painter(), rect.shrink(5.0), Icon::Code, color); + menu_popup(&resp).width(160.0).show(|ui| status_menu(app, ui, ids, doc)); +} + /// Draw section colour and hide-contents controls, Dev status, changed-reason Resolve, the /// last-change note and Resize to fit; returns for an empty selection or one without a Dev /// status. @@ -58,6 +85,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } let t = crate::theme::Tokens::get(ui.ctx()); let ids = sel.to_vec(); + let single_frame = sel.len() == 1 && sel.first().and_then(|id| doc.node(*id)).is_some_and(|n| matches!(n.kind, NodeKind::Frame)); let col = widgets::col_width(ui.available_width()); if sections { widgets::sublabels(ui, tr("Section color"), None); @@ -85,9 +113,11 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } }); } - widgets::sublabels(ui, tr("Status"), None); + if !single_frame { + widgets::sublabels(ui, tr("Status"), None); + row(ui, 32.0, |ui| status_dropdown(app, ui, doc, &ids, col * 2.0 + 8.0)); + } let changed = sel.iter().any(|i| doc.node(*i).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed)); - row(ui, 32.0, |ui| status_dropdown(app, ui, doc, &ids, col * 2.0 + 8.0)); if changed { // Changed since marked ready: say why to resolve it (back to Ready for dev). row(ui, 32.0, |ui| { @@ -117,14 +147,17 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { widgets::label(ui, &text, t.text_secondary); }); } - row(ui, 32.0, |ui| { - let fit = ui - .add_sized(vec2(col * 2.0 + 8.0, widgets::FIELD_H), egui::Button::new(tr("Resize to fit"))) - .on_hover_text(crate::shortcuts::tip(tr("Fit to contents"), "object.resizeToFit")); - if fit.clicked() { - app.run("object.resizeToFit", json!({"ids": ids})); - } - }); + // A single frame has the same action beside its type in the selection header. + if !single_frame { + row(ui, 32.0, |ui| { + let fit = ui + .add_sized(vec2(col * 2.0 + 8.0, widgets::FIELD_H), egui::Button::new(tr("Resize to fit"))) + .on_hover_text(crate::shortcuts::tip(tr("Fit to contents"), "object.resizeToFit")); + if fit.clicked() { + app.run("object.resizeToFit", json!({"ids": ids})); + } + }); + } ui.add_space(4.0); widgets::separator(ui); } From bf97557eb2a28dbd8466a639a72aa5271627202c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:28:51 +0000 Subject: [PATCH 03/26] Keep combining marks inside the word in title case Found while testing crates/doc/src/text.rs:150 (TextCase::apply_impl, Title arm) with is_word_char at :156 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 1 + crates/doc/Cargo.toml | 1 + crates/doc/src/text.rs | 34 ++++++++++++++++++++++++++++++---- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index df1d52b4..6833af8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1806,6 +1806,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "unicode-segmentation", ] [[package]] diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 5609ed85..c4346852 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -15,3 +15,4 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } imbl = { workspace = true } +unicode-segmentation = { workspace = true } diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index b7c5e4cc..b0c3d7ea 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -150,8 +150,12 @@ impl TextCase { } else { out.push(ch); // An apostrophe only continues a word right after a letter or digit. - let opening_apostrophe = matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(char::is_alphanumeric); - start = !is_word_char(ch) || opening_apostrophe; + let opening_apostrophe = + matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(|c| c.is_alphanumeric() || is_combining_mark(c)); + // A combining mark attaches to the char before it and never changes word state. + if !is_combining_mark(ch) { + start = !is_word_char(ch) || opening_apostrophe; + } } last = Some(ch); } @@ -166,9 +170,23 @@ impl TextCase { } } -/// Letters, digits and apostrophes continue a word (so "don't" stays "Don't"). +/// Letters, digits, combining marks and apostrophes continue a word (so "don't" stays "Don't" +/// and decomposed accents stay inside their word). fn is_word_char(c: char) -> bool { - c.is_alphanumeric() || matches!(c, '\'' | '\u{2019}') + c.is_alphanumeric() || is_combining_mark(c) || matches!(c, '\'' | '\u{2019}') +} + +/// Combining marks such as the U+0301 accent in decomposed text: non-alphanumeric chars that +/// extend the grapheme of the char before them. +fn is_combining_mark(c: char) -> bool { + use unicode_segmentation::UnicodeSegmentation; + if c.is_ascii() || c.is_alphanumeric() { + return false; + } + let mut buf = [0u8; 8]; + buf[0] = b'a'; + let end = 1 + c.encode_utf8(&mut buf[1..]).len(); + std::str::from_utf8(&buf[..end]).is_ok_and(|s| s.graphemes(true).count() == 1) } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -969,4 +987,12 @@ mod title_case_tests { assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t"); assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T"); } + + #[test] + fn title_case_combining_marks_do_not_break_words() { + assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301}"), "Re\u{301}sume\u{301}"); + assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301} bon"), "Re\u{301}sume\u{301} Bon"); + assert_eq!(TextCase::Title.apply("a \u{301}b"), "A \u{301}B"); + assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s"); + } } From cc3052de5e23b8e578311c529dec13f47f623ef4 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 06:03:35 +0700 Subject: [PATCH 04/26] M8: cap the input of boolean operations (#1104) (#1581) Each sweep takes at most 250 000 segments and 2 million possible crossings (pairs of segments whose bounds overlap, counted in O(n log n)), and a fold at most 4 million segments swept over its steps; past them the operation is refused with PathOpsError::TooComplex before it runs. A boolean group past the caps draws without its result, with a warning once, and the refusal is cached; vector.flatten refuses it instead of dropping it. --- ROADMAP.md | 3 +- crates/engine/src/boolean_cap_tests.rs | 39 +++++++ crates/engine/src/cmd/structure.rs | 27 +++-- crates/engine/src/lib.rs | 2 + crates/pathops/src/budget.rs | 130 ++++++++++++++++++++++ crates/pathops/src/budget_tests.rs | 95 ++++++++++++++++ crates/pathops/src/lib.rs | 33 +++++- crates/render/src/boolean.rs | 66 ++++++++--- crates/render/src/boolean_budget_tests.rs | 57 ++++++++++ crates/render/src/boolean_cache_tests.rs | 4 +- crates/render/src/draw.rs | 3 +- crates/render/src/lib.rs | 2 + docs/roadmap.md | 3 +- docs/ui-parity.md | 5 +- 14 files changed, 436 insertions(+), 33 deletions(-) create mode 100644 crates/engine/src/boolean_cap_tests.rs create mode 100644 crates/pathops/src/budget.rs create mode 100644 crates/pathops/src/budget_tests.rs create mode 100644 crates/render/src/boolean_budget_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 519f94d6..58256579 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 (figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1104: boolean operations had no input cap, so a crafted file could keep the sweep busy for unbounded time and memory (any number of segments, crossing as often as they like, and a fold re-sweeping its growing result for every operand). Each sweep now takes at most 250 000 segments and 2 million possible crossings (pairs of segments whose bounds overlap, counted exactly in O(n log n)), and a fold at most 4 million segments swept over all its steps; past them the operation is refused with `TooComplex` ("the shapes are too complex to combine") before it runs. The example files' boolean groups sweep at most 55 segments and 155 possible crossings per step. A boolean group past the caps draws without its result, with a warning, once, like layers past the render's memory budget; the refusal is remembered, so redrawing it costs nothing; Flatten refuses it instead of dropping it. Numbers unchanged. Tests: `budget_tests` (pathops), `boolean_budget_tests` (render), `boolean_cap_tests` (engine). | | 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged | | 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged | | 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged | diff --git a/crates/engine/src/boolean_cap_tests.rs b/crates/engine/src/boolean_cap_tests.rs new file mode 100644 index 00000000..b9d40e34 --- /dev/null +++ b/crates/engine/src/boolean_cap_tests.rs @@ -0,0 +1,39 @@ +//! A boolean group past the path operations' caps: it stays a boolean group, flattening it is +//! refused with the reason, and the document is unchanged. + +use figcraft_doc::{NodeKind, VectorNetwork}; +use figcraft_geom::kurbo::{BezPath, Rect, Shape}; +use serde_json::json; + +use crate::Session; +use crate::params::id_of; + +/// `n` long thin bars, across or down: 750 of each cross 2.25 million times, past the caps. +fn bars(n: usize, across: bool) -> BezPath { + let mut p = BezPath::new(); + for i in 0..n { + let (at, len) = (i as f64 * 4.0, n as f64 * 4.0); + let r = if across { Rect::new(0.0, at, len, at + 2.0) } else { Rect::new(at, 0.0, at + 2.0, len) }; + p.extend(r.path_elements(0.1)); + } + p +} + +#[test] +fn flattening_a_boolean_past_the_caps_is_refused() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let ids: Vec<_> = [true, false] + .into_iter() + .map(|across| { + let network = VectorNetwork::from_path(&bars(750, across)); + s.doc_mut().unwrap().create(page, NodeKind::Vector { network }, "Bars", 0.0, 0.0, 3000.0, 3000.0).unwrap() + }) + .collect(); + let g = id_of(&s.execute("bool.union", &json!({"ids": ids})).unwrap()["id"]).unwrap(); + let e = s.execute("vector.flatten", &json!({"ids": [g]})).unwrap_err().to_string(); + assert!(e.contains("too complex to combine"), "{e}"); + let doc = s.doc().unwrap(); + assert!(matches!(doc.node(g).map(|n| &n.kind), Some(NodeKind::BooleanOp { .. })), "the group is kept"); + assert_eq!(doc.node(g).map(|n| n.children.len()), Some(2)); +} diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index b99ba406..52b4a415 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -475,13 +475,7 @@ fn collect_flatten_parts( part } _ => { - let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { return Ok(()) }; - let mut part = VectorNetwork::from_path(&path); - for region in &mut part.regions { - if rule == figcraft_render::FillRule::EvenOdd { - region.winding = WindingRule::EvenOdd; - } - } + let Some(part) = outline_part(doc, node)? else { return Ok(()) }; part } }; @@ -490,6 +484,25 @@ fn collect_flatten_parts( Ok(()) } +/// What a layer other than a vector or a container flattens into: its outline (`None`: it has +/// none). A boolean group has no outline only past the path operations' caps; flattening it would +/// drop it, so that is refused. +fn outline_part(doc: &Document, node: &Node) -> Result> { + let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { + if matches!(node.kind, NodeKind::BooleanOp { .. }) { + return Err(bad("vector.flatten", figcraft_pathops::PathOpsError::TooComplex.to_string())); + } + return Ok(None); + }; + let mut part = VectorNetwork::from_path(&path); + for region in &mut part.regions { + if rule == figcraft_render::FillRule::EvenOdd { + region.winding = WindingRule::EvenOdd; + } + } + Ok(Some(part)) +} + fn append_flatten_part( node: &Node, fallback_fills: Option<&[Paint]>, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4e7b400d..e2969741 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -207,6 +207,8 @@ mod board_tests; #[cfg(test)] mod board_tools_tests; #[cfg(test)] +mod boolean_cap_tests; +#[cfg(test)] mod booleans_tests; #[cfg(test)] mod bound_alpha_tests; diff --git a/crates/pathops/src/budget.rs b/crates/pathops/src/budget.rs new file mode 100644 index 00000000..ff6f9cab --- /dev/null +++ b/crates/pathops/src/budget.rs @@ -0,0 +1,130 @@ +//! Input caps for the sweep: a boolean, a simplification or an arrangement past them is refused +//! with [`PathOpsError::TooComplex`] instead of running for unbounded time and memory (a crafted +//! file can hold any number of segments, crossing each other as often as it likes). +//! +//! The caps sit far above real designs: the boolean groups in the example files sweep at most 55 +//! segments with 155 possible crossings per step and 75 segments over a whole fold, and their +//! largest outline has about 4 000 segments. In a native release build the sweep takes 0.3 s for +//! 200 000 segments and 1.8 s for 2 million crossings, and a union of 1 000 squares one by one +//! (2 million segments swept) takes 1.8 s. + +use figcraft_geom::kurbo::{BezPath, ParamCurveExtrema, Rect}; + +use crate::PathOpsError; + +/// The most segments one sweep takes, over all its inputs. +pub const MAX_SEGMENTS: usize = 250_000; + +/// The most crossings one sweep may have to split at, counted as the pairs of segments whose +/// bounds overlap (every crossing is one of them). +pub const MAX_CROSSINGS: u64 = 2_000_000; + +/// The most segments a fold of several operands sweeps over all its steps ([`FoldBudget`]): each +/// step sweeps the result so far again. +pub const MAX_FOLD_SEGMENTS: usize = 4_000_000; + +/// How complex the sweep of some paths is ([`complexity`]). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Complexity { + pub segments: usize, + /// Pairs of segments whose bounds overlap (touching counts), counted up to [`MAX_CROSSINGS`] + 1. + pub crossings: u64, +} + +/// The segments of `paths` and the pairs of them that may cross; `None` past [`MAX_SEGMENTS`] +/// (counting stops there, so a huge input is refused at once). +pub fn complexity<'a>(paths: impl IntoIterator) -> Option { + let mut boxes = Vec::new(); + for seg in paths.into_iter().flat_map(BezPath::segments) { + if boxes.len() == MAX_SEGMENTS { + return None; + } + boxes.push(seg.bounding_box()); + } + Some(Complexity { segments: boxes.len(), crossings: overlapping_pairs(&boxes, MAX_CROSSINGS) }) +} + +/// Refuse `paths` past [`MAX_SEGMENTS`] or [`MAX_CROSSINGS`]. +pub(crate) fn check<'a>(paths: impl IntoIterator) -> Result<(), PathOpsError> { + match complexity(paths) { + Some(c) if c.crossings <= MAX_CROSSINGS => Ok(()), + _ => Err(PathOpsError::TooComplex), + } +} + +/// What a fold of several operands has swept so far, against [`MAX_FOLD_SEGMENTS`]. +#[derive(Debug, Default)] +pub struct FoldBudget(usize); + +impl FoldBudget { + /// Count the step combining `acc` with `p`: refused once the fold would sweep more than + /// [`MAX_FOLD_SEGMENTS`]. + pub fn spend(&mut self, acc: &BezPath, p: &BezPath) -> Result<(), PathOpsError> { + self.0 = self.0.saturating_add(acc.segments().count()).saturating_add(p.segments().count()); + if self.0 > MAX_FOLD_SEGMENTS { Err(PathOpsError::TooComplex) } else { Ok(()) } + } +} + +/// How many pairs of `boxes` overlap (touching counts), counted up to `limit` + 1, in +/// O(n log n): a sweep across keeps the boxes it is inside, and a box it enters overlaps those +/// of them that neither end above it nor start below it. +fn overlapping_pairs(boxes: &[Rect], limit: u64) -> u64 { + // The y coordinates by rank, for counting the boxes the sweep is inside by their top and bottom. + let mut ys: Vec = boxes.iter().flat_map(|b| [b.y0, b.y1]).collect(); + ys.sort_unstable_by(f64::total_cmp); + ys.dedup(); + let below = |y: f64| ys.partition_point(|v| *v < y); + let up_to = |y: f64| ys.partition_point(|v| *v <= y); + // Entering at x0 before leaving at x1, so boxes that only touch across count. + let mut events: Vec<(f64, bool, &Rect)> = boxes.iter().flat_map(|b| [(b.x0, false, b), (b.x1, true, b)]).collect(); + events.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); + let (mut tops, mut bottoms) = (Fenwick::new(ys.len()), Fenwick::new(ys.len())); + let (mut inside, mut pairs) = (0u64, 0u64); + for (_, leaving, b) in events { + if leaving { + tops.add(below(b.y0), -1); + bottoms.add(below(b.y1), -1); + inside = inside.saturating_sub(1); + continue; + } + let ending_above = bottoms.count_below(below(b.y0)); + let starting_below = inside.saturating_sub(tops.count_below(up_to(b.y1))); + pairs += inside.saturating_sub(ending_above + starting_below); + if pairs > limit { + return pairs; + } + tops.add(below(b.y0), 1); + bottoms.add(below(b.y1), 1); + inside += 1; + } + pairs +} + +/// Counts by rank, with the count below a rank in O(log n) (a Fenwick tree). +struct Fenwick(Vec); + +impl Fenwick { + fn new(ranks: usize) -> Self { + Self(vec![0; ranks + 1]) + } + + fn add(&mut self, rank: usize, by: i64) { + let mut i = rank + 1; + while let Some(c) = self.0.get_mut(i) { + *c += by; + i += i.isolate_lowest_one(); + } + } + + /// How many were added at ranks below `rank`. + fn count_below(&self, rank: usize) -> u64 { + let (mut i, mut n) = (rank, 0i64); + while i > 0 + && let Some(c) = self.0.get(i) + { + n += c; + i -= i.isolate_lowest_one(); + } + u64::try_from(n).unwrap_or(0) + } +} diff --git a/crates/pathops/src/budget_tests.rs b/crates/pathops/src/budget_tests.rs new file mode 100644 index 00000000..b53b5545 --- /dev/null +++ b/crates/pathops/src/budget_tests.rs @@ -0,0 +1,95 @@ +//! Input caps for the sweep: huge inputs are refused at once with a clear error, real ones run. + +use std::time::{Duration, Instant}; + +use figcraft_doc::BoolOp; +use figcraft_geom::kurbo::{BezPath, Rect, Shape}; + +use crate::{Complexity, FoldBudget, MAX_CROSSINGS, MAX_FOLD_SEGMENTS, MAX_SEGMENTS, PathOpsError, boolean, complexity, faces, simplify}; + +fn rects(rs: impl IntoIterator) -> BezPath { + let mut p = BezPath::new(); + for r in rs { + p.extend(r.path_elements(0.1)); + } + p +} + +/// `n` long thin bars across and `n` down: each bar across crosses each bar down 4 times. +fn grid(n: usize) -> (BezPath, BezPath) { + let len = n as f64 * 4.0; + let across = rects((0..n).map(|i| Rect::new(0.0, i as f64 * 4.0, len, i as f64 * 4.0 + 2.0))); + let down = rects((0..n).map(|i| Rect::new(i as f64 * 4.0, 0.0, i as f64 * 4.0 + 2.0, len))); + (across, down) +} + +/// A zigzag of `n` line segments. +fn zigzag(n: usize) -> BezPath { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + for i in 1..=n { + p.line_to(((i % 2) as f64, i as f64 * 1e-3)); + } + p +} + +#[test] +fn crossings_count_the_segments_whose_bounds_overlap() { + let square = rects([Rect::new(0.0, 0.0, 1.0, 1.0)]); + assert_eq!(complexity([&square]), Some(Complexity { segments: 4, crossings: 4 }), "each side touches its two neighbours"); + let apart = rects([Rect::new(0.0, 0.0, 1.0, 1.0), Rect::new(5.0, 5.0, 6.0, 6.0)]); + assert_eq!(complexity([&apart]), Some(Complexity { segments: 8, crossings: 8 })); + let mut plus = BezPath::new(); + plus.move_to((0.0, 5.0)); + plus.line_to((10.0, 5.0)); + plus.move_to((5.0, 0.0)); + plus.line_to((5.0, 10.0)); + assert_eq!(complexity([&plus]).map(|c| c.crossings), Some(1)); + assert_eq!(complexity([]), Some(Complexity::default())); + // Every crossing is counted, and little else: the bars touch only at their ends. + let (across, down) = grid(10); + let c = complexity([&across, &down]).unwrap(); + assert_eq!(c.segments, 80); + assert!((400..=400 + 12 * 10).contains(&c.crossings), "{c:?}"); +} + +#[test] +fn a_pattern_of_ten_thousand_dots_is_far_within_the_caps() { + let dots = rects((0..10_000).map(|i| { + let (x, y) = (f64::from(i % 100) * 3.0, f64::from(i / 100) * 3.0); + Rect::new(x, y, x + 2.0, y + 2.0) + })); + let c = complexity([&dots]).unwrap(); + assert_eq!(c, Complexity { segments: 40_000, crossings: 40_000 }); + assert!(c.segments * 5 < MAX_SEGMENTS && c.crossings * 40 < MAX_CROSSINGS); +} + +#[test] +fn a_boolean_past_the_crossing_cap_is_refused_before_it_runs() { + // 750 × 750 bars: 2.25 million crossings. + let (across, down) = grid(750); + let start = Instant::now(); + assert_eq!(boolean(&[across.clone(), down.clone()], BoolOp::Union), Err(PathOpsError::TooComplex)); + assert!(matches!(faces(&[(across, false), (down, false)]), Err(PathOpsError::TooComplex))); + assert!(start.elapsed() < Duration::from_secs(5), "{:?}", start.elapsed()); + assert!(PathOpsError::TooComplex.to_string().contains("too complex to combine")); +} + +#[test] +fn a_path_past_the_segment_cap_is_refused_without_reading_it_all() { + assert_eq!(complexity([&zigzag(MAX_SEGMENTS)]).map(|c| c.segments), Some(MAX_SEGMENTS), "at the cap: measured"); + let huge = zigzag(MAX_SEGMENTS + 1); + assert_eq!(complexity([&huge]), None); + let start = Instant::now(); + assert_eq!(simplify(&huge), Err(PathOpsError::TooComplex)); + assert!(start.elapsed() < Duration::from_secs(5), "{:?}", start.elapsed()); +} + +#[test] +fn a_fold_stops_once_it_has_swept_its_budget() { + let (acc, p) = (zigzag(100_000), rects([Rect::new(0.0, 0.0, 1.0, 1.0)])); + let mut budget = FoldBudget::default(); + let steps = (0..100).take_while(|_| budget.spend(&acc, &p).is_ok()).count(); + assert_eq!(steps, MAX_FOLD_SEGMENTS / 100_004); + assert_eq!(budget.spend(&BezPath::new(), &BezPath::new()), Err(PathOpsError::TooComplex), "spent stays spent"); +} diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 5f86f02b..8033bf4c 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -4,11 +4,17 @@ //! sweep-line topology from `linesweeper`); results use the even-odd rule. //! - [`stroke_outline`]: a stroke as a fill, with Figma's Inside / Center / Outside alignment, //! dashes, caps, joins, arrowheads, variable width profiles ([`width`]) and brushes ([`brush`]). +//! +//! Every sweep is capped ([`MAX_SEGMENTS`], [`MAX_CROSSINGS`], and [`MAX_FOLD_SEGMENTS`] over a +//! fold): past the caps an operation is refused with [`PathOpsError::TooComplex`]. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod brush; #[cfg(test)] mod brush_tests; +mod budget; +#[cfg(test)] +mod budget_tests; #[cfg(test)] mod dot_dash_tests; #[cfg(test)] @@ -25,15 +31,25 @@ mod width_tests; use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, VectorNetwork, WindingRule}; use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2}; +pub use budget::{Complexity, FoldBudget, MAX_CROSSINGS, MAX_FOLD_SEGMENTS, MAX_SEGMENTS, complexity}; + #[derive(Debug, PartialEq)] pub enum PathOpsError { /// The inputs contain NaN or infinite coordinates. BadInput, + /// The inputs are past [`MAX_SEGMENTS`] or [`MAX_CROSSINGS`]: the operation is refused rather + /// than run for unbounded time and memory. + TooComplex, } impl std::fmt::Display for PathOpsError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "path contains invalid coordinates") + match self { + Self::BadInput => write!(f, "path contains invalid coordinates"), + Self::TooComplex => { + write!(f, "the shapes are too complex to combine (over {MAX_SEGMENTS} segments or {MAX_CROSSINGS} possible crossings)") + } + } } } @@ -82,7 +98,9 @@ fn binary_by(a: &BezPath, b: &BezPath, rule: linesweeper::FillRule, op: lineswee if !finite(a) || !finite(b) { return Err(PathOpsError::BadInput); } - let contours = sweep(|| linesweeper::binary_op(&closed(a), &closed(b), rule, op).map_err(|_| PathOpsError::BadInput))?; + let (a, b) = (closed(a), closed(b)); + budget::check([&a, &b])?; + let contours = sweep(|| linesweeper::binary_op(&a, &b, rule, op).map_err(|_| PathOpsError::BadInput))?; let mut out = BezPath::new(); for c in contours.contours() { out.extend(c.path.elements().iter().copied()); @@ -121,13 +139,17 @@ pub fn boolean(paths: &[BezPath], op: BoolOp) -> Result { /// Fold `operands` bottom to top, each combined with the result of those below it by its own /// operation (the bottom operand's operation is unused): what a boolean group whose operands carry -/// their own operations paints. A lone operand comes out simplified. +/// their own operations paints. A lone operand comes out simplified. Past [`MAX_FOLD_SEGMENTS`] +/// swept over all the steps, the fold is refused ([`FoldBudget`]). pub fn boolean_ops<'a>(operands: impl IntoIterator) -> Result { let mut it = operands.into_iter(); let Some((first, _)) = it.next() else { return Ok(BezPath::new()) }; let mut acc: Option = None; + let mut budget = FoldBudget::default(); for (p, op) in it { - acc = Some(boolean_step(acc.as_ref().unwrap_or(first), p, op)?); + let below = acc.as_ref().unwrap_or(first); + budget.spend(below, p)?; + acc = Some(boolean_step(below, p, op)?); } match acc { Some(a) => Ok(a), @@ -208,6 +230,7 @@ pub fn faces(paths: &[(BezPath, bool)]) -> Result, PathOpsE if paths.len() > MAX_ARRANGEMENT_INPUTS || paths.iter().any(|(p, _)| !finite(p)) { return Err(PathOpsError::BadInput); } + budget::check(paths.iter().map(|(p, _)| p))?; // Self-intersecting or self-overlapping inputs (a centred stroke outlined around a sharp // corner folds over itself and touches the shape's fill) leave the winding on each side of a // segment ambiguous where they touch, which made the sweep report overlapping faces. Reducing @@ -219,6 +242,8 @@ pub fn faces(paths: &[(BezPath, bool)]) -> Result, PathOpsE let bbox = closed_paths.iter().filter(|p| !p.elements().is_empty()).map(Shape::bounding_box).reduce(|a, b| a.union(b)); let m = bbox.map(|b| b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs())).unwrap_or(0.0); let eps = (m * f64::EPSILON * 64.0).max(1e-6); + // Simplifying split the inputs where they crossed themselves: the arrangement is checked too. + budget::check(&closed_paths)?; sweep(|| arrange(&closed_paths, eps)) } diff --git a/crates/render/src/boolean.rs b/crates/render/src/boolean.rs index f95be6c9..7fc64208 100644 --- a/crates/render/src/boolean.rs +++ b/crates/render/src/boolean.rs @@ -10,10 +10,12 @@ use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use figcraft_doc::BoolOp; use figcraft_geom::BezPath; +use figcraft_pathops::{FoldBudget, PathOpsError}; use crate::draw::hash_path; @@ -72,15 +74,45 @@ pub fn boolean_steps() -> u64 { STEPS.with(std::cell::Cell::get) } +/// The folds refused as too complex to combine, so a refused group costs nothing the next time it +/// is drawn (an empty path marks each). +fn refused() -> &'static Mutex { + static C: OnceLock> = OnceLock::new(); + C.get_or_init(|| Mutex::new(PathCache::new(256))) +} + +static WARNED: AtomicBool = AtomicBool::new(false); + /// The result of folding `operands` (bottom to top, each with its operation; the bottom one's is -/// unused), reusing the longest cached prefix. Invalid geometry gives an empty path. -pub fn fold_cached(operands: &[(BezPath, BoolOp)]) -> BezPath { - if operands.len() < 2 { - return figcraft_pathops::boolean_ops(operands.iter().map(|(p, op)| (p, *op))).unwrap_or_default(); +/// unused), reusing the longest cached prefix. Invalid geometry gives an empty path. `None`: the +/// fold is past the path operations' caps ([`PathOpsError::TooComplex`]), so the group draws +/// without its result, as layers past the render's memory budget are left out (with a warning, +/// once). +pub fn fold_cached(operands: &[(BezPath, BoolOp)]) -> Option { + let keys = fold_keys(operands); + let Some(&last) = keys.last() else { return Some(BezPath::new()) }; + if locked(refused()).get(last).is_some() { + return None; } - // keys[i]: the fold of operands 0..=i. + let folded = + if operands.len() < 2 { figcraft_pathops::boolean_ops(operands.iter().map(|(p, op)| (p, *op))) } else { fold_from_cache(operands, &keys) }; + match folded { + Ok(p) => Some(p), + Err(PathOpsError::TooComplex) => { + if !WARNED.swap(true, Ordering::Relaxed) { + log::warn!("render: a boolean group's result is left out: {}", PathOpsError::TooComplex); + } + locked(refused()).insert(last, Arc::default()); + None + } + Err(PathOpsError::BadInput) => Some(BezPath::new()), + } +} + +/// `keys[i]`: the cache key of the fold of operands `0..=i`. +fn fold_keys(operands: &[(BezPath, BoolOp)]) -> Vec { let mut h = DefaultHasher::new(); - let keys: Vec = operands + operands .iter() .enumerate() .map(|(i, (p, op))| { @@ -91,31 +123,35 @@ pub fn fold_cached(operands: &[(BezPath, BoolOp)]) -> BezPath { 9u8.hash(&mut h); h.finish() }) - .collect(); + .collect() +} + +/// The fold of two or more `operands` from the longest cached prefix (`keys`: [`fold_keys`]); the +/// steps it computes count against one [`FoldBudget`]. +fn fold_from_cache(operands: &[(BezPath, BoolOp)], keys: &[u64]) -> Result { let hit = { let mut c = locked(cache()); keys.iter().enumerate().skip(1).rev().find_map(|(i, k)| c.get(*k).map(|p| (i, p))) }; let (start, mut acc) = match hit { - Some((i, p)) if i + 1 == operands.len() => return (*p).clone(), + Some((i, p)) if i + 1 == operands.len() => return Ok((*p).clone()), Some((i, p)) => (i, (*p).clone()), None => match operands.first() { Some((p, _)) => (0, p.clone()), - None => return BezPath::new(), + None => return Ok(BezPath::new()), }, }; + let mut budget = FoldBudget::default(); let mut fresh = Vec::new(); - for ((p, op), key) in operands.iter().zip(&keys).skip(start + 1) { + for ((p, op), key) in operands.iter().zip(keys).skip(start + 1) { STEPS.with(|s| s.set(s.get() + 1)); - acc = match figcraft_pathops::boolean_step(&acc, p, *op) { - Ok(r) => r, - Err(_) => return BezPath::new(), - }; + budget.spend(&acc, p)?; + acc = figcraft_pathops::boolean_step(&acc, p, *op)?; fresh.push((*key, Arc::new(acc.clone()))); } let mut c = locked(cache()); for (k, p) in fresh { c.insert(k, p); } - acc + Ok(acc) } diff --git a/crates/render/src/boolean_budget_tests.rs b/crates/render/src/boolean_budget_tests.rs new file mode 100644 index 00000000..f1319067 --- /dev/null +++ b/crates/render/src/boolean_budget_tests.rs @@ -0,0 +1,57 @@ +//! A boolean group past the path operations' caps draws without its result (with a warning, once) +//! and costs nothing the next time it is drawn; other groups are unchanged. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{BoolOp, Document, NodeId, NodeKind, VectorNetwork}; +use figcraft_geom::kurbo::{BezPath, Rect, Shape}; + +use crate::boolean::boolean_steps; +use crate::{Options, View, node_outline, render_view}; + +/// `n` long thin bars, across or down: 750 of each cross 2.25 million times, past the caps. +fn bars(n: usize, across: bool) -> BezPath { + let mut p = BezPath::new(); + for i in 0..n { + let (at, len) = (i as f64 * 4.0, n as f64 * 4.0); + let r = if across { Rect::new(0.0, at, len, at + 2.0) } else { Rect::new(at, 0.0, at + 2.0, len) }; + p.extend(r.path_elements(0.1)); + } + p +} + +/// A black union of the bars across and down, on a page of its own. +fn union_of_bars(n: usize) -> (Document, NodeId, NodeId) { + let mut d = Document::new("t"); + let page = d.first_page().unwrap(); + let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 0.0, 0.0).unwrap(); + d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + for across in [true, false] { + let network = VectorNetwork::from_path(&bars(n, across)); + let len = n as f64 * 4.0; + d.create(b, NodeKind::Vector { network }, "Bars", 0.0, 0.0, len, len).unwrap(); + } + d.fit_to_children(b).unwrap(); + (d, page, b) +} + +/// Whether the page shows anything dark at all. +fn painted(d: &Document, page: NodeId, scale: f64) -> bool { + let view = View { origin: (0.0, 0.0), scale, width: 100, height: 100 }; + let pix = render_view(d, page, &view, &Options::default()).unwrap(); + pix.data().chunks_exact(4).any(|px| px[3] > 0 && px[0] < 40) +} + +#[test] +fn a_boolean_past_the_caps_draws_without_its_result() { + let (d, page, b) = union_of_bars(750); + let group = d.node(b).unwrap(); + assert!(node_outline(&d, group).is_none(), "refused: the group has no outline"); + let before = boolean_steps(); + assert!(node_outline(&d, group).is_none()); + assert_eq!(boolean_steps(), before, "the refusal is remembered"); + assert!(!painted(&d, page, 100.0 / 3000.0), "nothing of the group is drawn"); + // A small one of the same kind is combined and drawn as before. + let (d, page, b) = union_of_bars(10); + assert!(node_outline(&d, d.node(b).unwrap()).is_some_and(|(p, _)| p.area().abs() > 100.0)); + assert!(painted(&d, page, 100.0 / 40.0)); +} diff --git a/crates/render/src/boolean_cache_tests.rs b/crates/render/src/boolean_cache_tests.rs index 452626c2..7a34bf4f 100644 --- a/crates/render/src/boolean_cache_tests.rs +++ b/crates/render/src/boolean_cache_tests.rs @@ -108,12 +108,12 @@ fn caches_drop_the_least_recently_used_half() { assert!(c.get(0).is_some() && c.get(1).is_some() && c.get(9).is_some()); assert!(c.get(2).is_none() && c.get(3).is_none()); // Junk and degenerate folds. - assert!(fold_cached(&[]).elements().is_empty()); + assert!(fold_cached(&[]).unwrap().elements().is_empty()); let mut bad = BezPath::new(); bad.move_to((f64::NAN, 0.0)); bad.line_to((1.0, 1.0)); let sq = figcraft_geom::Rect::new(0.0, 0.0, 1.0, 1.0).to_path(0.1); - assert!(fold_cached(&[(sq, BoolOp::Union), (bad, BoolOp::Union)]).elements().is_empty()); + assert!(fold_cached(&[(sq, BoolOp::Union), (bad, BoolOp::Union)]).unwrap().elements().is_empty()); } #[test] diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 4ef427e4..32b67654 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -320,7 +320,8 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F Some((p, c.bool_op.unwrap_or(*op))) }) .collect(); - Some((crate::boolean::fold_cached(&operands), FillRule::EvenOdd)) + // Past the path operations' caps the group has no outline: it draws without its result. + crate::boolean::fold_cached(&operands).map(|p| (p, FillRule::EvenOdd)) } NodeKind::Text(_) => { let (l, dy) = figcraft_text::layout_node(n)?; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4625ed7f..05fbaa7e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -491,6 +491,8 @@ mod backdrop_tests; #[cfg(test)] mod band_tests; #[cfg(test)] +mod boolean_budget_tests; +#[cfg(test)] mod boolean_cache_tests; #[cfg(test)] mod bounds_memo_tests; diff --git a/docs/roadmap.md b/docs/roadmap.md index cc620cfb..19f4ff6c 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 (figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); 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 (boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1104: boolean operations refuse input past 250 000 segments, 2 million possible crossings or 4 million segments swept over a fold (`TooComplex`, before the sweep runs); a boolean group past them draws without its result, with a warning, and Flatten refuses it. Tests: `budget_tests`, `boolean_budget_tests`, `boolean_cap_tests` | | 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged | | 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged | | 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7f30d759..474c1bfe 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-30 · **Change:** minor (the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); 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: @@ -87,7 +87,7 @@ interaction feel not yet compared. | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | -| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | +| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | | Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) | | Comment (C), Shift+C to show/hide | done | local only | @@ -175,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Boolean groups past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold; #1104) draw without their result, with a warning, and Flatten refuses them. Numbers unchanged. | | 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged | | 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. | From 974ab39503520774173ef9292bc4a192bed91265 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 06:36:33 +0700 Subject: [PATCH 05/26] M8: keep unsaved files when a panic stops the browser build (#1123) (#1580) wasm32 can't catch a panic, so the command and frame guards do nothing in the browser and a panic ended the page with its unsaved changes. The page now follows its files with unsaved changes after every frame; its panic hook writes them to local storage as .figcraft copies (as many as fit) and says on the page that it stopped. The next page opens them again as unsaved files: file.recover also takes the copy as base64 data. The recovery-copy helpers move from the desktop app into figcraft_engine::branches so both share them. --- Cargo.lock | 1 + ROADMAP.md | 3 +- apps/figcraft-web/Cargo.toml | 3 +- apps/figcraft-web/index.html | 4 + apps/figcraft-web/src/main.rs | 6 ++ apps/figcraft-web/src/recovery.rs | 85 +++++++++++++++++++++ apps/figcraft-web/src/recovery_tests.rs | 98 +++++++++++++++++++++++++ apps/figcraft-web/src/web.rs | 51 +++++++++++++ apps/figcraft/src/recovery.rs | 18 +---- crates/engine/src/cmd/file.rs | 13 +++- crates/engine/src/cmd/file/load.rs | 8 +- crates/engine/src/guard.rs | 4 + crates/engine/src/sharing/branches.rs | 18 +++++ crates/pathops/src/lib.rs | 3 +- crates/ui-egui/locales/en.toml | 5 ++ crates/ui-egui/src/lib.rs | 4 +- crates/ui-egui/src/services.rs | 15 ++++ crates/ui-egui/src/windows.rs | 3 +- docs/architecture.md | 2 +- docs/hardware-parity.md | 5 +- docs/roadmap.md | 3 +- 21 files changed, 320 insertions(+), 32 deletions(-) create mode 100644 apps/figcraft-web/src/recovery.rs create mode 100644 apps/figcraft-web/src/recovery_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 05adf9fc..889b53f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2100,6 +2100,7 @@ dependencies = [ "egui", "figcraft-collab-net", "figcraft-engine", + "figcraft-format", "figcraft-text", "figcraft-ui-egui", "js-sys", diff --git a/ROADMAP.md b/ROADMAP.md index 58256579..939b069d 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 (boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1123: a safety net for internal errors in the browser. wasm32 can't catch a panic (stable Rust has no unwinding std for it), so there the command guard and the frame guard do nothing and a panic stopped the page, losing every unsaved change. The page now follows its files with unsaved changes after every frame (shared copies, taken again only when a file changed); its panic hook writes them to the page's local storage as `.figcraft` copies, as many as fit (browsers give a page about 5 MB), and says on the page that it stopped and what reloading brings back; the next page opens them again as unsaved files (`file.recover` also takes the copy as base64 `data`). Numbers unchanged. Tests: `recovery_tests` (figcraft-web). | | 2026-10-30 | minor | M8, #1104: boolean operations had no input cap, so a crafted file could keep the sweep busy for unbounded time and memory (any number of segments, crossing as often as they like, and a fold re-sweeping its growing result for every operand). Each sweep now takes at most 250 000 segments and 2 million possible crossings (pairs of segments whose bounds overlap, counted exactly in O(n log n)), and a fold at most 4 million segments swept over all its steps; past them the operation is refused with `TooComplex` ("the shapes are too complex to combine") before it runs. The example files' boolean groups sweep at most 55 segments and 155 possible crossings per step. A boolean group past the caps draws without its result, with a warning, once, like layers past the render's memory budget; the refusal is remembered, so redrawing it costs nothing; Flatten refuses it instead of dropping it. Numbers unchanged. Tests: `budget_tests` (pathops), `boolean_budget_tests` (render), `boolean_cap_tests` (engine). | | 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged | | 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged | diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 384e3579..90e76c69 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -15,9 +15,11 @@ path = "src/main.rs" [dependencies] figcraft-engine = { workspace = true } +figcraft-format = { workspace = true } figcraft-ui-egui = { workspace = true } figcraft-collab-net = { workspace = true } figcraft-text = { workspace = true } +base64 = { workspace = true } egui = { workspace = true } log = { workspace = true } serde_json = { workspace = true } @@ -26,7 +28,6 @@ serde_json = { workspace = true } # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" -base64 = { workspace = true } js-sys = "0.3" wasm-bindgen-futures = "0.4" web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] } diff --git a/apps/figcraft-web/index.html b/apps/figcraft-web/index.html index 1915a9a2..7bcf5d38 100644 --- a/apps/figcraft-web/index.html +++ b/apps/figcraft-web/index.html @@ -10,9 +10,13 @@ #figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; } #figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; } + /* Shown once the page stopped on an internal error (web.rs: show_stopped). */ + #figcraft_stopped { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box; + padding: 16px; text-align: center; color: #1e1e1e; background: rgba(245, 245, 245, 0.92); font: 14px/1.5 system-ui, sans-serif; } @media (prefers-color-scheme: dark) { html, body { background: #2c2c2c; } #figcraft_loading { color: #b8b8b8; } + #figcraft_stopped { color: #f0f0f0; background: rgba(44, 44, 44, 0.92); } } diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index f9c0fb0a..4f38d787 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -9,6 +9,8 @@ //! - no native file dialogs yet: the session starts with one new document; //! - links open in a new browser tab; //! - of the preferences, the page keeps only your name across reloads (`kept.rs`); +//! - an internal error (a panic) can't be caught, so it stops the page: the files with unsaved +//! changes are kept in the page's storage and open again on the next reload (`recovery.rs`); //! - fonts: the built-in ones, and on request (the font menu's "Use fonts installed on this //! computer…", Chromium's Local Font Access) the computer's, each read when text uses it //! (`local_fonts.rs`); @@ -37,6 +39,10 @@ mod query; #[cfg(test)] mod query_tests; #[cfg(any(target_arch = "wasm32", test))] +mod recovery; +#[cfg(test)] +mod recovery_tests; +#[cfg(any(target_arch = "wasm32", test))] mod saved_files; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/figcraft-web/src/recovery.rs b/apps/figcraft-web/src/recovery.rs new file mode 100644 index 00000000..48e5bb4d --- /dev/null +++ b/apps/figcraft-web/src/recovery.rs @@ -0,0 +1,85 @@ +//! Recovery copies in the browser. The browser build can't catch a panic (wasm32 always aborts), +//! so a command or frame that panics ends the page instead of failing as on the desktop. The page +//! follows its files with unsaved changes after every frame ([`Unsaved::follow`]: cheap shared +//! copies, taken only when a file changed); the panic hook writes them to the page's local storage +//! ([`store`]) before the page stops, and the next page opens them again as unsaved files +//! ([`restore`], `file.recover`), as the desktop app does with its recovery copies. + +use std::sync::Arc; + +use base64::Engine as _; +use figcraft_engine::branches::{self, needs_copy, stamp}; +use figcraft_engine::doc::Document; +use figcraft_engine::{DocState, Session}; +use figcraft_format::{Branch, Version}; +use serde_json::json; + +/// A file with unsaved changes as of the last frame. +struct Copy { + uid: u64, + /// [`stamp`]: the revisions this copy is of. + stamp: Vec, + doc: Arc, + versions: Vec, + branches: Vec, +} + +impl Copy { + fn of(s: &Session, d: &DocState) -> Self { + let (versions, branches) = (d.versions.clone(), branches::current(s, d.uid)); + Self { uid: d.uid, stamp: stamp(s, d), doc: d.document_itself().clone(), versions, branches } + } +} + +/// The page's files with unsaved changes, as of the last frame. +#[derive(Default)] +pub struct Unsaved(Vec); + +impl Unsaved { + /// Catch up with `s`: a file that changed is taken again, the others are kept as they are. + pub fn follow(&mut self, s: &Session) { + let mut before = std::mem::take(&mut self.0); + for d in s.docs.iter().filter(|d| needs_copy(s, d)) { + let now = stamp(s, d); + let copy = match before.iter().position(|c| c.uid == d.uid && c.stamp == now) { + Some(i) => before.swap_remove(i), + None => Copy::of(s, d), + }; + self.0.push(copy); + } + } + + /// How many files have unsaved changes. + pub fn count(&self) -> usize { + self.0.len() + } + + /// Each file as a saved `.figcraft` file in base64 (one that can't be written is left out). + pub fn files(&self) -> Vec { + let b64 = base64::engine::general_purpose::STANDARD; + self.0 + .iter() + .filter_map(|c| figcraft_format::save_bytes_with_history(&c.doc, None, &c.versions, &c.branches).ok().map(|b| b64.encode(b))) + .collect() + } +} + +/// Keep as many of `files` as the storage takes, in order: `put` stores the list's JSON and says +/// whether it fitted (a refused write leaves what was stored before). Returns how many were kept. +pub fn store(files: Vec, mut put: impl FnMut(&str) -> bool) -> usize { + let mut kept = Vec::new(); + for f in files { + kept.push(f); + if !put(&json!(kept).to_string()) { + kept.pop(); + } + } + kept.len() +} + +/// Open the copies the last page kept (`kept`: what [`store`] stored, if anything) as files with +/// unsaved changes. Returns how many came back; junk in the storage is skipped. +pub fn restore(s: &mut Session, kept: Option) -> usize { + let files: Vec = kept.and_then(|k| serde_json::from_str(&k).ok()).unwrap_or_default(); + files.iter().filter(|f| s.execute("file.recover", &json!({"data": f})).is_ok()).count() +} diff --git a/apps/figcraft-web/src/recovery_tests.rs b/apps/figcraft-web/src/recovery_tests.rs new file mode 100644 index 00000000..1073ad2e --- /dev/null +++ b/apps/figcraft-web/src/recovery_tests.rs @@ -0,0 +1,98 @@ +//! Recovery copies in the browser: what a page kept when it stopped opens again, unsaved, on the +//! next page. + +use figcraft_engine::Session; +use figcraft_ui_egui::browser_stopped; +use serde_json::json; + +use crate::recovery::{Unsaved, restore, store}; + +fn edited(name: &str) -> Session { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + s.execute("file.rename", &json!({"name": name})).unwrap(); + s +} + +/// The page's copies, stored as the panic hook stores them. +fn stored(u: &Unsaved) -> Option { + let mut out = None; + let kept = store(u.files(), |v| { + out = Some(v.to_string()); + true + }); + assert_eq!(kept, u.count()); + out +} + +#[test] +fn a_stopped_pages_unsaved_files_open_again_unsaved() { + let s = edited("Poster"); + let mut u = Unsaved::default(); + u.follow(&s); + assert_eq!(u.count(), 1); + let mut next = Session::new(); + let before = next.docs.len(); + assert_eq!(restore(&mut next, stored(&u)), 1); + assert_eq!(next.docs.len(), before + 1); + let d = next.ds().unwrap(); + assert_eq!(d.doc.name, "Poster"); + assert!(d.is_dirty(), "a recovered file has unsaved changes"); + assert!(d.path.is_none(), "saving it asks where"); +} + +#[test] +fn the_latest_edit_is_kept_and_a_saved_file_is_not() { + let mut s = edited("First"); + let mut u = Unsaved::default(); + u.follow(&s); + u.follow(&s); + assert_eq!(u.count(), 1, "following again without a change keeps the one copy"); + s.execute("file.rename", &json!({"name": "Later"})).unwrap(); + u.follow(&s); + let mut next = Session::new(); + assert_eq!(restore(&mut next, stored(&u)), 1); + assert_eq!(next.ds().unwrap().doc.name, "Later"); + s.execute("file.markSaved", &json!({})).unwrap(); + u.follow(&s); + assert_eq!(u.count(), 0); + assert!(u.files().is_empty()); +} + +#[test] +fn what_the_storage_has_no_room_for_is_left_out() { + let files = vec!["a".repeat(10), "b".repeat(100), "c".repeat(10)]; + let mut kept = String::new(); + let n = store(files, |v| { + let fits = v.len() < 60; + if fits { + kept = v.to_string(); + } + fits + }); + assert_eq!(n, 2); + assert_eq!(kept, json!(["a".repeat(10), "c".repeat(10)]).to_string()); + assert_eq!(store(vec!["x".into()], |_| false), 0); +} + +#[test] +fn junk_in_the_storage_opens_nothing() { + let mut s = Session::new(); + let before = s.docs.len(); + for junk in [None, Some("not json"), Some("{}"), Some(r#"["!!"]"#), Some(r#"["aGVsbG8="]"#), Some("[1, null]")] { + assert_eq!(restore(&mut s, junk.map(String::from)), 0, "{junk:?}"); + } + assert_eq!(s.docs.len(), before); +} + +#[test] +fn the_stopped_page_says_what_was_kept() { + let says = |unsaved, kept, what: &str| { + let text = browser_stopped(unsaved, kept); + assert!(text.starts_with("FigCraft stopped because of an internal error. ") && text.contains(what), "{text}"); + }; + says(0, 0, "start again"); + says(2, 2, "get your unsaved files back"); + says(2, 0, "too large for the browser to keep"); + says(3, 1, "the browser had room for"); +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index a3e4d930..e8e7f54a 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -6,11 +6,23 @@ use wasm_bindgen::JsCast as _; const CANVAS_ID: &str = "figcraft_canvas"; const LOADING_ID: &str = "figcraft_loading"; +/// What the page shows once it stopped on an internal error (styled in `index.html`). +const STOPPED_ID: &str = "figcraft_stopped"; /// The local storage key your name is kept under ([`crate::kept`]). const NAME_KEY: &str = "figcraft.userName"; +/// The local storage key the recovery copies are kept under ([`crate::recovery`]): a JSON list of +/// base64 `.figcraft` files. +const RECOVERY_KEY: &str = "figcraft.recovery"; + +thread_local! { + /// The files with unsaved changes as of the last frame, for the panic hook ([`crate::recovery`]). + static UNSAVED: std::cell::RefCell = std::cell::RefCell::default(); +} pub fn start() { eframe::WebLogger::init(log::LevelFilter::Info).ok(); + // Before the runner's own hook, which logs the panic and then calls this one. + keep_unsaved_on_panic(); wasm_bindgen_futures::spawn_local(async { let Some(document) = web_sys::window().and_then(|w| w.document()) else { log::error!("no document"); @@ -39,11 +51,13 @@ pub fn start() { let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten()); let mut session = Session::new(); crate::kept::restore_name(&mut session, local_storage().and_then(|s| s.get_item(NAME_KEY).ok().flatten())); + let recovered = restore_unsaved(&mut session); // Live sessions: the browser joins sessions and publishes files to servers; // whatever arrives on the socket wakes the page for a frame. let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + fig.note_recovered(recovered); // `#join=`: join that session, as opening an invite link does on the desktop. let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default(); if let Some(link) = crate::query::join_link(&hash) { @@ -70,6 +84,41 @@ pub fn start() { }); } +/// The browser can't catch a panic (wasm32 aborts), so the page ends with it: keep the files with +/// unsaved changes in the page's storage for the next page, and say so on the page. +fn keep_unsaved_on_panic() { + static ONCE: std::sync::Once = std::sync::Once::new(); + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + previous(info); + // Callbacks that run after the page stopped can panic again: the files are kept once. + ONCE.call_once(|| { + let (unsaved, files) = UNSAVED.with(|u| u.try_borrow().map(|u| (u.count(), u.files())).unwrap_or_default()); + let kept = local_storage().map_or(0, |s| crate::recovery::store(files, |v| s.set_item(RECOVERY_KEY, v).is_ok())); + show_stopped(&figcraft_ui_egui::browser_stopped(unsaved, kept)); + }); + })); +} + +/// Open the files the last page kept when it stopped; how many came back. +fn restore_unsaved(session: &mut Session) -> usize { + let Some(storage) = local_storage() else { return 0 }; + let kept = storage.get_item(RECOVERY_KEY).ok().flatten(); + // Gone before they open: a copy that stops the page again must not come back on every reload + // (once a frame ran, the files it opened are followed as any unsaved file). + let _ = storage.remove_item(RECOVERY_KEY); + crate::recovery::restore(session, kept) +} + +/// Say on the page that it stopped (the canvas stays as it was). +fn show_stopped(text: &str) { + let Some(document) = web_sys::window().and_then(|w| w.document()) else { return }; + let (Ok(el), Some(body)) = (document.create_element("div"), document.body()) else { return }; + el.set_id(STOPPED_ID); + el.set_text_content(Some(text)); + let _ = body.append_child(&el); +} + /// The page's local storage (`None` where the browser keeps none, e.g. storage turned off). fn local_storage() -> Option { web_sys::window().and_then(|w| w.local_storage().ok().flatten()) @@ -90,6 +139,8 @@ impl eframe::App for WebShell { /// still advance autosave, live and control deadlines without painting. `FigApp::ui` does /// not repeat it. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Every frame, hidden or not, starts here: what the last one left is what a panic keeps. + UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok(); figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx)); } diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 605a04ca..eb66c363 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -25,6 +25,7 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, MutexGuard}; use std::thread::JoinHandle; +use figcraft_engine::branches::{needs_copy, stamp}; use figcraft_engine::{DocState, Session}; use figcraft_format::{Branch, Version}; use serde_json::json; @@ -321,23 +322,6 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option { s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64() } -/// A file whose changes would be lost: in its document, its history or one of its branch tabs -/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own). -fn needs_copy(s: &Session, d: &DocState) -> bool { - d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty)) -} - -/// The tabs showing branches of the file in tab `uid`. -fn branch_tabs(s: &Session, uid: u64) -> impl Iterator { - s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid)) -} - -/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change -/// only its own revision). A new copy is due when they change. -fn stamp(s: &Session, d: &DocState) -> Vec { - std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect() -} - /// Write `doc` with its version history and branches to `path` through a temporary file; false /// when it couldn't be written (the copy before it is still there). fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index dd786a09..1c9402e9 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -33,7 +33,7 @@ use load::font_report; #[cfg(test)] pub(crate) use load::{installed_family, sketch_line}; pub use load::{load, load_bytes, load_path}; -pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write}; +pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variables_param, write}; pub use open::MAX_RECENT; #[cfg(test)] use open::append_pages; @@ -181,11 +181,16 @@ pub fn specs() -> Vec { spec!( "file.recover", "Recover file", - "{path} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}", + "{path | data (base64: the copy's bytes, as the browser keeps them)} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}", false, |s, p| { - let path = req_string("file.recover", p, "path")?; - s.docs.push(recovered(&path)?); + let ds = match string(p, "data") { + Some(d) => recovered_from( + &base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.recover", format!("`data`: {e}")))?, + )?, + None => recovered(&req_string("file.recover", p, "path")?)?, + }; + s.docs.push(ds); s.active = s.docs.len() - 1; Ok(json!({"index": s.active})) } diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 01b36eda..a8462f19 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -4,7 +4,13 @@ use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Resul /// The recovery copy at `path` as a document with unsaved changes and no path. pub(crate) fn recovered(path: &str) -> Result { - let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?; + recovered_from(&read(path)?) +} + +/// A recovery copy's bytes as a document with unsaved changes and no path (the browser keeps its +/// copies in the page's storage, not in files). +pub(crate) fn recovered_from(bytes: &[u8]) -> Result { + let loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved()) } diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index d77a1bfc..8f96ae55 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -1,5 +1,9 @@ //! The last line of defence: a panic inside a command becomes an [`EngineError::Internal`] and //! the caller rolls the document back. It exists for bugs and doesn't replace `Result`. +//! +//! The browser build can't catch a panic (wasm32 always aborts, and stable Rust has no unwinding +//! std for it), so there a panicking command stops the page: its panic hook keeps the files with +//! unsaved changes for the next page instead (`apps/figcraft-web/src/recovery.rs`). use crate::{EngineError, Result}; diff --git a/crates/engine/src/sharing/branches.rs b/crates/engine/src/sharing/branches.rs index 4c9faa4a..56f0ca3e 100644 --- a/crates/engine/src/sharing/branches.rs +++ b/crates/engine/src/sharing/branches.rs @@ -180,6 +180,24 @@ pub fn current(s: &Session, uid: u64) -> Vec { branches } +/// The tabs showing branches of the file in tab `uid`. +pub fn tabs(s: &Session, uid: u64) -> impl Iterator { + s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid)) +} + +/// A file whose changes would be lost, so it needs a recovery copy: in its document, its history +/// or one of its branch tabs (a branch tab's edits are saved with its main file, so the tab has no +/// copy of its own). +pub fn needs_copy(s: &Session, d: &DocState) -> bool { + d.branch_of.is_none() && (d.is_dirty() || tabs(s, d.uid).any(DocState::is_dirty)) +} + +/// The revisions a recovery copy of `d` is of: its own and its branch tabs' (a branch tab's edits +/// change only its own revision). A new copy is due when they change. +pub fn stamp(s: &Session, d: &DocState) -> Vec { + std::iter::once(d.revision).chain(tabs(s, d.uid).map(|t| t.revision)).collect() +} + /// The tab a save writes: the main file's for a branch tab, else the active tab. pub fn file_tab(s: &Session) -> usize { s.ds().ok().and_then(|d| d.branch_of.as_ref()).and_then(|(m, _)| s.docs.iter().position(|d| d.uid == *m)).unwrap_or(s.active) diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 8033bf4c..07fb17fe 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -81,7 +81,8 @@ pub use figcraft_geom::measure::finite_path as finite; /// Run the sweep library on `f`. Version 0.4 can panic on rare near-degenerate input (an internal /// `unwrap` while ordering curves, hit by some font outlines): that is bad input, not a crash. -// shortcut: no catch where panics abort (wasm32); drop this once a linesweeper release no longer panics there. +// shortcut: wasm32 can't catch it (panics abort there): the page stops and its panic hook keeps the +// unsaved files (apps/figcraft-web/src/recovery.rs); drop this once linesweeper no longer panics. fn sweep(f: impl FnOnce() -> Result) -> Result { std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or(Err(PathOpsError::BadInput)) } diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index f99c18ac..ccf3c3c4 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -540,6 +540,7 @@ name = "English" "Fetching from the server…" = "Fetching from the server…" "FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." +"FigCraft stopped because of an internal error." = "FigCraft stopped because of an internal error." "FigCraft {version} is currently the newest version available." = "FigCraft {version} is currently the newest version available." "File" = "File" "File name" = "File name" @@ -1147,6 +1148,9 @@ name = "English" "Regular expression" = "Regular expression" "Relative to" = "Relative to" "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." +"Reload the page to get back the unsaved files the browser had room for." = "Reload the page to get back the unsaved files the browser had room for." +"Reload the page to get your unsaved files back." = "Reload the page to get your unsaved files back." +"Reload the page to start again." = "Reload the page to start again." "Remove" = "Remove" "Remove all" = "Remove all" "Remove annotation" = "Remove annotation" @@ -1660,6 +1664,7 @@ name = "English" "Your copy stays open; the file stays on the server" = "Your copy stays open; the file stays on the server" "Your name" = "Your name" "Your system asks for reduced motion: animations stay off." = "Your system asks for reduced motion: animations stay off." +"Your unsaved files were too large for the browser to keep." = "Your unsaved files were too large for the browser to keep." "Z" = "Z" "Z to A" = "Z to A" "Zoom" = "Zoom" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 00ce8c00..7b535a0b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -312,8 +312,8 @@ pub(crate) use clock::repaint_in; pub use clock::{AUTO_SAVE_SECS, AUTOSAVE_SECS, FrameClock, ToastClock}; pub(crate) use open::{drop_overlay, dropped_bytes, is_document, opens}; pub use services::{ - ContentsFuture, DialogFuture, MAX_CLIPBOARD_PNG_BYTES, NoServices, ReadFuture, Services, browser_can_read, egui_copy_png, egui_copy_text, - unreadable, + ContentsFuture, DialogFuture, MAX_CLIPBOARD_PNG_BYTES, NoServices, ReadFuture, Services, browser_can_read, browser_stopped, egui_copy_png, + egui_copy_text, unreadable, }; use services::{DialogDone, PickFuture, Picked, RepaintWaker}; pub use state::{ diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 9578b137..058c005d 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -124,6 +124,21 @@ pub fn unreadable(name: &str) -> String { i18n::trf("Couldn't read {name}.", &[("name", &name)]) } +/// What the page says once the browser build stopped on an internal error (in the browser a panic +/// ends the page): `unsaved` files had unsaved changes and `kept` of them fitted in its storage. +pub fn browser_stopped(unsaved: usize, kept: usize) -> String { + let next = if unsaved == 0 { + i18n::tr("Reload the page to start again.") + } else if kept >= unsaved { + i18n::tr("Reload the page to get your unsaved files back.") + } else if kept == 0 { + i18n::tr("Your unsaved files were too large for the browser to keep.") + } else { + i18n::tr("Reload the page to get back the unsaved files the browser had room for.") + }; + format!("{} {next}", i18n::tr("FigCraft stopped because of an internal error.")) +} + /// The largest file read in the browser: wasm has at most 4 GiB of memory, and a file's contents /// are held about three times on their way to `file.open` (the bytes, base64, decoded again). /// As large as one member of a `.figcraft` file may be. diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index a0875ee5..b9e7e997 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -124,7 +124,8 @@ pub fn with_app(f: impl FnOnce(&mut FigApp) -> R) -> Option { } /// Last-resort guard around a frame: a panic becomes a message and recovery copies of unsaved -/// files, not a lost document. +/// files, not a lost document. (In the browser a panic can't be caught: the page's panic hook +/// keeps the copies instead, `apps/figcraft-web/src/recovery.rs`.) pub fn guard(app: &mut FigApp, ctx: &Context, f: impl FnOnce(&mut FigApp)) { // Started with automation roots, everything the window and the control channel do is confined. let roots = app.automation_roots.clone(); diff --git a/docs/architecture.md b/docs/architecture.md index 065a2132..9faed547 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,7 +75,7 @@ content. Exports: PNG, JPG, WebP, SVG, PDF, code. There is no `.fig` reader yet Workspace lints deny `unwrap`, `expect`, `panic!` and friends in shipped code; `unsafe_code` is forbidden. `figcraft_engine::guard` turns a panicking command into an error and rolls the document -back; the desktop app also guards each UI frame. Junk-parameter sweeps run every command with bad +back; the desktop app also guards each UI frame. The browser build can't catch a panic (wasm32 aborts), so there a panic stops the page and its panic hook keeps the files with unsaved changes in local storage for the next page. Junk-parameter sweeps run every command with bad input. ## Revision history diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 121a6c8e..1676054c 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -44,13 +44,14 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) | | Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | | FreeBSD | none | tarball | | -| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) | +| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps); an internal error stops the page (a panic can't be caught there), but its unsaved files are kept in the page's local storage (about 5 MB) and open again on reload (#1123) | | iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1123: the browser can't catch a panic, so an internal error stopped the page and lost its unsaved changes. Its panic hook now keeps the files with unsaved changes in the page's local storage (as many as fit, about 5 MB) and says so on the page; reloading opens them again as unsaved files. Numbers unchanged. | | 2026-10-30 | minor | M1, #1320: settings in portable mode. The settings folder now comes from craft-settings, the suite's rules: `FIGCRAFT_CONFIG_DIR` names a folder (set but empty: none, nothing read or written); a `portable` file next to the executable (next to the `.app` on macOS) puts the preferences, recovery copies and installed plugins in `config/` beside it; otherwise they stay in `FigCraft` in the platform folder, where every earlier version kept them, so nothing moves for an installed app. The Windows portable zip now ships the `portable` file. A portable folder without preferences yet starts with a copy of the platform folder's `state.json` (owner-only, the original left for an installed app), so a portable copy that kept its settings in `%APPDATA%` before keeps them after the update. Checked on Windows 11 with a portable copy beside an installed copy's settings: the first start took them (its name and start screen), a name changed there was kept in `config\` only, and without the marker the app read the installed settings again. Tests: `settings_dir_tests`. Numbers unchanged | | 2026-10-30 | minor | M1, #965, #1438: "Parent device is lost" at start. Both reports lost the device while wgpu compiled its indirect-call validation shaders, which it builds with every device: on Windows Direct3D 12 took another program's `dxcompiler.dll` from the search path and failed without its `dxil.dll`; Intel Macs' Metal compiler aborts on them. Direct3D 12 now compiles with FXC (part of Windows; `WGPU_DX12_COMPILER` still chooses), and the validation shaders are left out (FigCraft draws nothing indirectly; `WGPU_VALIDATION_INDIRECT_CALL` turns them back on). When the graphics still fail before the window's first frame, the app starts itself again with the next attempt, from a fixed list: on Windows Direct3D 12/OpenGL, Vulkan, OpenGL, then the first again on the power-saving GPU with WebGL2-level device limits; on Linux and FreeBSD the defaults, OpenGL, then lower requirements; on macOS the defaults, then lower requirements. The last failure's message names what was tried; the log names the backend and GPU that drew. `WGPU_BACKEND` is still the one attempt. A restarted app waits up to 5 s for its `--control` port. Checked on Windows 11 with a local build that made the first one, two, three and then all four attempts fail: each fallback opened the window (Vulkan, OpenGL, Direct3D 12 on the integrated GPU) and four failures ended with the message. Not run on the reporters' machines or on macOS. Tests: `graphics_tests`, `startup_tests`. Numbers unchanged | | 2026-10-29 | minor | Large images on the canvas (#1483): an image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, the smallest that holds what the screen shows (`figcraft_render::images::shown`), cached beside the image itself in the same 512 MB budget. Two 16 000 × 10 000 images took 640 MB each and evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out and 336 MB at 8192. Moving one over the other went from about 2.1 s a frame to 11 ms zoomed to fit and 15 ms at 30% in the app (debug build). Closer than that, and for tiles, the canvas draws from the image itself, so 100% and closer stay sharp; a view of both at 100% still takes about 2 s a frame, and an image with shadows still decodes in full for them. Exports, thumbnails and every other render are unchanged. Tests: `large_image_tests`. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 19f4ff6c..5d420a02 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 (boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); 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 (in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1123: in the browser, where a panic can't be caught, the page's panic hook keeps its files with unsaved changes in local storage (as many as fit) and the next page opens them again as unsaved files; `file.recover` takes the copy as base64 `data`. Tests: `recovery_tests` (figcraft-web) | | 2026-10-30 | minor | M8, #1104: boolean operations refuse input past 250 000 segments, 2 million possible crossings or 4 million segments swept over a fold (`TooComplex`, before the sweep runs); a boolean group past them draws without its result, with a warning, and Flatten refuses it. Tests: `budget_tests`, `boolean_budget_tests`, `boolean_cap_tests` | | 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged | | 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged | From bc6dad0c2fd3947a3f1a1505711649b44ae9e7c1 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 06:56:33 +0700 Subject: [PATCH 06/26] M9: comment.list lists only the threads that mention a name (#1415) (#1585) comment.list takes mentions (a name, any case): only threads where a message, the first or a reply, @mentions it, by the same parser the thread view highlights with and each message's mentions reports; combined with the other filters. --- ROADMAP.md | 3 +- crates/engine/src/cmd/comment.rs | 39 ++++++++++++++++++--- crates/engine/src/comment_mentions_tests.rs | 35 ++++++++++++++++++ docs/collaboration-parity.md | 3 +- docs/mcp.md | 9 +++-- docs/roadmap.md | 3 +- 6 files changed, 82 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 939b069d..e5f7db9d 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 (in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (`comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M9, part of #1415: `comment.list` takes `mentions` (a name, any case, a leading `@` allowed) and lists only the threads where a message, the first or a reply, @mentions it, combined with the other filters (`{"mentions": "Claude", "filter": "unread"}`). It matches the names each message's `mentions` reports, by the same parser the thread view highlights with (`figcraft_doc::mentions`), so `@Ada Lovelace` is Ada Lovelace, not Ada, and an email address mentions nobody; the name counts as one of the file's people, so an unknown name can be several words. Numbers unchanged. Tests: `comment_mentions_tests`. | | 2026-10-30 | minor | M8, #1123: a safety net for internal errors in the browser. wasm32 can't catch a panic (stable Rust has no unwinding std for it), so there the command guard and the frame guard do nothing and a panic stopped the page, losing every unsaved change. The page now follows its files with unsaved changes after every frame (shared copies, taken again only when a file changed); its panic hook writes them to the page's local storage as `.figcraft` copies, as many as fit (browsers give a page about 5 MB), and says on the page that it stopped and what reloading brings back; the next page opens them again as unsaved files (`file.recover` also takes the copy as base64 `data`). Numbers unchanged. Tests: `recovery_tests` (figcraft-web). | | 2026-10-30 | minor | M8, #1104: boolean operations had no input cap, so a crafted file could keep the sweep busy for unbounded time and memory (any number of segments, crossing as often as they like, and a fold re-sweeping its growing result for every operand). Each sweep now takes at most 250 000 segments and 2 million possible crossings (pairs of segments whose bounds overlap, counted exactly in O(n log n)), and a fold at most 4 million segments swept over all its steps; past them the operation is refused with `TooComplex` ("the shapes are too complex to combine") before it runs. The example files' boolean groups sweep at most 55 segments and 155 possible crossings per step. A boolean group past the caps draws without its result, with a warning, once, like layers past the render's memory budget; the refusal is remembered, so redrawing it costs nothing; Flatten refuses it instead of dropping it. Numbers unchanged. Tests: `budget_tests` (pathops), `boolean_budget_tests` (render), `boolean_cap_tests` (engine). | | 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged | diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index ea3d0756..6dab7e8e 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -332,13 +332,37 @@ fn mentionable<'a>(people: &'a [String], user: &'a str) -> Vec<&'a str> { people.iter().map(String::as_str).chain([user]).collect() } +/// The names `text` @mentions by the rule the thread view highlights them with +/// ([`figcraft_doc::mentions`]), and whether each is one of `people` (else it is the letters and +/// digits after the `@`). +fn mentioned<'a>(text: &'a str, people: &[&'a str]) -> impl Iterator { + mentions(text, people).into_iter().map(move |(r, who)| (who.or_else(|| text.get(r.start + 1..r.end)).unwrap_or_default(), who.is_some())) +} + +/// Whether a message of `c` @mentions `name` (any case), as `comment.list` reports mentions; +/// `name` resolves like one of `people`, so a name nobody in the file has can be several words. +fn thread_mentions(c: &Comment, people: &[&str], name: &str) -> bool { + let people: Vec<&str> = people.iter().copied().chain([name]).collect(); + c.messages().any(|m| mentioned(&m.text, &people).any(|(n, _)| n.eq_ignore_ascii_case(name))) +} + +/// `comment.list`'s `mentions` filter: a name (a leading `@` is fine), or none. +fn mentions_param(p: &Value) -> crate::Result> { + match get(p, "mentions") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) if !s.trim().trim_start_matches('@').trim().is_empty() => { + Ok(Some(s.trim().trim_start_matches('@').trim().to_string())) + } + Some(_) => Err(bad("comment.list", "`mentions` must be a name")), + } +} + /// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against /// `people` (`mentionable`); `known: false` is a name nobody in the file has. fn message_json(m: &Message, people: &[&str], user: &str) -> Value { let mut named: Vec = Vec::new(); - for (r, who) in mentions(&m.text, people) { - let name = who.or_else(|| m.text.get(r.start + 1..r.end)).unwrap_or_default(); - let v = json!({"name": name, "known": who.is_some(), "you": who.is_some_and(|w| w.eq_ignore_ascii_case(user))}); + for (name, known) in mentioned(&m.text, people) { + let v = json!({"name": name, "known": known, "you": known && name.eq_ignore_ascii_case(user)}); if !named.contains(&v) { named.push(v); } @@ -807,16 +831,21 @@ pub fn specs() -> Vec { spec!( "comment.list", "List comments", - "{page? (default: current; \"all\" for every page), filter? (all|open|resolved|mine|unread; default all), sort? (date|page; default date), query? (search text), frame? (a top-level frame id), resolved? (false hides resolved ones), author? (whose threads and read state)}", + "{page? (default: current; \"all\" for every page), filter? (all|open|resolved|mine|unread; default all), sort? (date|page; default date), query? (search text), frame? (a top-level frame id), resolved? (false hides resolved ones), author? (whose threads and read state), mentions? (a name, any case: only threads where a message @mentions it, as each message's `mentions` reports)}", false, |s, p| { let doc = s.doc()?; let (page, filter, sort, text) = list_params("comment.list", s, p)?; + let want = mentions_param(p)?; let who = author(s, p); let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text }; let people = doc.comment_people(); let people = mentionable(&people, &who); - let list: Vec = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who, &people)).collect(); + let list: Vec = query(doc, &q, &who) + .into_iter() + .filter(|c| want.as_deref().is_none_or(|w| thread_mentions(c, &people, w))) + .map(|c| comment_json(doc, c, &who, &people)) + .collect(); Ok(json!({"comments": list})) } ), diff --git a/crates/engine/src/comment_mentions_tests.rs b/crates/engine/src/comment_mentions_tests.rs index b25a7934..94581029 100644 --- a/crates/engine/src/comment_mentions_tests.rs +++ b/crates/engine/src/comment_mentions_tests.rs @@ -1,5 +1,6 @@ //! `comment.list` reports the names each message @mentions, by the rule the thread view highlights //! them with: resolved against the file's people where it can be, and whether the name is you. +//! Its `mentions` param lists only the threads where a message mentions a name. use serde_json::{Value, json}; @@ -42,3 +43,37 @@ fn the_mention_rule_matches_the_highlighting_rule() { assert_eq!(figcraft_doc::mention_ranges("@Sam Lee, @sam, x@Sam, @Zoe!", &people), vec![0..8, 10..14]); assert!(figcraft_doc::mentions("@ and a@b.com", &people).is_empty()); } + +/// The ids of the threads `comment.list` returns for `p`. +fn listed(s: &mut Session, p: Value) -> Vec { + let l = s.execute("comment.list", &p).unwrap(); + l["comments"].as_array().unwrap().iter().map(|c| c["id"].as_u64().unwrap()).collect() +} + +fn add(s: &mut Session, text: &str, author: &str) -> u64 { + s.execute("comment.add", &json!({"x": 0, "y": 0, "text": text, "author": author})).unwrap()["id"].as_u64().unwrap() +} + +#[test] +fn the_mentions_param_lists_only_the_threads_that_mention_a_name() { + let mut s = Session::new(); + let claude = add(&mut s, "@Claude make this bigger", "Sam"); + let in_reply = add(&mut s, "Spacing looks off", "Sam"); + s.execute("comment.reply", &json!({"id": in_reply, "text": "agreed, @claude: tighten it", "author": "Ada Lovelace"})).unwrap(); + let other = add(&mut s, "@Ada Lovelace and @Zoe Smith, mail claude@example.com", "Sam"); + add(&mut s, "No mentions here", "Sam"); + let mut both = listed(&mut s, json!({"mentions": "CLAUDE"})); + both.sort_unstable(); + assert_eq!(both, vec![claude, in_reply], "any case, in a reply too, never an email address"); + assert_eq!(listed(&mut s, json!({"mentions": " @Claude "})), listed(&mut s, json!({"mentions": "claude"})), "a leading @ is fine"); + assert_eq!(listed(&mut s, json!({"mentions": "ada lovelace"})), vec![other]); + assert!(listed(&mut s, json!({"mentions": "Ada"})).is_empty(), "@Ada Lovelace names Ada Lovelace, not Ada"); + assert_eq!(listed(&mut s, json!({"mentions": "Zoe Smith"})), vec![other], "a name nobody in the file has, several words"); + assert_eq!(listed(&mut s, json!({"mentions": null})).len(), 4); + // Combined with the other filters: resolved threads drop out. + s.execute("comment.resolve", &json!({"id": claude})).unwrap(); + assert_eq!(listed(&mut s, json!({"mentions": "Claude", "filter": "open"})), vec![in_reply]); + for junk in [json!(""), json!("@"), json!(5), json!(["Claude"])] { + assert!(s.execute("comment.list", &json!({"mentions": junk})).is_err(), "{junk}"); + } +} diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 32755cdc..3694eabf 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-30 · **Change:** minor (the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); 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, @@ -29,6 +29,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M9, part of #1415: `comment.list {mentions}` lists only the threads where a message @mentions a name, combined with the other filters, so an agent tagged in comments reads just its threads. Numbers unchanged. | | 2026-10-30 | minor | M9, #917: the browser build publishes to a `figcraft-server`. Share ▸ Online (the server in Preferences) and Share ▸ Publish to server… upload the open file over the page's own WebSocket, make the links and join codes others join with, and keep the tab in sync with the new file, as the desktop app does; asking a server about its hosting, stopping a share and sharing again go out the same way. The Share popover offers only online sharing there (a page can't listen, so the local network stays the desktop app's); without a server in Preferences it says so and offers joining. A failed publish says why and leaves the file as it was. The browser keeps only your name across reloads, so the owner token of a file it put on an open server lasts as long as the page (`crates/collab-net/src/exchange.rs`, run by the browser client's driver; the requests and what their answers mean are shared with the desktop transport in `requests.rs`). Tests: `exchange_tests` (scripted server: with and without the key, sharing or not, refusals, an old server without join codes, time-outs, leaving while publishing, the background requests), `live_join_tests`; checked in a browser against a local server. Percentages and hours unchanged | | 2026-10-30 | minor | Where an AI agent works (M8, #1416): an agent attached over MCP (`figcraft-cli mcp --attach`) changed the file with no sign of where or whether it was still at work. Now, while it runs commands, the people row at the top of the right panel shows its avatar, ringed in its own colour and named as its comments are signed ("Claude (agent)"), and the canvas outlines the layers its last edit changed (the innermost ones), or that it last selected, in that colour with its name. Both go 8 seconds after its last command, and only the file it works in shows them. Display only: no undo step, never saved, never sent to a live session's other people, and the user's own commands never make one (`agent_presence_tests` in the engine, `live::agent` tests in the UI). Ready unchanged | | 2026-10-29 | minor | M9, #1380: your name is one setting, shown where you join. Preferences calls it "Your name" (it signs your comments and is what others see in live sessions). The Join dialog shows it, editable, beside the code, as the Share popover does, and the field is empty while it is the default. A new name is kept at once, and the browser build keeps it in the page's local storage, so a reload joins as you (the other preferences still start afresh there). Changing it while in live sessions renames you in each of them: a `name` signal, which the server cleans and answers with your `PeerJoined` to everyone, and newcomers' welcomes list the new name. A rename made while joining, or before a reconnect, goes out once the host reads signals. An older server ignores it, and clearing the name keeps the session's name until you join again. Tests: `collab/src/rename_tests.rs`, `collab-net/src/rename_tests.rs` (two apps over loopback), `ui-egui/src/display_name_tests.rs`, and `figcraft-web`'s `kept` round-trip. Numbers unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 81b13c65..c11eda31 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1186,13 +1186,18 @@ someone (`comment.people` lists the names used in the file and, as `you`, who is Read state is kept per name: a new message is unread for everyone but its author; `comment.markRead {id? | link?, read?}` marks one thread (or, without `id`, all of them) read or unread (not an undo step). `comment.list {page? | "all", filter? (all|open|resolved|mine|unread), -sort? (date|page), query?, frame?}` returns the threads with their position, `frame`/`frameName`, +sort? (date|page), query?, frame?, mentions?}` returns the threads with their position, `frame`/`frameName`, `pageName`, `unread`, `link` and replies (with `edited` times and `reactions`). Each message, the first and every reply, has `mentions`: the names it @mentions by the rule the app highlights them with, `{name, known, you}` each. `known` means the file's people (`comment.people`, or you) have the name, spelt as they spell it; `you` means it names you (or `author`). `@Name` counts at the start of the text or after a character that isn't a letter or digit, so `a@b.com` mentions nobody; -an unknown name is the letters and digits after the `@`. Links are +an unknown name is the letters and digits after the `@`. `mentions: ""` (any case; a leading +`@` is fine) lists only the threads where a message, the first or a reply, mentions that name as +those `mentions` report it, combined with the other filters: `{"mentions": "Claude", "filter": +"unread"}` is the unread threads that tag Claude. `@Ada Lovelace` mentions "Ada Lovelace", not +"Ada", when the file knows Ada Lovelace; a name nobody in the file has can still be several words +(`"Zoe Smith"` finds `@Zoe Smith`). Links are `figcraft://comment/`: `comment.link {id}` returns one, `comment.open {id | link, view?}` goes to its page, zooms to its frame (`fit`, the default; `center` keeps the zoom; `keep` doesn't move) and opens it in the app, and `comment.next {id?, dir?, …list params}` steps to the next or previous diff --git a/docs/roadmap.md b/docs/roadmap.md index 5d420a02..6a064ae3 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 (in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); 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 (`comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M9, part of #1415: `comment.list {mentions}` lists only the threads where a message @mentions a name (any case, by the same parser as the highlighting), combined with the other filters. Tests: `comment_mentions_tests` | | 2026-10-30 | minor | M8, #1123: in the browser, where a panic can't be caught, the page's panic hook keeps its files with unsaved changes in local storage (as many as fit) and the next page opens them again as unsaved files; `file.recover` takes the copy as base64 `data`. Tests: `recovery_tests` (figcraft-web) | | 2026-10-30 | minor | M8, #1104: boolean operations refuse input past 250 000 segments, 2 million possible crossings or 4 million segments swept over a fold (`TooComplex`, before the sweep runs); a boolean group past them draws without its result, with a warning, and Flatten refuses it. Tests: `budget_tests`, `boolean_budget_tests`, `boolean_cap_tests` | | 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged | From 9426eff1ff8324f820bd523b75299c2def7693e6 Mon Sep 17 00:00:00 2001 From: samhyun Date: Fri, 30 Oct 2026 09:10:14 +0900 Subject: [PATCH 07/26] M8: exports hold what stacked layer effects paint together Layer blurs and texture apply one after another and drop shadows are cast from what they leave, but an export grew the node's box by each effect on its own. Stacked blurs came out cut at the image's edges (eight blurs of 10: alpha 10 at the edge), and a sharp shadow under a blurred layer lost the blur around it (alpha 126). Blurs now combine as one whose squared reach is the sum of theirs, texture adds on, shadows grow from that, and paint_reach follows the same rule. Reaches are clamped so adding them stays finite. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/effects_tests.rs | 30 ++++++++++++++ crates/doc/src/node.rs | 42 ++++++++++++++++---- crates/doc/src/props.rs | 5 +++ crates/render/src/effects_behaviour_tests.rs | 23 +++++++++++ 4 files changed, 92 insertions(+), 8 deletions(-) diff --git a/crates/doc/src/effects_tests.rs b/crates/doc/src/effects_tests.rs index 3aa25ed1..0ef39c85 100644 --- a/crates/doc/src/effects_tests.rs +++ b/crates/doc/src/effects_tests.rs @@ -78,3 +78,33 @@ fn spread_reaches_further_at_sharp_corners_only() { assert_eq!(d.node(r).unwrap().paint_reach(), 10.0); assert_eq!(d.node(s).unwrap().paint_reach(), 10.0 * SPREAD_MITER_LIMIT); } + +#[test] +fn stacked_layer_effects_reach_together_and_shadows_from_there() { + let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "R", 10.0, 10.0); + let blur = |r: f64| Effect { kind: EffectKind::LayerBlur(Blur::uniform(r)), visible: true }; + // One blur reaches as before; four of 10 as one of 20 (their variances add). + n.effects = vec![blur(10.0)]; + assert_eq!(n.layer_effects_reach(), 15.0); + n.effects = vec![blur(10.0), blur(10.0), blur(10.0), blur(10.0)]; + assert!((n.layer_effects_reach() - 30.0).abs() < 1e-9, "{}", n.layer_effects_reach()); + // A sharp shadow below is cast from the blurred layer: it reaches the blur's distance further. + let shadow = + Effect { kind: EffectKind::DropShadow(Shadow { radius: 0.0, offset: (0.0, 50.0), spread: 0.0, ..Shadow::default() }), visible: true }; + n.effects = vec![blur(10.0), shadow]; + let b = n.grow_by_effects(figcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + assert_eq!((b.y0, b.y1), (-15.0, 75.0), "{b:?}"); + // Hidden effects reach nowhere. + n.effects = vec![Effect { visible: false, ..blur(10.0) }]; + assert_eq!(n.layer_effects_reach(), 0.0); +} + +#[test] +fn reaches_too_large_to_add_stay_finite() { + let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "R", 10.0, 10.0); + let t = Texture { radius: 1e308, ..Default::default() }; + n.effects = vec![Effect { kind: EffectKind::Texture(t.clone()), visible: true }, Effect { kind: EffectKind::Texture(t), visible: true }]; + let (reach, b) = (n.paint_reach(), n.grow_by_effects(figcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0))); + assert!(reach.is_finite() && reach > 1e6, "{reach}"); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() > 1e6, "{b:?}"); +} diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index ae4ae447..ef6b94a8 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -828,8 +828,20 @@ impl Node { /// Maximum distance (in local units) painting can reach outside the node box. pub fn paint_reach(&self) -> f64 { - let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max); - self.outline_reach() + fx + self.spread_miter_reach() + let layer = self.layer_effects_reach(); + let fx = self.effects.iter().filter(|e| !e.moves_the_layer()).map(|e| effect_reach(e.reach())).fold(0.0, f64::max); + self.outline_reach() + layer + fx + self.spread_miter_reach() + } + + /// How far the layer blurs and texture move the layer's pixels together: they apply one after + /// another, each moving what the one before it moved. Blurs one after another spread as one + /// blur whose radius squared is the sum of theirs (Gaussians add their variances); texture + /// scatters by up to its radius, added on. Drop shadows are cast from what they leave. + pub fn layer_effects_reach(&self) -> f64 { + let moving = || self.effects.iter().filter(|e| e.moves_the_layer()); + let blurs: f64 = moving().filter(|e| matches!(e.kind, EffectKind::LayerBlur(_))).map(|e| effect_reach(e.reach()).powi(2)).sum(); + let texture: f64 = moving().filter(|e| matches!(e.kind, EffectKind::Texture(_))).map(|e| effect_reach(e.reach())).sum(); + effect_reach(blurs.sqrt()) + texture } /// The page-space area this node paints itself under `world` (its children aside): its box @@ -842,18 +854,23 @@ impl Node { /// Page-space area `b` grown by what this node's visible effects paint around it: blurs all /// round, drop shadows towards their offset (in page units, as the renderer draws them), so a /// shadow falling down adds room below and none above. + /// Layer blurs and texture grow it by what they reach together, and drop shadows are cast from + /// that (see [`Node::layer_effects_reach`]). pub fn grow_by_effects(&self, b: figcraft_geom::Rect) -> figcraft_geom::Rect { let miter = self.spread_miter_reach(); - self.effects.iter().filter(|e| e.visible).fold(b, |r, e| match &e.kind { + let layer = self.layer_effects_reach(); + let moved = if layer > 0.0 { b.inflate(layer, layer) } else { b }; + self.effects.iter().filter(|e| e.visible).fold(moved, |r, e| match &e.kind { EffectKind::DropShadow(s) => { let g = s.radius.abs() * 1.5 + s.spread.max(0.0) + miter; let (dx, dy) = s.offset; - if g.is_finite() && dx.is_finite() && dy.is_finite() { r.union(b.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy)) } else { r } - } - _ => { - let g = e.reach(); - if g.is_finite() && g > 0.0 { r.union(b.inflate(g, g)) } else { r } + if g.is_finite() && dx.is_finite() && dy.is_finite() { + r.union(moved.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy)) + } else { + r + } } + _ => r, }) } @@ -899,3 +916,12 @@ impl Node { .fold(0.0, f64::max) } } + +/// The most one effect's reach counts for (document units) when reaches are added up: far past any +/// canvas, and a sum of any number of them (or of their squares) stays finite. +const MAX_EFFECT_REACH: f64 = 1e100; + +/// An effect's reach made safe to add: finite, in 0..[`MAX_EFFECT_REACH`]. +fn effect_reach(r: f64) -> f64 { + if r.is_finite() { r.clamp(0.0, MAX_EFFECT_REACH) } else { 0.0 } +} diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 403620d6..92ec539c 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -869,6 +869,11 @@ impl Effect { _ => 0.0, } } + /// Whether this is a visible layer blur or texture: what moves the layer's own pixels, one + /// after another in list order. + pub fn moves_the_layer(&self) -> bool { + self.visible && matches!(self.kind, EffectKind::LayerBlur(_) | EffectKind::Texture(_)) + } /// A layer or background blur's settings. pub fn blur(&self) -> Option<&Blur> { match &self.kind { diff --git a/crates/render/src/effects_behaviour_tests.rs b/crates/render/src/effects_behaviour_tests.rs index 5a4b6289..f32463e3 100644 --- a/crates/render/src/effects_behaviour_tests.rs +++ b/crates/render/src/effects_behaviour_tests.rs @@ -378,6 +378,29 @@ fn exports_include_what_effects_paint_outside_the_layer() { assert!(render_node(&d, f, 1.0).unwrap().height() >= 50, "frame shadow exported"); } +/// Layer blurs and texture apply one after another and drop shadows are cast from what they leave: +/// the export holds what they reach together, so nothing is cut at its edges. +#[test] +fn exports_hold_what_stacked_effects_reach_together() { + let blur = |r: f64| fx(EffectKind::LayerBlur(Blur::uniform(r))); + let rows = [ + ("four blurs of 25", vec![blur(25.0), blur(25.0), blur(25.0), blur(25.0)]), + ("eight blurs of 10", (0..8).map(|_| blur(10.0)).collect()), + ("sixteen blurs of 3", (0..16).map(|_| blur(3.0)).collect()), + ("texture over blurs", vec![blur(15.0), blur(15.0), fx(EffectKind::Texture(figcraft_doc::Texture { radius: 12.0, ..Default::default() }))]), + ("a blur under a shadow", vec![blur(20.0), fx(EffectKind::DropShadow(sharp((0.0, 30.0), 0.0)))]), + ("a shadow then a blur", vec![fx(EffectKind::DropShadow(sharp((0.0, 30.0), 0.0))), blur(20.0)]), + ]; + for (why, effects) in rows { + let (d, r) = square(vec![Paint::solid(hex("FF0000"))], effects); + let p = render_node(&d, r, 1.0).unwrap(); + let (w, h) = (p.width(), p.height()); + let alpha = |x: u32, y: u32| p.pixel(x, y).map_or(0, |c| c.alpha()); + let edge = (0..w).flat_map(|x| [alpha(x, 0), alpha(x, h - 1)]).chain((0..h).flat_map(|y| [alpha(0, y), alpha(w - 1, y)])).max().unwrap_or(0); + assert!(edge <= 1, "{why}: the export's edge has alpha {edge} ({w} × {h})"); + } +} + /// A black 200 × 200 frame with a uniform layer blur of `radius`, drawn on a 512 × 512 view at 1×. fn blurred_frame(radius: f64) -> Pixmap { let mut d = Document::new("Blur range"); From a09821f942fe4abe71c9587b4762eb082a431734 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 07:12:46 +0700 Subject: [PATCH 08/26] M1: Preferences > Scroll wheel zoom (#1390) (#1584) With pref.scrollWheelZoom on, a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls; Shift+wheel still scrolls sideways, and trackpads, pinches and touch screens are unchanged. A wheel is told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps. --- ROADMAP.md | 3 +- crates/engine/src/cmd/view.rs | 7 + crates/engine/src/prefs.rs | 4 + crates/ui-egui/locales/ar.toml | 2 + crates/ui-egui/locales/bn.toml | 2 + crates/ui-egui/locales/cs.toml | 2 + crates/ui-egui/locales/de.toml | 2 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/locales/fi.toml | 2 + crates/ui-egui/locales/fr.toml | 2 + crates/ui-egui/locales/id.toml | 2 + crates/ui-egui/locales/it.toml | 2 + crates/ui-egui/locales/ja.toml | 2 + crates/ui-egui/locales/ka.toml | 2 + crates/ui-egui/locales/ko.toml | 2 + crates/ui-egui/locales/nl.toml | 2 + crates/ui-egui/locales/pl.toml | 2 + crates/ui-egui/locales/pt-BR.toml | 2 + crates/ui-egui/locales/ru.toml | 2 + crates/ui-egui/locales/sv.toml | 2 + crates/ui-egui/locales/th.toml | 2 + crates/ui-egui/locales/tr.toml | 2 + crates/ui-egui/locales/vi.toml | 2 + crates/ui-egui/locales/zh-Hans.toml | 2 + crates/ui-egui/src/canvas.rs | 6 + crates/ui-egui/src/canvas/input.rs | 56 +++++++- crates/ui-egui/src/canvas/wheel_zoom_tests.rs | 128 ++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 1 + docs/roadmap.md | 3 +- docs/ui-parity.md | 5 +- 31 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/canvas/wheel_zoom_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index e5f7db9d..ec9dce99 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 (`comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M1, #1390: Preferences ▸ Scroll wheel zoom (`pref.scrollWheelZoom`, off by default, kept with the preferences). On, a mouse wheel zooms the canvas about the pointer at the rate Cmd/Ctrl+wheel zooms otherwise, and Cmd/Ctrl+wheel scrolls; Shift+wheel still scrolls sideways, and trackpad scrolls, pinches and touch screens are unchanged. A wheel is told from a trackpad by what the platform reports: macOS names the device; elsewhere a wheel scrolls by lines and a trackpad by pixels, so a mouse whose wheel reports pixels (Chromium-based browsers) keeps scrolling, and a trackpad that reports lines (some X11 setups) zooms. Numbers unchanged. Tests: `wheel_zoom_tests`. | | 2026-10-30 | minor | M9, part of #1415: `comment.list` takes `mentions` (a name, any case, a leading `@` allowed) and lists only the threads where a message, the first or a reply, @mentions it, combined with the other filters (`{"mentions": "Claude", "filter": "unread"}`). It matches the names each message's `mentions` reports, by the same parser the thread view highlights with (`figcraft_doc::mentions`), so `@Ada Lovelace` is Ada Lovelace, not Ada, and an email address mentions nobody; the name counts as one of the file's people, so an unknown name can be several words. Numbers unchanged. Tests: `comment_mentions_tests`. | | 2026-10-30 | minor | M8, #1123: a safety net for internal errors in the browser. wasm32 can't catch a panic (stable Rust has no unwinding std for it), so there the command guard and the frame guard do nothing and a panic stopped the page, losing every unsaved change. The page now follows its files with unsaved changes after every frame (shared copies, taken again only when a file changed); its panic hook writes them to the page's local storage as `.figcraft` copies, as many as fit (browsers give a page about 5 MB), and says on the page that it stopped and what reloading brings back; the next page opens them again as unsaved files (`file.recover` also takes the copy as base64 `data`). Numbers unchanged. Tests: `recovery_tests` (figcraft-web). | | 2026-10-30 | minor | M8, #1104: boolean operations had no input cap, so a crafted file could keep the sweep busy for unbounded time and memory (any number of segments, crossing as often as they like, and a fold re-sweeping its growing result for every operand). Each sweep now takes at most 250 000 segments and 2 million possible crossings (pairs of segments whose bounds overlap, counted exactly in O(n log n)), and a fold at most 4 million segments swept over all its steps; past them the operation is refused with `TooComplex` ("the shapes are too complex to combine") before it runs. The example files' boolean groups sweep at most 55 segments and 155 possible crossings per step. A boolean group past the caps draws without its result, with a warning, once, like layers past the render's memory budget; the refusal is remembered, so redrawing it costs nothing; Flatten refuses it instead of dropping it. Numbers unchanged. Tests: `budget_tests` (pathops), `boolean_budget_tests` (render), `boolean_cap_tests` (engine). | diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index a55ea2e2..25304508 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -369,6 +369,13 @@ pub fn specs() -> Vec { spec!("pref.renameDuplicates", "Rename duplicated layers", "{on?}", false, |s, p| pref(s, p, |x| &mut x.rename_duplicates)), spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)), spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)), + spec!( + "pref.scrollWheelZoom", + "Scroll wheel zoom", + "{on?} a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls it (Shift+wheel still scrolls sideways; trackpads are unchanged) → {on}", + false, + |s, p| pref(s, p, |x| &mut x.scroll_wheel_zoom) + ), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), spec!( "pref.uiScale", diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 89668a1a..3c241ef6 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -29,6 +29,9 @@ pub struct Prefs { pub highlight_on_hover: bool, pub number_keys_opacity: bool, pub show_dimensions: bool, + /// A mouse wheel zooms the canvas about the pointer, and Cmd/Ctrl+wheel scrolls it (off: the + /// other way round). Trackpads are unchanged. + pub scroll_wheel_zoom: bool, /// "system" by default; an explicit "light" or "dark" choice stays saved. pub theme: String, /// The widths of the main window's left and right panels (the interface keeps them within its @@ -122,6 +125,7 @@ impl Default for Prefs { highlight_on_hover: true, number_keys_opacity: true, show_dimensions: true, + scroll_wheel_zoom: false, theme: "system".into(), panel_widths: (240.0, 240.0), pages_height: None, diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index 8c9d2624..4429d008 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -808,6 +808,7 @@ status = "draft — needs native review" "Main menu" = "القائمة الرئيسية" "Make a component" = "إنشاء مكوّن" "Make slot" = "إنشاء خانة محتوى" +"Scroll wheel zoom" = "التكبير بعجلة التمرير" "Slot name" = "اسم خانة المحتوى" "Mar" = "مارس" "Margin" = "الهامش" @@ -2192,6 +2193,7 @@ status = "draft — needs native review" "pref.language" = "لغة الواجهة" "pref.nudge" = "مقدار الإزاحة الطفيفة" "pref.numberKeysOpacity" = "استخدام مفاتيح الأرقام للعتامة" +"pref.scrollWheelZoom" = "التكبير بعجلة التمرير" "pref.publishServer" = "خادم المشاركة عبر الإنترنت" "pref.reduceMotion" = "تقليل الحركة" "pref.renameDuplicates" = "إعادة تسمية الطبقات المكررة" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 4d5f2a13..1bc17af4 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -776,6 +776,7 @@ status = "draft — needs native review" "Main menu" = "প্রধান মেনু" "Make a component" = "একটি কম্পোনেন্ট তৈরি করুন" "Make slot" = "স্লট তৈরি করুন" +"Scroll wheel zoom" = "স্ক্রল হুইল দিয়ে জুম" "Slot name" = "স্লটের নাম" "Mar" = "মার্চ" "Margin" = "মার্জিন" @@ -1846,3 +1847,4 @@ status = "draft — needs native review" "tool.sticky" = "স্টিকি নোট" "widget.menu" = "উইজেটের মেনু" "widget.setText" = "উইজেটের ঘর সম্পাদনা" +"pref.scrollWheelZoom" = "স্ক্রল হুইল দিয়ে জুম" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 9d629382..006bebd3 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -761,6 +761,7 @@ status = "draft — needs native review" "Main menu" = "Hlavní nabídka" "Make a component" = "Vytvořit komponentu" "Make slot" = "Vytvořit slot" +"Scroll wheel zoom" = "Přibližování kolečkem myši" "Slot name" = "Název slotu" "Mar" = "března" "Margin" = "Vnější okraj" @@ -2085,6 +2086,7 @@ status = "draft — needs native review" "pref.language" = "Jazyk rozhraní" "pref.nudge" = "Velikost posunu" "pref.numberKeysOpacity" = "Nastavovat krytí číselnými klávesami" +"pref.scrollWheelZoom" = "Přibližování kolečkem myši" "pref.publishServer" = "Server pro online sdílení" "pref.reduceMotion" = "Omezit pohyb" "pref.renameDuplicates" = "Přejmenovávat duplikované vrstvy" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index ce9de2b4..81c99bae 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -740,6 +740,7 @@ status = "draft — needs native review" "Main component deleted" = "Hauptkomponente gelöscht" "Main menu" = "Hauptmenü" "Make slot" = "Slot erstellen" +"Scroll wheel zoom" = "Mit dem Mausrad zoomen" "Slot name" = "Slot-Name" "Margin" = "Rand" "Mark all as read" = "Alle als gelesen markieren" @@ -2039,6 +2040,7 @@ status = "draft — needs native review" "pref.language" = "Sprache der Benutzeroberfläche" "pref.nudge" = "Schrittweite" "pref.numberKeysOpacity" = "Zifferntasten für Deckkraft verwenden" +"pref.scrollWheelZoom" = "Mit dem Mausrad zoomen" "pref.publishServer" = "Server für das Online-Teilen" "pref.reduceMotion" = "Bewegung reduzieren" "pref.renameDuplicates" = "Duplizierte Ebenen umbenennen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ccf3c3c4..a4f3622a 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -814,6 +814,7 @@ name = "English" "Main menu" = "Main menu" "Make a component" = "Make a component" "Make slot" = "Make slot" +"Scroll wheel zoom" = "Scroll wheel zoom" "Slot name" = "Slot name" "Mar" = "Mar" "Margin" = "Margin" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6dd69248..0b2de28c 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -32,6 +32,7 @@ status = "draft — needs native review" "Couldn't download the variables" = "No se pudieron descargar las variables" "Couldn't download {name}" = "No se pudo descargar {name}" "Relative to" = "Relativa a" +"Scroll wheel zoom" = "Zoom con la rueda del ratón" "Trigger layer" = "Capa que la abre" "Whole screen" = "Pantalla completa" @@ -2181,6 +2182,7 @@ status = "draft — needs native review" "pref.language" = "Idioma de la interfaz" "pref.nudge" = "Cantidad de desplazamiento" "pref.numberKeysOpacity" = "Usar teclas numéricas para la opacidad" +"pref.scrollWheelZoom" = "Zoom con la rueda del ratón" "pref.publishServer" = "Servidor para compartir en línea" "pref.reduceMotion" = "Reducir movimiento" "pref.renameDuplicates" = "Renombrar capas duplicadas" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 5c5c8edb..c6ff654e 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -778,6 +778,7 @@ status = "draft — needs native review" "Main menu" = "Päävalikko" "Make a component" = "Luo komponentti" "Make slot" = "Luo sisältöpaikka" +"Scroll wheel zoom" = "Zoomaus hiiren rullalla" "Slot name" = "Sisältöpaikan nimi" "Mar" = "maalisk." "Margin" = "Marginaali" @@ -2131,6 +2132,7 @@ status = "draft — needs native review" "pref.language" = "Käyttöliittymän kieli" "pref.nudge" = "Siirtoaskeleen koko" "pref.numberKeysOpacity" = "Säädä peittävyyttä numeronäppäimillä" +"pref.scrollWheelZoom" = "Zoomaus hiiren rullalla" "pref.publishServer" = "Verkkojakamisen palvelin" "pref.reduceMotion" = "Vähennä liikettä" "pref.renameDuplicates" = "Nimeä monistetut tasot uudelleen" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index f50150af..732bc7bc 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -778,6 +778,7 @@ status = "draft — needs native review" "Main menu" = "Menu principal" "Make a component" = "Créer un composant" "Make slot" = "Créer un emplacement" +"Scroll wheel zoom" = "Zoom avec la molette" "Slot name" = "Nom de l'emplacement" "Mar" = "mars" "Margin" = "Marge" @@ -2132,6 +2133,7 @@ status = "draft — needs native review" "pref.language" = "Langue de l’interface" "pref.nudge" = "Pas de décalage" "pref.numberKeysOpacity" = "Utiliser les touches numériques pour l’opacité" +"pref.scrollWheelZoom" = "Zoom avec la molette" "pref.publishServer" = "Serveur pour le partage en ligne" "pref.reduceMotion" = "Réduire les animations" "pref.renameDuplicates" = "Renommer les calques dupliqués" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 66764481..6c3b9a6c 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -9,6 +9,7 @@ status = "draft — needs native review" [ui] "Relative to" = "Relatif terhadap" +"Scroll wheel zoom" = "Zoom dengan roda gulir" "Trigger layer" = "Layer pemicu" "Whole screen" = "Seluruh layar" @@ -2089,6 +2090,7 @@ status = "draft — needs native review" "pref.language" = "Bahasa antarmuka" "pref.nudge" = "Jarak geser" "pref.numberKeysOpacity" = "Gunakan tombol angka untuk opasitas" +"pref.scrollWheelZoom" = "Zoom dengan roda gulir" "pref.publishServer" = "Server untuk berbagi online" "pref.reduceMotion" = "Kurangi gerakan" "pref.renameDuplicates" = "Ganti nama layer duplikat" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index a4e65a87..7e3a80e2 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -751,6 +751,7 @@ status = "draft — needs native review" "Main menu" = "Menu principale" "Make a component" = "Crea un componente" "Make slot" = "Crea slot" +"Scroll wheel zoom" = "Zoom con la rotella del mouse" "Slot name" = "Nome dello slot" "Mar" = "mar" "Margin" = "Margine" @@ -2045,6 +2046,7 @@ status = "draft — needs native review" "pref.language" = "Lingua dell'interfaccia" "pref.nudge" = "Entità dello spostamento" "pref.numberKeysOpacity" = "Usa i tasti numerici per l'opacità" +"pref.scrollWheelZoom" = "Zoom con la rotella del mouse" "pref.publishServer" = "Server per la condivisione online" "pref.reduceMotion" = "Riduci animazioni" "pref.renameDuplicates" = "Rinomina i livelli duplicati" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 31647797..63686d04 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -772,6 +772,7 @@ status = "draft — needs native review" "Main menu" = "メインメニュー" "Make a component" = "コンポーネントを作成" "Make slot" = "スロットにする" +"Scroll wheel zoom" = "スクロールホイールでズーム" "Slot name" = "スロット名" "Mar" = "3月" "Margin" = "マージン" @@ -2111,6 +2112,7 @@ status = "draft — needs native review" "pref.language" = "インターフェースの言語" "pref.nudge" = "微調整の量" "pref.numberKeysOpacity" = "数字キーで不透明度を変更" +"pref.scrollWheelZoom" = "スクロールホイールでズーム" "pref.publishServer" = "オンライン共有用のサーバー" "pref.reduceMotion" = "動きを減らす" "pref.renameDuplicates" = "複製したレイヤーの名前を変更" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 985c4845..2be767e5 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -785,6 +785,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Main menu" = "მთავარი მენიუ" "Make a component" = "შექმენით კომპონენტი" "Make slot" = "სლოტად გადაქცევა" +"Scroll wheel zoom" = "მასშტაბირება გადახვევის ბორბლით" "Slot name" = "სლოტის სახელი" "Mar" = "მარ" "Margin" = "ველი" @@ -2184,6 +2185,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "pref.language" = "ინტერფეისის ენა" "pref.nudge" = "მცირედ გადაწევის ბიჯი" "pref.numberKeysOpacity" = "გაუმჭვირვალობისთვის ციფრული კლავიშების გამოყენება" +"pref.scrollWheelZoom" = "მასშტაბირება გადახვევის ბორბლით" "pref.publishServer" = "ონლაინ გაზიარების სერვერი" "pref.reduceMotion" = "მოძრაობის შემცირება" "pref.renameDuplicates" = "დუბლირებული შრეების სახელის შეცვლა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index cd256ac0..4b2f4f19 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -736,6 +736,7 @@ status = "draft — needs native review" "Main component deleted" = "메인 컴포넌트가 삭제됨" "Main menu" = "메인 메뉴" "Make slot" = "슬롯으로 만들기" +"Scroll wheel zoom" = "스크롤 휠로 확대/축소" "Slot name" = "슬롯 이름" "Margin" = "여백" "Mark all as read" = "모두 읽음으로 표시" @@ -2145,6 +2146,7 @@ status = "draft — needs native review" "pref.language" = "인터페이스 언어" "pref.nudge" = "미세 이동 거리" "pref.numberKeysOpacity" = "숫자 키로 불투명도 변경" +"pref.scrollWheelZoom" = "스크롤 휠로 확대/축소" "pref.publishServer" = "온라인 공유용 서버" "pref.reduceMotion" = "동작 줄이기" "pref.renameDuplicates" = "복제한 레이어 이름 바꾸기" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 803ce53c..4d3e9140 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -778,6 +778,7 @@ status = "draft — needs native review" "Main menu" = "Hoofdmenu" "Make a component" = "Maak een component" "Make slot" = "Slot maken" +"Scroll wheel zoom" = "Zoomen met scrollwiel" "Slot name" = "Slotnaam" "Mar" = "mrt." "Margin" = "Marge" @@ -2130,6 +2131,7 @@ status = "draft — needs native review" "pref.language" = "Interfacetaal" "pref.nudge" = "Stapafstand" "pref.numberKeysOpacity" = "Cijfertoetsen gebruiken voor dekking" +"pref.scrollWheelZoom" = "Zoomen met scrollwiel" "pref.publishServer" = "Server voor online delen" "pref.reduceMotion" = "Beweging verminderen" "pref.renameDuplicates" = "Gedupliceerde lagen hernoemen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 0d4986cd..ee0145e4 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -780,6 +780,7 @@ name = "Polski" "Main menu" = "Menu główne" "Make a component" = "Utwórz komponent" "Make slot" = "Utwórz gniazdo (slot)" +"Scroll wheel zoom" = "Powiększanie kółkiem myszy" "Slot name" = "Nazwa gniazda" "Mar" = "Mar" "Margin" = "Margines" @@ -2143,6 +2144,7 @@ name = "Polski" "pref.language" = "Język interfejsu" "pref.nudge" = "Wartości przesunięcia (Nudge)" "pref.numberKeysOpacity" = "Klawisze numeryczne zmieniają krycie" +"pref.scrollWheelZoom" = "Powiększanie kółkiem myszy" "pref.publishServer" = "Serwer do udostępniania online" "pref.reduceMotion" = "Ogranicz animacje" "pref.renameDuplicates" = "Zmieniaj nazwy duplikatów" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 8825d657..23d9fbfb 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -735,6 +735,7 @@ status = "draft — needs native review" "Main component deleted" = "Componente principal excluído" "Main menu" = "Menu principal" "Make slot" = "Transformar em slot" +"Scroll wheel zoom" = "Zoom com a roda do mouse" "Slot name" = "Nome do slot" "Margin" = "Margem" "Mark all as read" = "Marcar tudo como lido" @@ -2142,6 +2143,7 @@ status = "draft — needs native review" "pref.language" = "Idioma da interface" "pref.nudge" = "Valor do deslocamento" "pref.numberKeysOpacity" = "Usar teclas numéricas para a opacidade" +"pref.scrollWheelZoom" = "Zoom com a roda do mouse" "pref.publishServer" = "Servidor para compartilhamento online" "pref.reduceMotion" = "Reduzir movimento" "pref.renameDuplicates" = "Renomear camadas duplicadas" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 0065e930..6432b21c 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -780,6 +780,7 @@ name = "Русский" "Main menu" = "Главное меню" "Make a component" = "Создать компонент" "Make slot" = "Сделать слотом" +"Scroll wheel zoom" = "Масштабирование колесом мыши" "Slot name" = "Имя слота" "Mar" = "мар." "Margin" = "Внешний отступ" @@ -2143,6 +2144,7 @@ name = "Русский" "pref.language" = "Язык интерфейса" "pref.nudge" = "Шаг сдвига" "pref.numberKeysOpacity" = "Задавать непрозрачность цифровыми клавишами" +"pref.scrollWheelZoom" = "Масштабирование колесом мыши" "pref.publishServer" = "Сервер для онлайн-публикации" "pref.reduceMotion" = "Уменьшить движение" "pref.renameDuplicates" = "Переименовывать дубликаты слоёв" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 714b1ecf..09974e37 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -802,6 +802,7 @@ name = "Svenska" "Main menu" = "Huvudmeny" "Make a component" = "Skapa en komponent" "Make slot" = "Gör till plats" +"Scroll wheel zoom" = "Zooma med scrollhjulet" "Slot name" = "Platsens namn" "Mar" = "Mar" "Margin" = "Marginal" @@ -2188,6 +2189,7 @@ name = "Svenska" "pref.language" = "Gränssnittsspråk" "pref.nudge" = "Steglängd" "pref.numberKeysOpacity" = "Använd siffertangenterna för opacitet" +"pref.scrollWheelZoom" = "Zooma med scrollhjulet" "pref.publishServer" = "Server för delning online" "pref.reduceMotion" = "Minska rörelse" "pref.renameDuplicates" = "Byt namn på duplicerade lager" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index 730fb297..92a52d7f 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -774,6 +774,7 @@ status = "draft — needs native review" "Main menu" = "เมนูหลัก" "Make a component" = "สร้างคอมโพเนนต์" "Make slot" = "สร้างสล็อต" +"Scroll wheel zoom" = "ซูมด้วยล้อเลื่อนเมาส์" "Slot name" = "ชื่อสล็อต" "Mar" = "มี.ค." "Margin" = "ระยะขอบนอก" @@ -2107,6 +2108,7 @@ status = "draft — needs native review" "pref.language" = "ภาษาของอินเทอร์เฟซ" "pref.nudge" = "ระยะขยับ" "pref.numberKeysOpacity" = "ใช้ปุ่มตัวเลขกำหนดความทึบ" +"pref.scrollWheelZoom" = "ซูมด้วยล้อเลื่อนเมาส์" "pref.publishServer" = "เซิร์ฟเวอร์สำหรับการแชร์ออนไลน์" "pref.reduceMotion" = "ลดการเคลื่อนไหว" "pref.renameDuplicates" = "เปลี่ยนชื่อเลเยอร์ที่ทำสำเนา" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index dbb75d4c..2b514b11 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -765,6 +765,7 @@ name = "Türkçe" "Main menu" = "Ana menü" "Make a component" = "Bileşen yap" "Make slot" = "Yuva yap" +"Scroll wheel zoom" = "Kaydırma tekerleğiyle yakınlaştırma" "Slot name" = "Yuva adı" "Mar" = "Mar" "Margin" = "Kenar boşluğu" @@ -2089,6 +2090,7 @@ name = "Türkçe" "pref.language" = "Arayüz dili" "pref.nudge" = "Kaydırma miktarı" "pref.numberKeysOpacity" = "Opaklık için rakam tuşlarını kullan" +"pref.scrollWheelZoom" = "Kaydırma tekerleğiyle yakınlaştırma" "pref.publishServer" = "Çevrimiçi paylaşım sunucusu" "pref.reduceMotion" = "Hareketi azalt" "pref.renameDuplicates" = "Çoğaltılan katmanları yeniden adlandır" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 384f829e..dab0c5c8 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -752,6 +752,7 @@ status = "draft — needs native review" "Main menu" = "Trình đơn chính" "Make a component" = "Tạo thành phần" "Make slot" = "Tạo ô chứa" +"Scroll wheel zoom" = "Thu phóng bằng con lăn chuột" "Slot name" = "Tên ô chứa" "Mar" = "Thg 3" "Margin" = "Lề" @@ -2046,6 +2047,7 @@ status = "draft — needs native review" "pref.language" = "Ngôn ngữ giao diện" "pref.nudge" = "Mức dịch chuyển nhẹ" "pref.numberKeysOpacity" = "Dùng phím số để chỉnh độ mờ" +"pref.scrollWheelZoom" = "Thu phóng bằng con lăn chuột" "pref.publishServer" = "Máy chủ chia sẻ trực tuyến" "pref.reduceMotion" = "Giảm chuyển động" "pref.renameDuplicates" = "Đổi tên các lớp nhân bản" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 6953ce1c..3cf26dbd 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -741,6 +741,7 @@ status = "draft — needs native review" "Main component deleted" = "主组件已删除" "Main menu" = "主菜单" "Make slot" = "设为插槽" +"Scroll wheel zoom" = "滚轮缩放" "Slot name" = "插槽名称" "Margin" = "边距" "Mark all as read" = "全部标为已读" @@ -2042,6 +2043,7 @@ status = "draft — needs native review" "pref.language" = "界面语言" "pref.nudge" = "微移量" "pref.numberKeysOpacity" = "用数字键设置不透明度" +"pref.scrollWheelZoom" = "滚轮缩放" "pref.publishServer" = "在线分享所用的服务器" "pref.reduceMotion" = "减少动态效果" "pref.renameDuplicates" = "重命名副本图层" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 3faa779f..04e5a63f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -81,6 +81,8 @@ pub struct CanvasState { /// The pen pressure last reported during the current press (pen and stylus touches; the /// winit backends and browsers that report it). pressure: Option, + /// The latest scroll came from a mouse wheel rather than a trackpad (Scroll wheel zoom). + wheel_is_mouse: bool, } impl Default for CanvasState { @@ -107,6 +109,7 @@ impl Default for CanvasState { hovering: false, preedit: None, pressure: None, + wheel_is_mouse: false, } } } @@ -367,5 +370,8 @@ mod builder_poly_tests; #[cfg(test)] mod outline_tests; +#[cfg(test)] +mod wheel_zoom_tests; + #[cfg(test)] mod zoom_preview_tests; diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index e45e8202..9a24e90c 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -25,6 +25,53 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods: true } +/// The wheel's scroll and the zoom for this frame. With Preferences ▸ Scroll wheel zoom on, a +/// mouse wheel zooms and Cmd/Ctrl+wheel scrolls ([`swap_wheel`]); Shift+wheel still scrolls +/// sideways, and trackpads, pinches and touch screens are left as they are. +fn wheel_zoom(app: &mut FigApp, ctx: &egui::Context, zoom: f32, shift: bool) -> (egui::Vec2, f32) { + let (wheel, latest, pinch) = ctx.input(|i| { + let latest = i.events.iter().rev().find_map(|e| match e { + egui::Event::MouseWheel { unit, source, .. } => Some(is_mouse_wheel(*source, *unit)), + _ => None, + }); + let pinch = i.multi_touch().is_some() || i.events.iter().any(|e| matches!(e, egui::Event::Zoom(_))); + (i.smooth_scroll_delta, latest, pinch) + }); + let speed = ctx.options(|o| o.input_options.scroll_zoom_speed); + // egui spreads a wheel step over a few frames: the kind holds until the next scroll event. + if let Some(mouse) = latest { + app.canvas.wheel_is_mouse = mouse; + } + if !app.session.prefs.scroll_wheel_zoom || !app.canvas.wheel_is_mouse || pinch { + return (wheel, zoom); + } + swap_wheel(wheel, zoom, shift, speed) +} + +/// Whether a scroll event came from a mouse wheel. Only macOS says which device scrolled; elsewhere +/// a wheel scrolls by lines and a trackpad by points, which is the best guess there is. +pub(crate) fn is_mouse_wheel(source: egui::MouseWheelSource, unit: egui::MouseWheelUnit) -> bool { + match source { + egui::MouseWheelSource::Wheel => true, + egui::MouseWheelSource::Unknown => unit != egui::MouseWheelUnit::Point, + _ => false, + } +} + +/// Scroll wheel zoom on a mouse wheel's frame: egui reads Cmd/Ctrl+wheel as `zoom`, which becomes +/// a scroll down (across with Shift: `ViewState::scroll`), and a plain +/// `wheel` scroll becomes a zoom at egui's own Cmd/Ctrl+wheel rate (`speed`). Shift+wheel is +/// left a sideways scroll. +pub(crate) fn swap_wheel(wheel: egui::Vec2, zoom: f32, shift: bool, speed: f32) -> (egui::Vec2, f32) { + if zoom > 0.0 && (zoom - 1.0).abs() > 1e-6 && speed > 0.0 { + return (egui::vec2(0.0, zoom.ln() / speed), 1.0); + } + if !shift && wheel != egui::Vec2::ZERO { + return (egui::Vec2::ZERO, (speed * (wheel.x + wheel.y)).exp()); + } + (wheel, zoom) +} + fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option) { let dropped = ctx.input(|i| i.raw.dropped_files.clone()); let mut refused = false; @@ -65,10 +112,12 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo } let ctx = ui.ctx().clone(); // Two fingers pan as well as pinch (a touch screen has no scroll wheel). - let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| { + let (mods, pan, zoom_delta, hover_pos, space) = ctx.input(|i| { let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |m| m.translation_delta); - (i.modifiers, i.smooth_scroll_delta + pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space)) + (i.modifiers, pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space)) }); + let (wheel, zoom_delta) = wheel_zoom(app, &ctx, zoom_delta, mods.shift); + let scroll = wheel + pan; let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false); let typing_elsewhere = crate::a11y::field_focused(&ctx); app.canvas.space_pan = space && !editing_text && !typing_elsewhere; @@ -103,7 +152,8 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo app.ui.pages_focus = None; app.ui.key_box = None; } - // Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans. + // Zoom (pinch, Cmd/Ctrl+scroll; a plain mouse wheel with Scroll wheel zoom) about the pointer; + // other scrolls pan. if resp.hovered() { if (zoom_delta - 1.0).abs() > 1e-4 { let anchor = app.to_page(pos); diff --git a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs new file mode 100644 index 00000000..5b196a4d --- /dev/null +++ b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs @@ -0,0 +1,128 @@ +//! Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and +//! Cmd/Ctrl+wheel scrolls it; Shift+wheel still scrolls sideways, and trackpads and pinches are +//! unchanged. Off (the default), a wheel scrolls and Cmd/Ctrl+wheel zooms. + +use egui::{Event, Modifiers, MouseWheelSource, MouseWheelUnit, Pos2, RawInput, Rect, TouchPhase, Vec2, vec2}; +use figcraft_engine::Session; +use serde_json::json; + +use super::input::{is_mouse_wheel, swap_wheel}; +use crate::{FigApp, NoServices}; + +/// The view's zoom, cx and cy. +type View = (f64, f64, f64); + +struct Canvas { + ctx: egui::Context, + app: FigApp, + time: f64, +} + +/// A notch of a mouse wheel (lines, as Windows and Linux report one) or a trackpad scroll (points). +fn wheel(unit: MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Event { + Event::MouseWheel { unit, source: MouseWheelSource::Unknown, delta: vec2(0.0, dy), phase: TouchPhase::Move, modifiers } +} + +impl Canvas { + fn new(scroll_wheel_zoom: bool) -> Self { + let mut c = Self { ctx: egui::Context::default(), app: FigApp::new(Session::new(), Box::new(NoServices)), time: 0.0 }; + c.app.run("pref.scrollWheelZoom", json!({"on": scroll_wheel_zoom})); + for _ in 0..3 { + c.frame(vec![], Modifiers::NONE); + } + c + } + + /// The middle of the canvas (of the window until the canvas is laid out). + fn centre(&self) -> Pos2 { + let r = self.app.canvas.rect; + if r.is_positive() { r.center() } else { Pos2::new(720.0, 450.0) } + } + + fn frame(&mut self, mut events: Vec, modifiers: Modifiers) { + self.time += 1.0 / 60.0; + events.splice(0..0, [Event::ModifiersChanged(modifiers), Event::PointerMoved(self.centre())]); + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + } + + /// `event` over the middle of the canvas, then frames until egui's smoothing settles; the + /// view's (zoom, cx, cy) before and after, and the page point under the pointer before and after. + fn gesture(&mut self, event: Event, modifiers: Modifiers) -> (View, View, (Vec2, Vec2)) { + let view = |c: &Self| c.app.session.ds().map(|d| (d.view.zoom, d.view.cx, d.view.cy)).unwrap(); + let under = |c: &Self| { + let p = c.app.to_page(c.centre()); + vec2(p.x as f32, p.y as f32) + }; + let (before, at) = (view(self), under(self)); + self.frame(vec![event], modifiers); + for _ in 0..40 { + self.frame(vec![], modifiers); + } + (before, view(self), (at, under(self))) + } +} + +#[test] +fn off_a_wheel_scrolls_and_cmd_or_ctrl_wheel_zooms() { + let mut c = Canvas::new(false); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::NONE), Modifiers::NONE); + assert_eq!(a.0, b.0, "no zoom"); + assert!(b.2 > a.2 + 1.0, "scrolled down: {a:?} → {b:?}"); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::COMMAND), Modifiers::COMMAND); + assert!(b.0 > a.0 * 1.05, "zoomed in: {a:?} → {b:?}"); +} + +#[test] +fn on_a_wheel_zooms_about_the_pointer_and_cmd_or_ctrl_wheel_scrolls() { + let mut c = Canvas::new(true); + let (a, b, (at, after)) = c.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE); + assert!(b.0 > a.0 * 1.05, "zoomed in: {a:?} → {b:?}"); + assert!((at - after).length() < 0.5, "about the pointer: {at:?} → {after:?}"); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::NONE), Modifiers::NONE); + assert!(b.0 < a.0 / 1.05, "zoomed out: {a:?} → {b:?}"); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::COMMAND), Modifiers::COMMAND); + assert!((b.0 - a.0).abs() < 1e-9 && b.2 > a.2 + 1.0 && (b.1 - a.1).abs() < 1e-9, "scrolled down: {a:?} → {b:?}"); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Line, -3.0, Modifiers::SHIFT), Modifiers::SHIFT); + assert!((b.0 - a.0).abs() < 1e-9 && b.1 > a.1 + 1.0 && (b.2 - a.2).abs() < 1e-9, "scrolled sideways: {a:?} → {b:?}"); +} + +#[test] +fn on_trackpads_and_pinches_are_unchanged() { + let mut c = Canvas::new(true); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Point, -6.0, Modifiers::NONE), Modifiers::NONE); + assert!((b.0 - a.0).abs() < 1e-9 && b.2 > a.2, "two fingers pan: {a:?} → {b:?}"); + let (a, b, _) = c.gesture(wheel(MouseWheelUnit::Point, 6.0, Modifiers::COMMAND), Modifiers::COMMAND); + assert!(b.0 > a.0, "Cmd/Ctrl+two fingers zoom: {a:?} → {b:?}"); + // A pinch right after a mouse wheel is still a pinch. + c.gesture(wheel(MouseWheelUnit::Line, 1.0, Modifiers::NONE), Modifiers::NONE); + let (a, b, _) = c.gesture(Event::Zoom(1.5), Modifiers::NONE); + assert!((b.0 / a.0 - 1.5).abs() < 1e-3, "pinch: {a:?} → {b:?}"); +} + +#[test] +fn which_scrolls_count_as_a_mouse_wheel() { + assert!(is_mouse_wheel(MouseWheelSource::Wheel, MouseWheelUnit::Point)); + assert!(is_mouse_wheel(MouseWheelSource::Unknown, MouseWheelUnit::Line)); + assert!(is_mouse_wheel(MouseWheelSource::Unknown, MouseWheelUnit::Page)); + assert!(!is_mouse_wheel(MouseWheelSource::Unknown, MouseWheelUnit::Point)); + assert!(!is_mouse_wheel(MouseWheelSource::Trackpad, MouseWheelUnit::Line)); + assert!(!is_mouse_wheel(MouseWheelSource::Momentum, MouseWheelUnit::Point)); +} + +#[test] +fn the_swap_keeps_egui_rates() { + let speed = 1.0 / 200.0; + // Cmd/Ctrl+wheel: egui's zoom back into the scroll it came from. + let (s, z) = swap_wheel(Vec2::ZERO, (speed * 40.0f32).exp(), false, speed); + assert!((s.y - 40.0).abs() < 1e-3 && s.x == 0.0 && z == 1.0); + // A plain wheel: the zoom egui gives Cmd/Ctrl+wheel. + let (s, z) = swap_wheel(vec2(0.0, 40.0), 1.0, false, speed); + assert!(s == Vec2::ZERO && (z - (speed * 40.0f32).exp()).abs() < 1e-6); + // Shift+wheel and nothing at all are left alone. + assert_eq!(swap_wheel(vec2(40.0, 0.0), 1.0, true, speed), (vec2(40.0, 0.0), 1.0)); + assert_eq!(swap_wheel(Vec2::ZERO, 1.0, false, speed), (Vec2::ZERO, 1.0)); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fe3bdb73..137f2ae6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -238,6 +238,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { (tr("Rename duplicated layers"), prefs.rename_duplicates, "pref.renameDuplicates"), (tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"), (tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"), + (tr("Scroll wheel zoom"), prefs.scroll_wheel_zoom, "pref.scrollWheelZoom"), (tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"), ] { if crate::widgets::checkbox(ui, val, label) { diff --git a/docs/roadmap.md b/docs/roadmap.md index 6a064ae3..2200ebff 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 (`comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); 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 (Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M1, #1390: Preferences ▸ Scroll wheel zoom: a mouse wheel zooms about the pointer and Cmd/Ctrl+wheel scrolls; Shift+wheel, trackpads and pinches unchanged (wheels told from trackpads by the device macOS reports, elsewhere by line versus pixel steps). Tests: `wheel_zoom_tests` | | 2026-10-30 | minor | M9, part of #1415: `comment.list {mentions}` lists only the threads where a message @mentions a name (any case, by the same parser as the highlighting), combined with the other filters. Tests: `comment_mentions_tests` | | 2026-10-30 | minor | M8, #1123: in the browser, where a panic can't be caught, the page's panic hook keeps its files with unsaved changes in local storage (as many as fit) and the next page opens them again as unsaved files; `file.recover` takes the copy as base64 `data`. Tests: `recovery_tests` (figcraft-web) | | 2026-10-30 | minor | M8, #1104: boolean operations refuse input past 250 000 segments, 2 million possible crossings or 4 million segments swept over a fold (`TooComplex`, before the sweep runs); a boolean group past them draws without its result, with a warning, and Flatten refuses it. Tests: `budget_tests`, `boolean_budget_tests`, `boolean_cap_tests` | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 474c1bfe..2b00049a 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-30 · **Change:** minor (boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); 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: @@ -99,7 +99,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | +| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | | Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only | | Frame labels (rotated, right-to-left), size badge | done | | | Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on | @@ -175,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Preferences ▸ Scroll wheel zoom (#1390): a mouse wheel zooms about the pointer and Cmd/Ctrl+wheel scrolls; Shift+wheel, trackpads and pinches unchanged. Numbers unchanged. | | 2026-10-30 | minor | Boolean groups past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold; #1104) draw without their result, with a warning, and Flatten refuses them. Numbers unchanged. | | 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged | | 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. | From 872c85bf7d8df4dd8be043f909e2b231e31777d9 Mon Sep 17 00:00:00 2001 From: samhyun Date: Fri, 30 Oct 2026 09:13:26 +0900 Subject: [PATCH 09/26] M8: review on #1583: the too-large margin test asserts the capped sum The test only checked for a large finite margin, which the old largest-effect margin (1e308) passed as well. It now asserts the two capped reaches added up, so it fails on a return to either the old maximum or an overflowing sum. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/draw.rs | 2 +- crates/render/src/retained_tests.rs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index a30c7bb0..97ff3ad8 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -474,7 +474,7 @@ pub(crate) fn bounds_within<'a>( /// The most one effect counts for in [`effect_margin`] (document units): far past any view, and /// sums of any number of them stay finite. -const MAX_REACH: f64 = 1e12; +pub(crate) const MAX_REACH: f64 = 1e12; /// How far (document units) effects that read outside a node's paint bounds reach: blurs, glass, /// drop shadows and texture. Culling and partial renders keep this much margin. diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index bdc1be19..e6c09ec5 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -436,14 +436,15 @@ fn a_shadow_cast_from_a_blurred_layer_draws_the_same_in_a_cut_view() { assert_within(&part, &one, 390, 2, "the shadow of a blurred layer above the view"); } -/// Reaches too large to add (texture of 1e308, twice) still keep a margin, not none. +/// Reaches too large to add (texture of 1e308, twice) each count up to the cap and add up: a margin +/// that stays finite, not none, and not the largest one alone. #[test] fn effect_margins_too_large_to_add_stay_large() { let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "T", 10.0, 10.0); let t = figcraft_doc::Texture { radius: 1e308, ..Default::default() }; n.effects = vec![Effect { kind: EffectKind::Texture(t.clone()), visible: true }, Effect { kind: EffectKind::Texture(t), visible: true }]; let m = crate::draw::effect_margin(&n); - assert!(m.is_finite() && m > 1e6, "{m}"); + assert_eq!(m, 2.0 * crate::draw::MAX_REACH); } /// Every channel of `a` within `tol` of the same channel of `b` from row `rows_down` on. From e7ae0f442685daba19c5c4b8c6feb21b2c428ed9 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 07:29:40 +0700 Subject: [PATCH 10/26] M9: share links can comment: a comment access level beside edit and view (#1525) (#1576) Someone who joins with a comment link (or its join code) sees the file live and adds, replies to, reacts to and resolves comment threads, but can't change the design: their app refuses other edits and the server accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones; the Share popover offers Can edit, Can comment and Can view, and the commenter's toolbar shows a Can comment badge with Move, Hand and Comment. Backward compatible: clients announce the comments word; one that doesn't is welcomed with a comment link as a viewer and gets comment links listed as view links, a level this build doesn't know reads as view, and an older server that can't make a comment link leaves the share without one. --- ROADMAP.md | 3 +- apps/figcraft-server/src/cli.rs | 9 +- apps/figcraft-server/src/codes_tests.rs | 9 +- .../figcraft-server/src/comment_link_tests.rs | 145 +++++++++++++ apps/figcraft-server/src/lib.rs | 2 + apps/figcraft-server/src/owned_tests.rs | 2 +- crates/collab-net/src/exchange.rs | 59 ++++-- crates/collab-net/src/exchange_tests.rs | 28 ++- crates/collab-net/src/hub.rs | 30 +-- crates/collab-net/src/hub/barred_tests.rs | 2 +- crates/collab-net/src/hub/queue_tests.rs | 2 +- crates/collab-net/src/manage.rs | 17 +- crates/collab-net/src/requests.rs | 44 +++- crates/collab/src/change.rs | 10 + crates/collab/src/client.rs | 6 + crates/collab/src/comment_access_tests.rs | 200 ++++++++++++++++++ crates/collab/src/lib.rs | 4 +- crates/collab/src/message.rs | 97 ++++++++- crates/collab/src/server.rs | 32 ++- crates/engine/src/cmd/view.rs | 5 +- crates/engine/src/comment_link_tests.rs | 91 ++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/live_share_tests.rs | 4 +- crates/engine/src/online_tests.rs | 2 +- crates/engine/src/session/exec.rs | 7 +- crates/engine/src/sharing/live.rs | 18 ++ crates/engine/src/sharing/live/events.rs | 24 ++- crates/engine/src/sharing/live/host.rs | 34 +-- crates/engine/src/sharing/live/join.rs | 10 +- crates/engine/src/sharing/live/sync.rs | 9 +- crates/engine/src/sharing/live/types.rs | 27 ++- crates/engine/src/sharing/online.rs | 14 +- crates/ui-egui/locales/ar.toml | 3 + crates/ui-egui/locales/bn.toml | 3 + crates/ui-egui/locales/cs.toml | 3 + crates/ui-egui/locales/de.toml | 3 + crates/ui-egui/locales/en.toml | 3 + crates/ui-egui/locales/es.toml | 3 + crates/ui-egui/locales/fi.toml | 3 + crates/ui-egui/locales/fr.toml | 3 + crates/ui-egui/locales/id.toml | 3 + crates/ui-egui/locales/it.toml | 3 + crates/ui-egui/locales/ja.toml | 3 + crates/ui-egui/locales/ka.toml | 3 + crates/ui-egui/locales/ko.toml | 3 + crates/ui-egui/locales/nl.toml | 3 + crates/ui-egui/locales/pl.toml | 3 + crates/ui-egui/locales/pt-BR.toml | 3 + crates/ui-egui/locales/ru.toml | 3 + crates/ui-egui/locales/sv.toml | 3 + crates/ui-egui/locales/th.toml | 3 + crates/ui-egui/locales/tr.toml | 3 + crates/ui-egui/locales/vi.toml | 3 + crates/ui-egui/locales/zh-Hans.toml | 3 + crates/ui-egui/src/live.rs | 48 +++-- crates/ui-egui/src/live_server_tests.rs | 26 ++- crates/ui-egui/src/live_share_tests.rs | 42 +++- crates/ui-egui/src/online_tests.rs | 2 +- crates/ui-egui/src/toolbar.rs | 22 +- docs/collab-protocol.md | 19 +- docs/collaboration-parity.md | 5 +- docs/control-protocol.md | 2 +- docs/mcp.md | 17 +- docs/roadmap.md | 3 +- docs/server.md | 17 +- 65 files changed, 1039 insertions(+), 178 deletions(-) create mode 100644 apps/figcraft-server/src/comment_link_tests.rs create mode 100644 crates/collab/src/comment_access_tests.rs create mode 100644 crates/engine/src/comment_link_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index ec9dce99..8e4f66f1 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 (Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M9, #1525: share links can comment. A third access level, `comment`, sits between `edit` and `view`: someone who joins with a comment link (or its join code) sees the file live, as with a view link, and adds comment threads, replies, reactions and resolves them; any other edit is refused, by their app and by the server, which accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones, and the Share popover offers Can edit, Can comment and Can view; the toolbar shows a "Can comment" badge with Move, Hand and Comment. `collab.share {access: "comment"}`, `collab.manageFile {action: createLink, access: "comment"}` and `figcraft-server links create FILE --access comment` make and pick it. Older apps are unaffected: one that joins with a comment link is welcomed as a viewer, links listed to it read as view links, and a level an app doesn't know reads as view; an older server makes no comment link and sharing goes on without one. Comment authorship is the name in Preferences, as before (no accounts), so a commenter can edit or remove other people's threads. Tests: `comment_access_tests.rs` (collab), `comment_link_tests.rs` (engine, server end to end), the Share popover and toolbar in `live_share_tests.rs` and `live_server_tests.rs`. | | 2026-10-30 | minor | M1, #1390: Preferences ▸ Scroll wheel zoom (`pref.scrollWheelZoom`, off by default, kept with the preferences). On, a mouse wheel zooms the canvas about the pointer at the rate Cmd/Ctrl+wheel zooms otherwise, and Cmd/Ctrl+wheel scrolls; Shift+wheel still scrolls sideways, and trackpad scrolls, pinches and touch screens are unchanged. A wheel is told from a trackpad by what the platform reports: macOS names the device; elsewhere a wheel scrolls by lines and a trackpad by pixels, so a mouse whose wheel reports pixels (Chromium-based browsers) keeps scrolling, and a trackpad that reports lines (some X11 setups) zooms. Numbers unchanged. Tests: `wheel_zoom_tests`. | | 2026-10-30 | minor | M9, part of #1415: `comment.list` takes `mentions` (a name, any case, a leading `@` allowed) and lists only the threads where a message, the first or a reply, @mentions it, combined with the other filters (`{"mentions": "Claude", "filter": "unread"}`). It matches the names each message's `mentions` reports, by the same parser the thread view highlights with (`figcraft_doc::mentions`), so `@Ada Lovelace` is Ada Lovelace, not Ada, and an email address mentions nobody; the name counts as one of the file's people, so an unknown name can be several words. Numbers unchanged. Tests: `comment_mentions_tests`. | | 2026-10-30 | minor | M8, #1123: a safety net for internal errors in the browser. wasm32 can't catch a panic (stable Rust has no unwinding std for it), so there the command guard and the frame guard do nothing and a panic stopped the page, losing every unsaved change. The page now follows its files with unsaved changes after every frame (shared copies, taken again only when a file changed); its panic hook writes them to the page's local storage as `.figcraft` copies, as many as fit (browsers give a page about 5 MB), and says on the page that it stopped and what reloading brings back; the next page opens them again as unsaved files (`file.recover` also takes the copy as base64 `data`). Numbers unchanged. Tests: `recovery_tests` (figcraft-web). | diff --git a/apps/figcraft-server/src/cli.rs b/apps/figcraft-server/src/cli.rs index d92689df..02bdfbed 100644 --- a/apps/figcraft-server/src/cli.rs +++ b/apps/figcraft-server/src/cli.rs @@ -29,7 +29,7 @@ Usage: figcraft-server files [list] [--sort size|age|idle] the files kept (biggest, oldest or longest unopened first) figcraft-server files remove FILE remove a file and its links for good figcraft-server links list FILE a file's share links - figcraft-server links create FILE --access edit|view [--label TEXT] + figcraft-server links create FILE --access edit|comment|view [--label TEXT] figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected) figcraft-server plugins [list] the plugin directory: every plugin and its versions figcraft-server plugins remove ID [--version V] unpublish a plugin (every version, or one) @@ -124,7 +124,7 @@ pub fn parse(args: &[String]) -> Result { "--listen" => config.listen = value("--listen")?, "--data" => config.data = PathBuf::from(value("--data")?), "--public-url" => config.public_url = Some(value("--public-url")?), - "--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?), + "--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit, comment or view")?), "--label" => label = value("--label")?, "--rotate" => rotate = true, "--sort" => { @@ -147,7 +147,7 @@ pub fn parse(args: &[String]) -> Result { ["files", "remove", file] => Command::RemoveFile { file: file.to_string() }, ["links", "list", file] => Command::Links { file: file.to_string() }, ["links", "create", file] => { - Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label } + Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit, comment or view")?, label } } ["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() }, ["plugins"] | ["plugins", "list"] => Command::Plugins, @@ -361,8 +361,7 @@ fn new_link(cfg: &Config, l: &NewLink) -> Result { }; let invite = Invite { server: public_endpoint(cfg, listen)?, file: l.link.file.clone(), token: l.token.clone() }; let mut out = String::new(); - let what = if l.link.access == Access::View { "view" } else { "edit" }; - let _ = writeln!(out, "New {what} link {} for file {}:", l.link.id, l.link.file); + let _ = writeln!(out, "New {} link {} for file {}:", l.link.access.id(), l.link.id, l.link.file); let _ = writeln!(out, " {}", invite.link()); let _ = writeln!(out, " {}", invite.url()); let _ = writeln!(out, "This is the only time the link is shown (the server keeps only a hash of its token)."); diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs index e226d2ec..cbc7f70a 100644 --- a/apps/figcraft-server/src/codes_tests.rs +++ b/apps/figcraft-server/src/codes_tests.rs @@ -57,7 +57,7 @@ fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: & assert!(!links.contains(secret), "{links}"); } assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token"); - assert_eq!(links.matches("\"code\"").count(), 2); + assert_eq!(links.matches("\"code\"").count(), 3); } #[test] @@ -76,7 +76,8 @@ fn a_file_shared_online_is_joined_by_its_codes() { assert_eq!(shared["code"], edit.as_str(), "the code shown lets people edit unless asked otherwise"); let p = a.live().published.clone().unwrap(); assert!(p.owner.is_some(), "the owner token is kept as before"); - // Others get links of their own (the owner keeps the file's first ones): four in all. + // Others get links of their own (edit, view and comment; the owner keeps the file's first + // ones): five in all. let edit_link = shared["share"]["editLink"].as_str().unwrap().to_string(); assert!(edit_link != p.edit && shared["share"]["viewLink"] != p.view.as_str() && shared["share"]["shared"] == true); let ids = |dir: &std::path::Path| -> Vec { @@ -84,7 +85,7 @@ fn a_file_shared_online_is_joined_by_its_codes() { links.as_array().unwrap().iter().map(|l| l["id"].as_str().unwrap().to_string()).collect() }; let before_stop = ids(dir.path()); - assert_eq!(before_stop.len(), 4); + assert_eq!(before_stop.len(), 5); // On disk: hashes only, of the codes as of the links' tokens. only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view); // Someone types the edit code, the way people type. @@ -142,7 +143,7 @@ fn a_file_shared_online_is_joined_by_its_codes() { let (edit2, link2) = (again["code"].as_str().unwrap().to_string(), again["share"]["editLink"].as_str().unwrap().to_string()); assert!(is_code(&edit2) && edit2 != edit && link2 != edit_link && again["published"]["file"] == p.file.as_str(), "{again}"); assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1, "still one file on the server"); - assert_eq!(ids(dir.path()).len(), 4); + assert_eq!(ids(dir.path()).len(), 5); ex(&mut b, "collab.join", json!({"code": edit2})); pump(&mut [&mut a, &mut b], "Bo back in", |s| live(s[1]) && layers(s[1]) == before + 3 && s[0].live().peers.len() == 1); // The old code is still gone. diff --git a/apps/figcraft-server/src/comment_link_tests.rs b/apps/figcraft-server/src/comment_link_tests.rs new file mode 100644 index 00000000..50af8a01 --- /dev/null +++ b/apps/figcraft-server/src/comment_link_tests.rs @@ -0,0 +1,145 @@ +//! Comment links end to end over loopback: sharing online makes a comment link and code beside the +//! edit and view ones; someone who joins with it comments and everyone sees it, their app refuses +//! their other edits, and the server refuses them too for a client that skips the check; a client +//! that can't read comment links gets in as a viewer, and links listed to it read as view links. + +use std::time::Duration; + +use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, Manage, PROTOCOL, ServerMsg}; +use figcraft_collab::{COMMENT_ONLY, Change, Client, Entry, Json}; +use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage}; +use figcraft_doc::{Comment, Message}; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::test_util::{Raw, TempDir, config, join, key, live, publish, pump, session}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +/// The welcome `raw` gets after saying hello with `client_version` through `link`. +fn welcome(raw: &mut Raw, link: &str, client_version: &str) -> Client { + let invite = Invite::parse(link).unwrap(); + let ClientMsg::Hello(mut h) = Client::hello(&invite.file, &invite.token, "Raw", "") else { panic!("not a hello") }; + h.client_version = client_version.into(); + raw.send(&ClientMsg::Hello(h)); + loop { + if let w @ ServerMsg::Welcome { .. } = raw.next() { + return Client::join(w).unwrap(); + } + } +} + +/// What the server says to the batch `raw` sends next. +fn answer(raw: &mut Raw) -> ServerMsg { + loop { + if let m @ (ServerMsg::Reject { .. } | ServerMsg::Changes(_)) = raw.next() { + return m; + } + } +} + +#[test] +fn a_comment_link_comments_and_the_server_refuses_its_edits() { + let dir = TempDir::new("comment-link"); + let mut server = crate::start(&config(dir.path())).unwrap(); + let addr = server.addr(); + let mut a = session("Ada"); + ex(&mut a, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 80})); + let p = publish(&mut a, dir.path(), addr); + let file = Invite::parse(&p.edit).unwrap().file; + let endpoint = Endpoint::plain(addr.to_string()); + let ask = |req: Admin| manage::request(&endpoint, &key(dir.path()), vec![req], &NetLimits::default(), Duration::from_secs(5)).unwrap().remove(0); + let AdminReply::Link(made) = ask(Admin::CreateLink { file: file.clone(), access: Access::Comment, label: "Client".into() }) else { panic!() }; + assert_eq!(made.link.access, Access::Comment); + let link = Invite { server: endpoint.clone(), file: file.clone(), token: made.token.clone() }.link(); + // Cy joins with it and comments; Ada sees the thread. + let mut c = join(&link, "Cy"); + pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1); + assert_eq!((&c.live_summary()["access"], &c.live_summary()["readOnly"]), (&json!("comment"), &json!(true))); + let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Bigger logo?"}))["id"].as_u64().unwrap(); + pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some()); + ex(&mut a, "comment.reply", json!({"id": thread, "text": "On it"})); + pump(&mut [&mut a, &mut c], "the reply at Cy", |s| s[1].doc().unwrap().comment(thread).is_some_and(|t| t.replies.len() == 1)); + ex(&mut c, "comment.resolve", json!({"id": thread})); + pump(&mut [&mut a, &mut c], "resolved at Ada", |s| s[0].doc().unwrap().comment(thread).is_some_and(|t| t.resolved)); + // Cy's app refuses anything else before it is sent. + let e = c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap_err().to_string(); + assert!(e.contains("Can comment"), "{e}"); + // A client that skips the app's check: its comment goes through, its edit is refused. + let mut raw = Raw::connect(addr); + let mut client = welcome(&mut raw, &link, &figcraft_collab::message::client_version()); + assert_eq!(client.access(), Access::Comment); + let mut d = client.document().clone(); + let page = d.first_page().unwrap(); + let id = d.new_comment_id(); + d.comments.push(Comment { + id, + page, + x: 1.0, + y: 1.0, + anchor: None, + first: Message::new("Raw", "Hi", 0), + resolved: false, + replies: vec![], + read_by: None, + }); + raw.send(&client.edit(&d).unwrap().unwrap()); + let ServerMsg::Changes(acc) = answer(&mut raw) else { panic!("refused") }; + client.receive(ServerMsg::Changes(acc), &figcraft_collab::NoRecompute).unwrap(); + let mut d = client.document().clone(); + d.node_mut(page).unwrap().name = "Sneaky".into(); + raw.send(&client.edit(&d).unwrap().unwrap()); + assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY)); + // So is a forged batch that hides an edit among comment changes. + let forged = Change::Put { entry: Entry::Comment(id), value: Json::of(&client.document().comments[0]).unwrap() }; + let rename = Change::SetDoc { prop: figcraft_collab::DocProp::Name, value: Json::of("Sneaky").unwrap() }; + raw.send(&ClientMsg::Changes { base_seq: 0, batch_id: 99, changes: vec![forged, rename] }); + assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY)); + pump(&mut [&mut a], "the raw comment, nothing else", |s| { + let d = s[0].doc().unwrap(); + d.comment(id).is_some() && d.node(page).is_some_and(|n| n.name != "Sneaky") && d.name != "Sneaky" + }); + // A client from before comment links gets in as a viewer. + let mut old = Raw::connect(addr); + assert_eq!(welcome(&mut old, &link, "0.12.1 signals hosting").access(), Access::View); + // And lists the link as a view link. + let mut m = Raw::connect(addr); + m.send(&ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key(dir.path()), client_version: "0.12.1".into() })); + assert!(matches!(m.next(), ServerMsg::Admin(r) if *r == AdminReply::Ready)); + m.send(&ClientMsg::Admin(Admin::Links { file: file.clone() })); + let ServerMsg::Admin(r) = m.next() else { panic!("no listing") }; + let AdminReply::Links(links) = *r else { panic!("{r:?}") }; + assert!(links.iter().all(|l| l.access != Access::Comment) && links.iter().any(|l| l.id == made.link.id && l.access == Access::View)); + let AdminReply::Links(links) = ask(Admin::Links { file }) else { panic!() }; + assert!(links.iter().any(|l| l.id == made.link.id && l.access == Access::Comment), "this build reads it"); + server.stop(); +} + +#[test] +fn sharing_online_offers_a_comment_code() { + let dir = TempDir::new("comment-code"); + let mut server = crate::start(&config(dir.path())).unwrap(); + let url = format!("ws://{}/?key={}", server.addr(), key(dir.path())); + let mut a = session("Ada"); + ex(&mut a, "collab.share", json!({"server": url})); + pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some() && s[0].live_summary()["share"]["shared"] == true); + let shared = a.live_summary()["share"].clone(); + let comment = shared["commentCode"].as_str().unwrap().to_string(); + assert!(comment != shared["editCode"] && comment != shared["viewCode"], "{shared}"); + assert_eq!(ex(&mut a, "collab.share", json!({"access": "comment"}))["code"], comment.as_str()); + // Someone types it: they can comment, not edit. + let mut c = session("Cy"); + ex(&mut c, "pref.publishServer", json!({"url": format!("ws://{}/", server.addr())})); + ex(&mut c, "collab.join", json!({"code": comment})); + pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1); + assert_eq!(c.live_summary()["access"], "comment"); + let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Looks good"}))["id"].as_u64().unwrap(); + pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some()); + assert!(c.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err()); + // Stopping revokes it with the others: Cy is out. + ex(&mut a, "collab.stop", json!({})); + pump(&mut [&mut a, &mut c], "Cy out", |s| !s[1].live().status.active() && s[0].live().peers.is_empty()); + server.stop(); +} diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index 746205d1..ce5eefdc 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -95,6 +95,8 @@ mod cli_tests; #[cfg(test)] mod codes_tests; #[cfg(test)] +mod comment_link_tests; +#[cfg(test)] mod directory_tests; #[cfg(test)] mod hardening_tests; diff --git a/apps/figcraft-server/src/owned_tests.rs b/apps/figcraft-server/src/owned_tests.rs index b4df0502..767b9e39 100644 --- a/apps/figcraft-server/src/owned_tests.rs +++ b/apps/figcraft-server/src/owned_tests.rs @@ -158,7 +158,7 @@ fn only_a_files_own_token_learns_about_it() { assert_eq!(list.len(), 5, "one answer per pair, in order"); let first = list[0].as_ref().expect("its own token"); assert_eq!(first.name, "Untitled"); - assert_eq!(first.links.len(), 4, "the owner's two links and the two made for others"); + assert_eq!(first.links.len(), 5, "the owner's two links and the three made for others"); assert!(first.expires.is_some()); assert!(list[1..].iter().all(Option::is_none), "a wrong token and a missing file read the same"); // Nothing asked: nothing answered; too many: refused, and the connection stays usable. diff --git a/crates/collab-net/src/exchange.rs b/crates/collab-net/src/exchange.rs index a01e9d59..b0d103ae 100644 --- a/crates/collab-net/src/exchange.rs +++ b/crates/collab-net/src/exchange.rs @@ -53,9 +53,7 @@ impl Exchange { /// `file` with its `owner` token. pub(crate) fn new(server: Endpoint, key: Option<&str>, file: &str, owner: &str, requests: Vec) -> Self { let open = match key { - Some(key) => { - ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() }) - } + Some(key) => ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: message::client_version() }), None => ClientMsg::OpenHosting(OpenHosting { protocol: PROTOCOL, client_version: message::client_version(), @@ -144,7 +142,7 @@ pub(crate) fn publish(job: Publish) -> Next { // The file's first links stay its owner's (this app joins with one): others get their // own, which stopping takes away again. let owner = published.owner.unwrap_or_default(); - let then: Box, String>) -> Next> = Box::new(move |made| { + let then: LinksThen = Box::new(move |made| { events.push(Event::Shared(made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string())))); Next::Done(events, Some(edit)) }); @@ -163,26 +161,45 @@ struct Sharing { create: bool, } -/// Revoke the links `s.revoke` names, then (to create) make new ones and ask for their codes on a -/// connection of their own; `then` gets the links. -fn share_links(s: Sharing, then: Box, String>) -> Next>) -> Next { +/// What [`share_links`] hands the links to. +type LinksThen = Box, String>) -> Next>; + +/// Revoke the links `s.revoke` names, then (to create) make new ones, then a comment link and their +/// codes, each on a connection of its own; `then` gets the links. +fn share_links(s: Sharing, then: LinksThen) -> Next { let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::link_requests(&s.file, &s.revoke, s.create)); Next::Ask( Box::new(ex), - Box::new(move |answers| { - let links = match answers.and_then(|a| requests::made_links(a.replies, s.create)) { - Ok(Some(links)) => links, - other => return then(other.map(|_| None)), - }; - let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links)); - Next::Ask( - Box::new(ex), - Box::new(move |codes| { - // A server from before join codes: the links are shared without them. - let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default(); - then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies)))) - }), - ) + Box::new(move |answers| match answers.and_then(|a| requests::made_links(a.replies, s.create)) { + Ok(Some(links)) => comment_link(s, links, then), + other => then(other.map(|_| None)), + }), + ) +} + +/// Ask for a comment link beside `links`, then for their codes. +fn comment_link(s: Sharing, mut links: requests::Links, then: LinksThen) -> Next { + let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::comment_request(&s.file)); + Next::Ask( + Box::new(ex), + Box::new(move |made| { + // A server from before comment links: the file is shared without one. + let made = made.map_err(|e| log::info!("live session: no comment link from {}: {e}", s.server.display())); + links.comment = made.ok().and_then(|a| requests::made_comment(a.replies)); + codes(s, links, then) + }), + ) +} + +/// Ask for the codes of `links`; `then` gets them with their codes. +fn codes(s: Sharing, links: requests::Links, then: LinksThen) -> Next { + let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links)); + Next::Ask( + Box::new(ex), + Box::new(move |codes| { + // A server from before join codes: the links are shared without them. + let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default(); + then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies)))) }), ) } diff --git a/crates/collab-net/src/exchange_tests.rs b/crates/collab-net/src/exchange_tests.rs index 0e38039a..85274e41 100644 --- a/crates/collab-net/src/exchange_tests.rs +++ b/crates/collab-net/src/exchange_tests.rs @@ -129,6 +129,11 @@ fn make_links(d: &mut Driver