Merge main into #1548

This commit is contained in:
bflatastic
2026-10-30 01:45:39 +07:00
6 changed files with 298 additions and 5 deletions
+2 -1
View File
@@ -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 (Thai, Lao, Khmer and Burmese lines break between words (#1219); 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 (Thai, Lao, Khmer and Burmese lines break between words (#1219); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). |
| 2026-10-30 | minor | M5, #405: a manual overlay can be placed on the canvas. While the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed and outlined where it opens, from the top-left of the layer that opens it (right below it without an offset), and dragging it moves it: the X and Y fields follow while dragging, and the drag is one undo step. Tests: `overlay_ghost` (ui-egui) |
| 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 |
| 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged |
| 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged |
@@ -10,6 +10,8 @@ use serde_json::{Value, json};
use crate::{FigApp, RightTab};
mod overlay_ghost;
const HANDLE_R: f32 = 7.0;
/// A connection being dragged out of a layer's handle.
@@ -159,6 +161,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
let open = crate::proto_panel::editor(ctx);
let hover = ctx.input(|i| i.pointer.hover_pos());
let dragging: Option<Drag> = ctx.data(|d| d.get_temp(drag_id()));
overlay_ghost::draw(app, painter, doc, page);
for nd in noodles(app, ctx, doc, page) {
let selected = sel.contains(&nd.source);
let is_open = open == Some((nd.source, nd.index));
@@ -233,6 +236,7 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool {
if drag.is_some() {
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
}
overlay_ghost::end(app, &ctx);
return false;
}
let (pos, pressed, down, released) = ctx.input(|i| {
@@ -243,6 +247,9 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool {
i.pointer.button_released(egui::PointerButton::Primary),
)
});
if drag.is_none() && overlay_ghost::input(app, &ctx, pos, pos.is_some_and(|p| app.canvas.rect.contains(p)) && resp.hovered()) {
return true;
}
let Ok(ds) = app.session.ds() else { return false };
let (doc, page, sel) = (ds.doc.clone(), ds.page, ds.selection.clone());
if let Some(mut d) = drag {
@@ -0,0 +1,283 @@
//! The ghost of a manually placed overlay: while the interaction editor has an Open overlay action
//! with Manual position open, the overlay frame shows on the canvas where it will open (its offset
//! from the top-left of the layer that opens it), and dragging the ghost moves it. The drag is one
//! undo step, and the action's X and Y fields follow it live.
use std::sync::Arc;
use egui::{Color32, CornerRadius, Id, Pos2, Rect, Stroke, StrokeKind, pos2};
use figcraft_doc::proto::{Action, MAX_ACTION_DEPTH, OverlayPosition};
use figcraft_doc::{Document, NodeId};
use serde_json::json;
use crate::FigApp;
use crate::proto_editor::{Slot, run_at};
/// The overlay thumbnail's longest side (pixels).
const GHOST_PX: f32 = 1024.0;
/// The undo step a drag makes (the label of the command it runs).
const LABEL: &str = "Edit prototype action";
/// The Manual overlay action being edited.
struct Manual {
slot: Slot,
action: Action,
dest: NodeId,
/// The trigger layer's top-left (page units).
origin: [f64; 2],
/// The overlay's top-left from `origin`; without one, right below the trigger layer.
offset: [f64; 2],
}
/// A ghost being dragged: the action at the press, the page point grabbed and the last offset set.
#[derive(Clone)]
struct Drag {
slot: Slot,
action: Action,
grab: [f64; 2],
start: [f64; 2],
last: [f64; 2],
}
fn drag_id() -> Id {
Id::unique("proto_overlay_ghost_drag")
}
/// The first Open overlay action with Manual position in `actions` (conditional blocks too); its
/// path is left in `path`.
fn find_manual(actions: &[Action], path: &mut Vec<usize>, depth: usize) -> Option<Action> {
for (i, a) in actions.iter().enumerate() {
path.push(i);
if matches!(a, Action::OpenOverlay { position: OverlayPosition::Manual, .. }) {
return Some(a.clone());
}
if let Action::Conditional { blocks } = a
&& depth < MAX_ACTION_DEPTH
&& let Some(found) = blocks.iter().enumerate().find_map(|(b, block)| {
let mut inner = vec![b];
let found = find_manual(&block.actions, &mut inner, depth + 1)?;
path.extend(inner);
Some(found)
})
{
return Some(found);
}
path.pop();
}
None
}
/// The Manual overlay of interaction `k` of `node`, if it has one.
fn manual(doc: &Document, node: NodeId, k: usize) -> Option<Manual> {
let it = doc.node(node)?.interactions.get(k)?;
let mut path = Vec::new();
let action = find_manual(&it.actions, &mut path, 0)?;
let Action::OpenOverlay { dest, offset, .. } = &action else { return None };
let trigger = doc.world_bounds(node)?;
let (dest, offset) = (*dest, offset.unwrap_or([0.0, trigger.height()]));
Some(Manual { slot: Slot { node, k, path }, action, dest, origin: [trigger.x0, trigger.y0], offset })
}
/// The Manual overlay the interaction editor has open on the shown page.
fn editing(app: &FigApp, ctx: &egui::Context, doc: &Document, page: NodeId) -> Option<Manual> {
let (node, k) = crate::proto_panel::editor(ctx)?;
if !super::active(app) || doc.page_of(node) != Some(page) {
return None;
}
manual(doc, node, k)
}
/// Screen rect of page rect (`x`, `y`, `w`, `h`).
fn screen(app: &FigApp, [x, y]: [f64; 2], w: f64, h: f64) -> Rect {
Rect::from_min_max(app.to_screen(x, y), app.to_screen(x + w, y + h))
}
/// Where the overlay frame opens, on screen.
fn ghost_rect(app: &FigApp, doc: &Document, m: &Manual) -> Option<Rect> {
let b = doc.world_bounds(m.dest)?;
Some(screen(app, [m.origin[0] + m.offset[0], m.origin[1] + m.offset[1]], b.width(), b.height()))
}
/// The overlay frame's thumbnail and the page area it shows (effects reach past the frame), kept
/// while the frame's layers stay the same: dragging edits only the trigger layer.
#[derive(Clone)]
struct Thumb {
doc: Arc<Document>,
dest: NodeId,
tex: Option<egui::TextureHandle>,
}
fn thumbnail(ctx: &egui::Context, doc: &Arc<Document>, dest: NodeId) -> Option<egui::TextureHandle> {
let id = Id::unique("proto_overlay_ghost_thumb");
let kept: Option<Thumb> = ctx.data(|d| d.get_temp(id));
let tex = match kept {
Some(t) if t.dest == dest && (Arc::ptr_eq(&t.doc, doc) || crate::assets_panel::same_layers(&t.doc, doc, dest)) => t.tex,
_ => crate::assets_panel::thumbnail(ctx, doc, dest, GHOST_PX),
};
ctx.data_mut(|d| d.insert_temp(id, Thumb { doc: doc.clone(), dest, tex: tex.clone() }));
tex
}
/// Draw the ghost of the Manual overlay being edited: the frame, dimmed, outlined in the accent.
pub(super) fn draw(app: &FigApp, painter: &egui::Painter, doc: &Arc<Document>, page: NodeId) {
let ctx = painter.ctx();
let Some(m) = editing(app, ctx, doc, page) else { return };
let Some(r) = ghost_rect(app, doc, &m) else { return };
let accent = crate::theme::canvas_accent(ctx);
let art = figcraft_render::export_bounds(doc, m.dest).zip(doc.world_bounds(m.dest));
match (thumbnail(ctx, doc, m.dest), art) {
(Some(tex), Some((e, b))) => {
let at = [m.origin[0] + m.offset[0] + e.x0 - b.x0, m.origin[1] + m.offset[1] + e.y0 - b.y0];
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
painter.image(tex.id(), screen(app, at, e.width(), e.height()), uv, Color32::WHITE.gamma_multiply(0.6));
}
_ => {
painter.rect_filled(r, CornerRadius::ZERO, accent.gamma_multiply(0.12));
}
}
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.5, accent), StrokeKind::Outside);
}
/// Move the dragged ghost so the grabbed point is under `p`.
fn drag_to(app: &mut FigApp, ctx: &egui::Context, mut d: Drag, p: Pos2) {
let at = app.to_page(p);
let o = [(d.start[0] + at.x - d.grab[0]).round(), (d.start[1] + at.y - d.grab[1]).round()].map(|v| v.clamp(-100_000.0, 100_000.0));
if o != d.last {
let mut action = d.action.clone();
if let Action::OpenOverlay { offset, .. } = &mut action {
*offset = Some(o);
}
run_at(app, &d.slot, "proto.action.set", json!({ "action": action }));
d.last = o;
}
ctx.data_mut(|m| m.insert_temp(drag_id(), d));
}
/// End a drag under way, as one undo step.
pub(super) fn end(app: &mut FigApp, ctx: &egui::Context) {
if ctx.data(|m| m.get_temp::<Drag>(drag_id())).is_some() {
ctx.data_mut(|m| m.remove::<Drag>(drag_id()));
let _ = app.session.commit_interaction();
}
}
/// Canvas input for the ghost: a press on it starts a drag, which follows the pointer until the
/// button is released. Returns true while it has the gesture.
pub(super) fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option<Pos2>, hovered: bool) -> bool {
let (pressed, down) = ctx.input(|i| (i.pointer.button_pressed(egui::PointerButton::Primary), i.pointer.primary_down()));
if let Some(d) = ctx.data(|m| m.get_temp::<Drag>(drag_id())) {
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
ctx.request_repaint();
match pos {
Some(p) if down => drag_to(app, ctx, d, p),
_ => end(app, ctx),
}
return true;
}
let Some(p) = pos.filter(|_| hovered) else { return false };
let Ok(ds) = app.session.ds() else { return false };
let (doc, page) = (ds.doc.clone(), ds.page);
let Some(m) = editing(app, ctx, &doc, page).filter(|m| ghost_rect(app, &doc, m).is_some_and(|r| r.contains(p))) else { return false };
ctx.set_cursor_icon(egui::CursorIcon::Grab);
if !pressed || app.session.begin_interaction(LABEL).is_err() {
return false;
}
let at = app.to_page(p);
let d = Drag { slot: m.slot, action: m.action, grab: [at.x, at.y], start: m.offset, last: m.offset };
ctx.data_mut(|m| m.insert_temp(drag_id(), d));
true
}
#[cfg(test)]
mod tests {
use egui::{Event, PointerButton, vec2};
use figcraft_doc::NodeId;
use figcraft_doc::proto::{Action, OverlayAnchor, OverlayPosition};
use figcraft_engine::Session;
use serde_json::json;
use super::{find_manual, ghost_rect, manual};
use crate::RightTab;
use crate::present_tests::{Ui, id};
fn overlay(dest: NodeId, position: OverlayPosition, offset: Option<[f64; 2]>) -> Action {
Action::OpenOverlay { dest, position, close_on_outside: true, background: None, offset, anchor: OverlayAnchor::Screen }
}
/// A button in a frame opening frame `b` as a Manual overlay 10, 20 from the button's corner,
/// with the interaction editor open on it.
fn scene() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
let mut ex = |c: &str, p: serde_json::Value| s.execute(c, &p).unwrap();
ex("frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}));
let btn = id(&ex("shape.rectangle", json!({"x": 40, "y": 30, "width": 80, "height": 30})));
let b = id(&ex("frame.create", json!({"x": 600, "y": 0, "width": 160, "height": 100})));
ex("proto.interaction.add", json!({"ids": [btn], "dest": b}));
ex("proto.action.set", json!({"ids": [btn], "index": 0, "path": [0], "action": overlay(b, OverlayPosition::Manual, Some([10.0, 20.0]))}));
ex("select.set", json!({"ids": [btn]}));
let mut u = Ui::new(s);
u.app.ui.right_tab = RightTab::Prototype;
u.run("view.zoomFit", json!({}));
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
u.frames(2, Vec::new);
(u, btn, b)
}
fn offset(u: &Ui, btn: NodeId) -> Option<[f64; 2]> {
let doc = u.app.session.doc().unwrap();
match doc.node(btn).unwrap().interactions[0].actions.first() {
Some(Action::OpenOverlay { offset, .. }) => *offset,
_ => None,
}
}
#[test]
fn dragging_the_ghost_moves_the_overlay_by_the_dragged_amount_in_one_undo_step() {
let (mut u, btn, _) = scene();
let doc = u.app.session.doc().unwrap().clone();
let r = ghost_rect(&u.app, &doc, &manual(&doc, btn, 0).unwrap()).unwrap();
let (from, to) = (r.center(), r.center() + vec2(60.0, 45.0));
let moved = [u.app.to_page(to).x - u.app.to_page(from).x, u.app.to_page(to).y - u.app.to_page(from).y];
let steps = u.app.session.undo_labels(100).len();
u.button(from, true);
for k in 1..=3 {
u.frame(vec![Event::PointerMoved(from + vec2(20.0, 15.0) * k as f32)]);
}
assert_eq!(offset(&u, btn), Some([10.0 + moved[0].round(), 20.0 + moved[1].round()]), "the X/Y follow while dragging");
u.frame(vec![Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Default::default() }]);
u.frames(2, Vec::new);
assert_eq!(offset(&u, btn), Some([10.0 + moved[0].round(), 20.0 + moved[1].round()]));
assert_eq!(u.app.session.undo_labels(100).len(), steps + 1, "one undo step");
u.app.session.undo().unwrap();
assert_eq!(offset(&u, btn), Some([10.0, 20.0]));
}
#[test]
fn without_an_offset_the_ghost_sits_right_below_the_trigger() {
let mut s = Session::new();
let btn = id(&s.execute("shape.rectangle", &json!({"x": 5, "y": 7, "width": 50, "height": 20})).unwrap());
let b = id(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 80})).unwrap());
s.execute("proto.interaction.add", &json!({"ids": [btn], "dest": b})).unwrap();
s.execute("proto.action.set", &json!({"ids": [btn], "index": 0, "path": [0], "action": overlay(b, OverlayPosition::Manual, None)})).unwrap();
let m = manual(&s.doc().unwrap().clone(), btn, 0).unwrap();
assert_eq!((m.origin, m.offset, m.dest), ([5.0, 7.0], [0.0, 20.0], b));
}
#[test]
fn only_a_manual_overlay_has_a_ghost_and_nested_ones_are_found() {
let d = NodeId(9);
let centred = overlay(d, OverlayPosition::Center, None);
let mut path = Vec::new();
assert!(find_manual(std::slice::from_ref(&centred), &mut path, 0).is_none());
assert!(path.is_empty());
let nested = Action::Conditional {
blocks: vec![
figcraft_doc::proto::ConditionalBlock { condition: None, actions: vec![centred.clone()] },
figcraft_doc::proto::ConditionalBlock { condition: None, actions: vec![Action::Back, overlay(d, OverlayPosition::Manual, None)] },
],
};
let found = find_manual(&[centred, nested], &mut path, 0);
assert!(matches!(found, Some(Action::OpenOverlay { position: OverlayPosition::Manual, .. })));
assert_eq!(path, vec![1, 1, 1]);
}
}
+1 -1
View File
@@ -171,7 +171,7 @@ fn drag_id() -> Id {
/// Whether component `id` has the same layers in two versions of a document (layers an edit
/// didn't touch stay shared between the versions, so this compares pointers, not contents).
fn same_layers(a: &Document, b: &Document, id: NodeId) -> bool {
pub(crate) fn same_layers(a: &Document, b: &Document, id: NodeId) -> bool {
let mut stack = vec![id];
let mut seen = 0;
while let Some(i) = stack.pop() {
+3 -2
View File
@@ -1,6 +1,6 @@
# Prototyping parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-29 · **Change:** patch (Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-30 · **Change:** patch (a manual overlay's position can be dragged on the canvas (#405); Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
**Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except
media control, every transition and easing exist; key/gamepad triggers take controller buttons,
@@ -50,7 +50,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Scroll to with an offset: the layer stops that far from the top-left (room for a fixed header) | **added** | `offset: [x, y]` on the action, X / Y fields in the interaction editor; `scroll_to_brings_the_layer_to_the_top_less_its_offset` (nested scrolling frames: the innermost keeps the offset; silent) |
| Open overlay: positions, background dimming, close on a click outside (on by default, can be turned off) | pass | `a_click_outside_an_overlay_closes_it_unless_turned_off`, `overlay_positions` |
| Overlays stack; swap overlay replaces the top one; close overlay closes it | pass | `overlays_stack_swap_close_and_go_with_navigation` |
| Manual overlay position relative to the hotspot | **added** | `offset: [x, y]` on the action, from the top-left of the layer that opens it (X / Y fields in the interaction editor; without one, below that layer); `present_delay_tests` |
| Manual overlay position relative to the hotspot | **added** | `offset: [x, y]` on the action, from the top-left of the layer that opens it (X / Y fields in the interaction editor, or drag the overlay's ghost on the canvas while the action is open, one undo step; without one, below that layer); `present_delay_tests`, `overlay_ghost` tests |
| Overlay position presets relative to the layer that opens it, inside or outside (not in Figma: [forum request](https://forum.figma.com/suggest-a-feature-11/set-overlay-position-relative-to-element-not-to-page-35366)) | **extra** | `anchor: triggerInside \| triggerOutside` on the open overlay action; Relative to (Whole screen / Trigger layer) and Inside / Outside in the interaction editor. Outside: top presets above the layer, bottom presets below it, edges lined up (a dropdown is Bottom left + Outside); Center centres it on the layer. No flipping at the screen edge. `.fig` export writes a manual offset; `proto_overlay_anchor_tests`, `present_overlay_anchor_tests`, MCP `overlay_anchor` |
| Delay on mouse enter / leave / down / up | **added** | `delayMs` on the interaction, a Delay field in the editor; fires once after the delay while its screen or overlay still shows (a pointer that leaves early: silent, it still fires); `mouse_triggers_wait_out_their_delay` |
| Open link | pass | always a new tab or browser window (opening in place would leave the prototype) |
@@ -105,6 +105,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | patch | A manual overlay can be placed on the canvas (#405): while the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed, outlined, where it opens from the top-left of the layer that opens it (right below that layer without an offset), and dragging it moves it there. The X and Y fields follow while dragging, and the drag is one undo step (`crates/ui-egui/src/canvas/proto_canvas/overlay_ghost.rs`). Numbers unchanged |
| 2026-10-29 | patch | The Open link notice covers mail links, and skipping it covers only links the viewer fired: before, a mail link opened the mail app at once, also from an "After delay" trigger, and while the notice was skipped any trigger opened any number of web links. Now every openable link waits behind the notice; with it skipped, a link the viewer's own click, press, drag or key fired opens at once, one per frame, while a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, which then offers no "Don't show this again". Nothing opens while a notice shows, and a press under way when it appears ends there: before, its release could still open a link or navigate behind the notice. The mail notice says the link opens the mail app to write a message (#1096, `crates/ui-egui/src/present_links_tests.rs`). Numbers unchanged |
| 2026-10-24 | patch | The Triggers row's Missing column no longer lists delays on mouse enter, leave, down and up, which landed on 2026-10-13 (`crates/engine/src/proto_delay_tests.rs`, `crates/ui-egui/src/present/delay_tests.rs`). Numbers unchanged |
| 2026-10-24 | patch | Open link to a website first shows a notice that it leaves the prototype, with the link, Open link, Cancel and "Don't show this again", which the desktop app saves with its preferences and the web build keeps until the page reloads (`proto.linkNotice` turns the notice back on); a second link to a website that fires while the notice shows is dropped, and a mail link still opens. Before, the player opened the link at once, also from an After delay trigger. Numbers unchanged |
+2 -1
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Thai, Lao, Khmer and Burmese lines break between words (#1219); 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 (Thai, Lao, Khmer and Burmese lines break between words (#1219); 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
@@ -76,6 +76,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). |
| 2026-10-30 | minor | M5, #405: a manual overlay's ghost shows on the canvas while its Open overlay action is open in the interaction editor, and dragging it sets the action's offset from the layer that opens it (X and Y follow, one undo step). Tests: `overlay_ghost` (ui-egui) |
| 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 |
| 2026-10-29 | minor | A marquee from anywhere in vector edit mode (#1508): a press on another layer used to leave edit mode at once, so a drag there moved that layer; it now draws a marquee that selects the points inside it. A click there still leaves edit mode and selects what it lands on. Numbers unchanged |
| 2026-10-29 | minor | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged |