M8: a control request that times out says whether it was applied (#1673) (#1692)

The UI starts a request through its gate (a new Running state) just before running it and never runs one the transport already cancelled. At the timeout the reply says it was dropped and not applied, or that it is still running and will take effect; a reply that just made it is delivered. Only screenshots and input blame the window.
This commit is contained in:
BFlat authored and GitHub committed 2026-10-31 01:37:09 +07:00
1 parent 9f4d1ce75b
commit b6499a295c
7 files changed
+138 -13

No files matched your search

+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-30 · **Change:** minor (a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused pastLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layLine truncated
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
@@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | M8, #1673: an attached client whose request ran past the control channel's 60 s timeout was told "timed out waiting for the UI: screenshots and input need the FigCraft window on screen" while the request went on and was applied. Now the UI marks a request started (a new `Running` state of its request gate) just before it runs it and never runs one the transport already gave up on. At the timeout the reply says which happened: not started, so dropped and not applied; or still running, so it will take effect and the document should be checked before retrying. A reply that arrives just as the timeout fires is still delivered. Only `ui.screenshot` and `ui.input` keep the window message. Tests: `control_server_tests` (a started request times out as still running, a cancelled one never starts) and `idle_tests::a_request_the_transport_gave_up_on_is_never_applied` (from a frame and from a hidden window). Numbers unchanged |
| 2026-10-30 | minor | M12, gap 24: a board keeps each ended vote's results in the file (the last 20), as the reference app keeps each voting session's results until they're deleted. A record holds the vote, its ballots, the names the voters went by and when it ended, so someone who has left a live session still shows by name. The Results popover lists them newest first, with arrows to earlier votes and Delete results (`board.vote {action: delete, index}`). Clear results still only takes the results on the board off it. `board.voteResults` lists `sessions`. The results are saved (`vote_history`; older files open without it, and a vote ended in one still shows), synced in live sessions (`DocProp::VoteHistory`), and like the vote they're not undo steps: undo and redo carry them over. Tests: `vote_history_tests`, `facilitate_tests` and `vote_history_ui_tests`. |
| 2026-10-30 | minor | M12, gap 24: an ended vote's results list who voted for each object and how many times ("Ada ×2, Bo"), as the reference app shows how each participant voted once a vote ends; while it runs, people still see only their own votes. `board.voteResults` gives them per result as `voters: [{voter, name, votes}]`. In a live session a voter is named as the session knows them, and someone who has left shows as such. The Voting row no longer asks for facilitator-only ending: the reference app's help says anyone in the file can end a vote, as here. Tests: `facilitate_tests` (offline and live, including a person who left) and `vote_voters_tests` (the Results popover). |
| 2026-10-30 | minor | M12, gap 24: while the stamp tool is over an object, or a stamp is dragged over one, that object is outlined (the canvas's hover outline): the one the stamp will stick to, found by the same rule as `board.stamp` and `settle_stamps`. Found while checking it: a drag dropped what it moved into the auto layout of a sticky or shape with text under the pointer, so a stamp dragged onto a sticky became part of its layout; board objects (stickies, shapes with text, tables, stamps, labels) now take no drops, which go to what is under them, and a stamp already inside its sticky from such a drop moves with it once. Checked in the app. Numbers unchanged. Tests: `board_connect_tests::the_object_a_stamp_will_stick_to_is_highlighted`, `board_tests::stamps_stick_to_what_they_land_on`. |
+25 -6
View File
@@ -28,6 +28,9 @@ enum WaitFailure {
Timeout,
/// The UI side dropped its sender before replying (the app is closing).
Disconnected,
/// The UI started the request before the timeout and is still running it: it takes effect
/// when it finishes, though nobody waits for its reply (#1673).
StillRunning,
}
/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses.
@@ -46,10 +49,30 @@ fn wait_for_reply(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGa
fn after_timeout(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate) -> Result<ControlReply, WaitFailure> {
match gate.cancel_if_open() {
RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected),
// It may have finished just now: its reply is the truth then.
RequestState::Running => rx.try_recv().map_err(|_| WaitFailure::StillRunning),
RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout),
}
}
/// What a request that got no reply is told. Only a request that needs a drawn frame can be held
/// up by the window being out of sight; any other was either never started (the app was busy, so
/// it was dropped and never applied) or is still running and will be applied (#1673).
fn timeout_message(failure: &WaitFailure, needs_frame: bool) -> &'static str {
match failure {
WaitFailure::Disconnected => "the FigCraft window closed before answering",
WaitFailure::StillRunning => {
"the app is still running this request after 60 s: it will take effect when it finishes, but its reply is lost. Check the document before retrying"
}
WaitFailure::Timeout if needs_frame => {
"timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"
}
WaitFailure::Timeout => {
"timed out after 60 s before the app started this request (it was busy, or its window isn't drawing): it was not applied"
}
}
}
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
target.retain(|request| !request.is_cancelled());
@@ -171,6 +194,7 @@ fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
continue;
}
let needs_frame = matches!(method.as_str(), "ui.screenshot" | "ui.input");
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() });
drop(pending);
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
@@ -181,12 +205,7 @@ fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
},
Err(WaitFailure::Timeout) => {
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
}
Err(WaitFailure::Disconnected) => {
json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"})
}
Err(failure) => json!({"id": id, "ok": false, "error": timeout_message(&failure, needs_frame)}),
}
}
Err(e) => {
+32
View File
@@ -162,6 +162,38 @@ fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision");
}
/// #1673: a request the UI already started can't be cancelled any more. Its timeout says it is
/// still running and will take effect, not that the window is hidden; one that finished just as
/// the timeout fired gets its real reply.
#[test]
fn a_started_request_times_out_as_still_running() {
let gate = RequestGate::new();
assert!(gate.start(), "an open request starts");
let (tx, rx) = mpsc::channel::<ControlReply>();
let Err(failure) = wait_for_reply(&rx, &gate, Duration::ZERO) else { panic!("no reply was sent") };
assert_eq!(failure, WaitFailure::StillRunning);
assert_eq!(gate.state(), RequestState::Running, "the timeout doesn't cancel a running request");
let message = timeout_message(&failure, false);
assert!(message.contains("will take effect") && !message.contains("window"), "{message}");
tx.send(ControlReply { id: json!(3), result: Ok(json!({"done": true})) }).unwrap();
assert_eq!(wait_for_reply(&rx, &gate, Duration::ZERO).unwrap().id, json!(3), "a reply that made it is the truth");
}
/// #1673: a request the transport gave up on before the UI started it never starts, and its
/// timeout says it wasn't applied; only requests that need a drawn frame blame the window.
#[test]
fn a_request_cancelled_before_it_started_never_runs() {
let gate = RequestGate::new();
let (_tx, rx) = mpsc::channel::<ControlReply>();
let Err(failure) = wait_for_reply(&rx, &gate, Duration::ZERO) else { panic!("no reply was sent") };
assert_eq!(failure, WaitFailure::Timeout);
assert!(!gate.start(), "the UI must not run a cancelled request");
assert_eq!(gate.state(), RequestState::Cancelled);
assert!(timeout_message(&failure, false).contains("not applied"));
assert!(!timeout_message(&failure, false).contains("window on screen"));
assert!(timeout_message(&failure, true).contains("window on screen"), "screenshots and input still need the window");
}
#[test]
fn idle_and_partial_frame_connections_expire() {
for bytes in [b"".as_slice(), b"{\"id\":"] {
+29 -2
View File
@@ -48,8 +48,8 @@ pub struct ControlReply {
/// Which side has finished a control request.
///
/// The transport and the UI race to settle one request: while it is `Open`, either may cancel it
/// (the transport's timeout) or commit it (the UI's final reply). Once settled, neither side may
/// change it.
/// (the transport's timeout) or commit it (the UI's final reply) or, for a request the UI answers
/// at once, start it (`Running`). Once settled or started, the transport can't cancel it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RequestState {
#[default]
@@ -58,6 +58,9 @@ pub enum RequestState {
Cancelled,
/// The UI wrote the file (if any) and queued the reply before releasing the gate.
Replied,
/// The UI started a request it answers at once (an edit, an inspection); the transport can no
/// longer cancel it, so a timeout now means "still running, it will take effect" (#1673).
Running,
}
/// The gate the transport's timeout and the UI's final reply share for one request.
@@ -102,6 +105,17 @@ impl RequestGate {
*state
}
/// Start a request the UI answers at once, unless the transport already gave up on it: then
/// it must not run at all. `false` when it was cancelled (or already settled).
pub fn start(&self) -> bool {
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if *state != RequestState::Open {
return false;
}
*state = RequestState::Running;
true
}
/// Run the final commit (file write and reply enqueue) only while the request is still open,
/// then mark it replied before releasing the gate. `None` means the transport cancelled it or
/// it was already committed; the closure must not have run then.
@@ -131,6 +145,13 @@ impl ControlRequest {
pub fn is_cancelled(&self) -> bool {
self.state.is_cancelled()
}
/// Mark the request started just before the UI runs it; `false` when the transport timed out
/// first, and then it must not run. `ui.input` is acknowledged by a later frame, so it stays
/// open (cancellable) like a screenshot.
fn begin(&self) -> bool {
self.method == "ui.input" || self.state.start()
}
}
/// One screenshot's completion slot.
@@ -519,6 +540,9 @@ pub fn background(app: &mut FigApp, ctx: &Context) {
let _ = r.reply.send(ControlReply { id: r.id, result: Err(error) });
continue;
}
if !r.begin() {
continue;
}
// Outside a frame, so outside the frame's guard: within the window's swap, a panic still
// ends in recovery copies and leaves every window its own state.
let res = crate::windows::as_window(app, target, |app| {
@@ -719,6 +743,9 @@ fn route_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now
admit(app, r, target, now);
return;
}
if !r.begin() {
return;
}
// Every control-channel gesture is fenced from the user's system clipboard (the flag is
// restored so a batch already in a frame's raw input keeps its own provenance).
let before = app.control_synthetic;
+41
View File
@@ -344,6 +344,47 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() {
assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL);
}
/// #1673: the UI runs a queued request only if the transport hasn't given up on it, and marks it
/// started first, so a timeout can tell "not applied" from "still running". Both from a frame and
/// from a hidden window's logic pass.
#[test]
fn a_request_the_transport_gave_up_on_is_never_applied() {
let (mut h, text) = document();
h.settle();
let (tx, rx) = std::sync::mpsc::channel();
let rename = |id: u64, name: &str| ControlRequest {
id: json!(id),
method: "engine.execute".into(),
params: json!({"command": "node.set", "params": {"ids": [text.clone()], "props": {"name": name}}}),
reply: tx.clone(),
state: Default::default(),
};
let node = figcraft_doc::NodeId(text.as_u64().unwrap());
let name = |h: &Harness| h.app.session.doc().unwrap().node(node).unwrap().name.clone();
let before = name(&h);
let mut hidden = RawInput::default();
hidden.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() });
for on_screen in [true, false] {
let gave_up = rename(1, "late");
gave_up.state.cancel_if_open();
let wanted = rename(2, "wanted");
let gate = wanted.state.clone();
h.app.control_queue.extend([gave_up, wanted]);
if on_screen {
h.frame(vec![]);
} else {
let ctx = h.ctx.clone();
let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx));
}
let reply = rx.try_recv().expect("the wanted request is answered");
assert_eq!(reply.id, json!(2), "on screen: {on_screen}");
assert!(rx.try_recv().is_err(), "nothing answers the cancelled one");
assert_eq!(gate.state(), crate::RequestState::Running, "started before it ran");
assert_eq!(name(&h), "wanted");
h.app.session.execute("node.set", &json!({"ids": [text.clone()], "props": {"name": before.clone()}})).unwrap();
}
}
/// Asserts a hidden window answers edits and inspection at once, while a screenshot is
/// parked with a deadline and times out once instead of waiting for a frame.
#[test]
+7 -3
View File
@@ -17,7 +17,11 @@ reply: {"id": 1, "ok": true, "result": ...}
```
Requests are queued and run on the UI thread at the start of the next frame, so replies reflect the
real UI state. A request that the UI doesn't answer within 60 s gets a timeout error. A line that is
real UI state. A request that the UI doesn't answer within 60 s gets a timeout error that says what
happened to it: one the app never started (it was busy, or its window wasn't drawing) was dropped
and not applied; one it had started (a long `batch`, say) can't be stopped, so the error says it is
still running and will take effect, and to check the document before retrying; only `ui.screenshot`
and `ui.input`, which need a drawn frame, point at the window being minimised or covered. A line that is
not JSON gets `{"id": null, "ok": false, "error": "bad JSON: …"}` and ends the connection (so an
HTTP request a web page makes the browser send here never runs). The implementation is in
`crates/ui-egui/src/control.rs` (methods) and `apps/figcraft/src/control_server.rs` (socket).
@@ -35,8 +39,8 @@ before the newline** and drops the connection on a longer one (see [MCP server](
Both the socket queue and the UI queue hold at most **8 requests** each, including requests waiting
for another window's frame. A full socket queue replies with `"control request queue is full"`
instead of dispatching the new request. These limits apply to the opt-in loopback control channel;
requests still queued after the UI timeout are discarded, including requests deferred to a hidden
window. A command already running is not interrupted.
requests still queued after the UI timeout are discarded (never run), including requests deferred to
a hidden window. A command already running is not interrupted: it takes effect when it finishes.
A minimised or covered window (the main one or another, see Windows below) draws no frames, but
still answers every method that acts on the app's state (`engine.execute`, `document.inspect`,
+2 -1
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lisLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#15Line truncated
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-31 | minor | M8, #1673: an attached client whose request ran past the control channel's 60 s timeout was told "timed out waiting for the UI: screenshots and input need the FigCraft window on screen" while the request went on and was applied. Now the UI marks a request started (a new `Running` state of its request gate) just before it runs it and never runs one the transport already gave up on. At the timeout the reply says which happened: not started, so dropped and not applied; or still running, so it will take effect and the document should be checked before retrying. A reply that arrives just as the timeout fires is still delivered. Only `ui.screenshot` and `ui.input` keep the window message. Tests: `control_server_tests` (a started request times out as still running, a cancelled one never starts) and `idle_tests::a_request_the_transport_gave_up_on_is_never_applied` (from a frame and from a hidden window). Numbers unchanged |
| 2026-10-30 | minor | M12, gap 24: a board keeps each ended vote's results in the file (the last 20), as the reference app keeps each voting session's results until they're deleted. A record holds the vote, its ballots, the names the voters went by and when it ended, so someone who has left a live session still shows by name. The Results popover lists them newest first, with arrows to earlier votes and Delete results (`board.vote {action: delete, index}`). Clear results still only takes the results on the board off it. `board.voteResults` lists `sessions`. The results are saved (`vote_history`; older files open without it, and a vote ended in one still shows), synced in live sessions (`DocProp::VoteHistory`), and like the vote they're not undo steps: undo and redo carry them over. Tests: `vote_history_tests`, `facilitate_tests` and `vote_history_ui_tests`. |
| 2026-10-30 | minor | M12, gap 24: an ended vote's results list who voted for each object and how many times ("Ada ×2, Bo"), as the reference app shows how each participant voted once a vote ends; while it runs, people still see only their own votes. `board.voteResults` gives them per result as `voters: [{voter, name, votes}]`. In a live session a voter is named as the session knows them, and someone who has left shows as such. The Voting row no longer asks for facilitator-only ending: the reference app's help says anyone in the file can end a vote, as here. Tests: `facilitate_tests` (offline and live, including a person who left) and `vote_voters_tests` (the Results popover). |
| 2026-10-30 | minor | M12, gap 24: the object a stamp will stick to is outlined under the stamp tool and while a stamp is dragged; board objects take no drops. Tests: `board_connect_tests`, `board_tests` |