Merge main into #1548

This commit is contained in:
bflatastic
2026-10-30 01:29:43 +07:00
44 changed files with 361 additions and 92 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); 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); 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-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 |
| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address, with the server key and owner tokens alike (100 renames in a row from one address: 100 served before, 60 now; another address is unaffected), and finds join codes through an index of their hashes instead of opening every link's code. Same results, same protocol. Tests: `admin_budget_tests`. Numbers unchanged |
+2 -2
View File
@@ -19,13 +19,13 @@ figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
# `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 }
serde_json = { 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"] }
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"] }
+39
View File
@@ -0,0 +1,39 @@
//! What the page keeps across reloads, in the browser's local storage: your name (Preferences ▸
//! Your name), so a reload or a later visit joins live sessions and comments as you.
use figcraft_engine::Session;
use serde_json::json;
/// Put back the name the page kept, checked as one typed in Preferences: the page's
/// storage is anyone's to change.
pub fn restore_name(session: &mut Session, kept: Option<String>) {
if let Some(name) = kept
&& let Err(e) = session.execute("pref.userName", &json!({"name": name}))
{
log::warn!("the kept name wasn't used: {e}");
}
}
#[cfg(test)]
mod tests {
use figcraft_engine::Session;
use serde_json::json;
use super::restore_name;
/// What one page kept, the next one restores; junk in the storage is cleaned like typing.
#[test]
fn the_name_survives_a_reload() {
let mut before = Session::new();
before.execute("pref.userName", &json!({"name": " Alice "})).unwrap();
let kept = before.prefs.user_name.clone();
let mut after = Session::new();
restore_name(&mut after, Some(kept));
assert_eq!(after.prefs.user_name, "Alice");
let mut fresh = Session::new();
restore_name(&mut fresh, None);
assert_eq!(fresh.prefs.user_name, Session::new().prefs.user_name, "nothing kept: the default");
restore_name(&mut fresh, Some(format!("Bob\u{7}{}", "x".repeat(500))));
assert!(fresh.prefs.user_name.starts_with("Bobx") && fresh.prefs.user_name.chars().count() == 64);
}
}
+3
View File
@@ -8,6 +8,7 @@
//! - no TCP control server (browsers can't listen on sockets);
//! - 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`);
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't
//! host one. A page served over `https://` can only reach sessions on the same computer
//! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to
@@ -24,6 +25,8 @@
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(any(target_arch = "wasm32", test))]
mod kept;
#[cfg(any(target_arch = "wasm32", test))]
mod query;
#[cfg(test)]
mod query_tests;
+14
View File
@@ -6,6 +6,8 @@ use wasm_bindgen::JsCast as _;
const CANVAS_ID: &str = "figcraft_canvas";
const LOADING_ID: &str = "figcraft_loading";
/// The local storage key your name is kept under ([`crate::kept`]).
const NAME_KEY: &str = "figcraft.userName";
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
@@ -36,6 +38,7 @@ 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()));
// Live sessions: the browser joins sessions a desktop app hosts; whatever arrives
// on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
@@ -67,6 +70,11 @@ pub fn start() {
});
}
/// The page's local storage (`None` where the browser keeps none, e.g. storage turned off).
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
}
fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
@@ -153,6 +161,12 @@ impl Services for WebServices {
fn reduce_motion(&self) -> bool {
self.reduce_motion.as_ref().is_some_and(web_sys::MediaQueryList::matches)
}
/// Only your name is kept ([`crate::kept`]): the rest starts afresh with each page.
fn save_prefs(&mut self, prefs: &figcraft_engine::Prefs) {
if let Some(Err(e)) = local_storage().map(|s| s.set_item(NAME_KEY, &prefs.user_name)) {
log::warn!("couldn't keep your name: {e:?}");
}
}
/// The browser's language (`navigator.language`).
fn system_language(&self) -> Option<String> {
web_sys::window().and_then(|w| w.navigator().language())
+2
View File
@@ -63,6 +63,8 @@ mod links_tests;
#[cfg(test)]
mod loopback_tests;
#[cfg(test)]
mod rename_tests;
#[cfg(test)]
mod session_tests;
#[cfg(test)]
mod sessions_tests;
+34
View File
@@ -0,0 +1,34 @@
//! Your name changed in Preferences while in a live session: everyone there sees the new one,
//! people who join later too, and a rename made while joining arrives once the host reads it
//! (#1380).
use serde_json::json;
use crate::session_tests::{host_and_join, pump, session};
#[test]
fn a_rename_reaches_everyone_in_the_session() {
let (mut host, mut guest, hana, ada) = host_and_join();
pump(&mut host, &mut guest, "both told signals work", |h, g| h.live().signals && g.live().signals);
host.message = None;
guest.execute("pref.userName", &json!({"name": "Bob"})).unwrap();
assert_eq!(guest.live().info.as_ref().unwrap().name, "Bob", "you see it at once");
pump(&mut host, &mut guest, "the guest's new name at the host", |h, _| h.live().peers[&ada].name == "Bob");
assert_eq!(host.message, None, "a rename isn't announced as someone joining");
host.execute("pref.userName", &json!({"name": "Kim"})).unwrap();
pump(&mut host, &mut guest, "the host's new name at the guest", |_, g| g.live().peers[&hana].name == "Kim");
assert_eq!(guest.live().info.as_ref().unwrap().name, "Bob", "the server confirmed the guest's");
// Renamed while joining: the hello had the old name, the new one follows once signals work.
let mut late = session("Lin");
late.execute("collab.join", &json!({"link": host.live().info.as_ref().unwrap().link.clone()})).unwrap();
late.execute("pref.userName", &json!({"name": "Lou"})).unwrap();
pump(&mut host, &mut late, "the late joiner's name", |h, l| {
h.live().peers.values().any(|p| p.name == "Lou") && l.live().info.as_ref().is_some_and(|i| i.name == "Lou")
});
let names: Vec<&str> = late.live().peers.values().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"Kim") && names.contains(&"Bob"), "newcomers get the new names: {names:?}");
// Blank goes back to the default for comments; the session keeps the name it shows.
guest.execute("pref.userName", &json!({"name": " "})).unwrap();
assert_eq!(guest.live().info.as_ref().unwrap().name, "Bob");
host.execute("collab.stop", &json!({})).unwrap();
}
+3 -3
View File
@@ -12,7 +12,7 @@ use serde_json::{Value, json};
use crate::NetLink;
use crate::loopback_tests::quick;
fn session(name: &str) -> Session {
pub(crate) fn session(name: &str) -> Session {
let mut s = Session::new();
s.live.install(Box::new(NetLink::new(Arc::new(|| {})).with_guest_config(quick)));
s.execute("pref.userName", &json!({"name": name})).unwrap();
@@ -20,7 +20,7 @@ fn session(name: &str) -> Session {
}
/// Pump both sessions (as their UIs would every frame) until `f` holds.
fn pump(a: &mut Session, b: &mut Session, what: &str, f: impl Fn(&Session, &Session) -> bool) {
pub(crate) fn pump(a: &mut Session, b: &mut Session, what: &str, f: impl Fn(&Session, &Session) -> bool) {
let start = Instant::now();
loop {
a.live_poll();
@@ -35,7 +35,7 @@ fn pump(a: &mut Session, b: &mut Session, what: &str, f: impl Fn(&Session, &Sess
}
}
fn host_and_join() -> (Session, Session, u32, u32) {
pub(crate) fn host_and_join() -> (Session, Session, u32, u32) {
let mut host = session("Hana");
let mut guest = session("Ada");
let r = host.execute("collab.host", &json!({"port": 0})).unwrap();
+2
View File
@@ -149,6 +149,8 @@ mod owned_tests;
#[cfg(test)]
mod reconnect_tests;
#[cfg(test)]
mod rename_tests;
#[cfg(test)]
mod server_tests;
#[cfg(test)]
mod signal_tests;
+21 -4
View File
@@ -134,9 +134,11 @@ pub struct EditingText {
/// ignored: a server drops them, a client skips them.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signal {
/// [`Signal::CHAT`], [`Signal::SPOTLIGHT`] or (server to client) [`Signal::READY`].
/// [`Signal::CHAT`], [`Signal::SPOTLIGHT`], (client to server) [`Signal::NAME`] or (server to
/// client) [`Signal::READY`].
pub kind: String,
/// Chat: the message (empty: closed). Spotlight: `"on"`, or empty when it stopped.
/// Chat: the message (empty: closed). Spotlight: `"on"`, or empty when it stopped. Name: the
/// new name.
pub text: String,
}
@@ -147,6 +149,11 @@ impl Signal {
pub const SPOTLIGHT: &str = "spotlight";
/// From the server, about the client itself, right after its welcome: signals may be sent.
pub const READY: &str = "ready";
/// To the server: the client was renamed. The server keeps the new name and tells everyone,
/// the client too, with its [`ServerMsg::PeerJoined`] (it isn't passed on as a signal).
pub const NAME: &str = "name";
/// Longest name shown to others (characters).
pub const MAX_NAME: usize = 64;
/// Longest chat message (characters).
pub const MAX_CHAT: usize = 100;
/// Longest signal on the wire before it is cleaned (bytes of kind and text together).
@@ -164,14 +171,19 @@ impl Signal {
Signal { kind: Signal::READY.into(), text: String::new() }
}
pub fn name(name: &str) -> Signal {
Signal { kind: Signal::NAME.into(), text: name.into() }
}
/// A spotlight that is on.
pub fn on(&self) -> bool {
self.text == "on"
}
/// Untrusted input made safe to pass on or show: `None` for an unknown kind, a malformed
/// spotlight or anything oversized; a chat's control characters become spaces and it is cut to
/// [`MAX_CHAT`](Self::MAX_CHAT) characters (an all-blank one is empty: closed).
/// spotlight, a blank name or anything oversized; a chat's control characters become spaces and
/// it is cut to [`MAX_CHAT`](Self::MAX_CHAT) characters (an all-blank one is empty: closed); a
/// name is trimmed, loses its control characters and is cut to [`MAX_NAME`](Self::MAX_NAME).
pub fn clean(self) -> Option<Signal> {
if self.kind.len() + self.text.len() > Signal::MAX_BYTES {
return None;
@@ -183,6 +195,11 @@ impl Signal {
}
Signal::SPOTLIGHT if self.text.is_empty() || self.on() => Some(self),
Signal::READY if self.text.is_empty() => Some(self),
Signal::NAME => {
let name: String = self.text.trim().chars().filter(|c| !c.is_control()).take(Signal::MAX_NAME).collect();
let name = name.trim_end();
(!name.is_empty()).then(|| Signal::name(name))
}
_ => None,
}
}
+44
View File
@@ -0,0 +1,44 @@
//! A client renamed in a session (a `name` signal): the server keeps the new name and tells
//! everyone with the client's `PeerJoined`; newcomers' welcomes list it (#1380).
use figcraft_doc::Document;
use crate::message::{ClientMsg, Hello, Peer, ServerMsg, Signal, client_version};
use crate::{Client, Limits, Outgoing, Server};
fn hello(name: &str) -> Hello {
let ClientMsg::Hello(h) = Client::hello("f", "", name, "#FF8800") else { panic!("not a hello") };
Hello { client_version: client_version(), ..h }
}
fn rename(s: &mut Server, from: u32, name: &str) -> Vec<Outgoing> {
s.receive(from, ClientMsg::Signal(Signal::name(name)))
}
#[test]
fn the_new_name_goes_to_everyone_and_to_newcomers() {
let mut s = Server::new(Document::new("Shared"), "", Limits::default());
let (ada, _) = s.join(hello("Ada")).unwrap();
s.join(hello("Hana")).unwrap();
let out = rename(&mut s, ada, " Bob\u{7} ");
let bob = Peer { client_id: ada, name: "Bob".into(), color: "#FF8800".into() };
assert_eq!(out, vec![Outgoing::All(ServerMsg::PeerJoined(bob))], "cleaned, to everyone, Ada too");
let (_, out) = s.join(hello("Lin")).unwrap();
let Some(Outgoing::To(_, ServerMsg::Welcome { peers, .. })) = out.first() else { panic!("no welcome") };
assert!(peers.iter().any(|p| p.client_id == ada && p.name == "Bob"));
// The same name, a blank one, a stranger's and a name past the server's limit change nothing.
assert!(rename(&mut s, ada, "Bob").is_empty());
assert!(rename(&mut s, ada, " \n ").is_empty());
assert!(!rename(&mut s, 999, "Eve").iter().any(|o| matches!(o, Outgoing::All(ServerMsg::PeerJoined(_)))));
let mut tight = Server::new(Document::new("Shared"), "", Limits { max_name: 7, ..Limits::default() });
let (id, _) = tight.join(hello("Ada")).unwrap();
assert!(rename(&mut tight, id, "Long name").is_empty());
assert_eq!(rename(&mut tight, id, "Bo").len(), 1);
}
#[test]
fn a_name_is_cut_to_the_longest_shown() {
let long = "é".repeat(Signal::MAX_NAME + 10);
assert_eq!(Signal::name(&long).clean().map(|s| s.text.chars().count()), Some(Signal::MAX_NAME));
assert_eq!(Signal::name("\t").clean(), None);
}
+12
View File
@@ -418,6 +418,9 @@ impl Server {
// current spotlight.
ClientMsg::Signal(sig) => {
let Some(sig) = sig.clean().filter(|s| s.kind != Signal::READY) else { return Vec::new() };
if sig.kind == Signal::NAME {
return self.rename(from, sig.text);
}
if sig.kind == Signal::SPOTLIGHT {
if sig.on() {
self.spotlight = Some(from);
@@ -447,6 +450,15 @@ impl Server {
}
}
/// Client `from` is called `name` from now on (cleaned): everyone, itself too, hears it as its
/// `PeerJoined`, and newcomers' welcomes list it so.
fn rename(&mut self, from: u32, name: String) -> Vec<Outgoing> {
let max = self.limits.max_name;
let Some(peer) = self.peers.get_mut(&from).filter(|p| p.name != name && name.chars().count() <= max) else { return Vec::new() };
peer.name = name;
vec![Outgoing::All(ServerMsg::PeerJoined(peer.clone()))]
}
/// Keep an accepted batch in the log (dropping the oldest beyond the limits).
fn remember(&mut self, acc: Accepted) {
self.log_changes += acc.changes.len();
+13 -6
View File
@@ -394,12 +394,19 @@ pub fn specs() -> Vec<CommandSpec> {
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
Ok(json!({"small": s.prefs.nudge_small, "big": s.prefs.nudge_big}))
}),
spec!("pref.userName", "Your name in comments", "{name (blank: the default, \"You\")}", false, |s, p| {
let name = req_string("pref.userName", p, "name")?;
let name: String = name.trim().chars().filter(|c| !c.is_control()).take(64).collect();
s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name };
Ok(json!({"name": s.prefs.user_name}))
}),
spec!(
"pref.userName",
"Your name",
"{name (blank: the default: \"You\" on comments, \"Guest N\" in live sessions)} → {name}: what your comments are signed with and others see you as in live sessions; the live sessions you're in show the new name at once (blank keeps the name they gave you until you join again)",
false,
|s, p| {
let name = req_string("pref.userName", p, "name")?;
let name: String = name.trim().chars().filter(|c| !c.is_control()).take(crate::live::MAX_NAME).collect();
s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name };
s.live_rename();
Ok(json!({"name": s.prefs.user_name}))
}
),
spec!(
"pref.publishServer",
"Server for online sharing",
+47 -15
View File
@@ -2,7 +2,7 @@ use super::host::{clean_hosting, clean_shared};
use super::types::{Event, PageInfo, Role, SessionId, Status};
use super::{
Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal,
ToolId, VIEW_ONLY, editing_hint, editing_of, sanitize,
ToolId, VIEW_ONLY, display_name, editing_hint, editing_of, sanitize,
};
/// What a live session's document holding components that contain each other's instances shows
@@ -11,6 +11,22 @@ fn loop_notice(names: &str) -> String {
format!("Components contain each other's instances ({names}): those instances stop where they would show themselves")
}
/// A session that starts reconnecting forgets who's here (that comes again with the next welcome;
/// edits wait for it), and the host's signal support until it says it reads them again (a
/// spotlight of yours goes out again then).
fn reset_for_reconnect(live: &mut Live) {
live.peers.clear();
live.presence.clear();
live.sent = None;
live.signals = false;
live.chats.clear();
live.chat.clear();
live.spotlight = None;
if let Some(c) = live.client.as_mut() {
c.disconnect();
}
}
impl Session {
/// The active document (`None`: no document is open).
pub(super) fn tab(&self) -> Option<u64> {
@@ -139,20 +155,8 @@ impl Session {
self.live.end(id);
}
Event::Status(st) => {
// Who's here comes again with the next welcome; edits wait for it.
if let Status::Reconnecting { .. } = st {
live.peers.clear();
live.presence.clear();
live.sent = None;
// Until the host says it reads them again (a spotlight of yours goes out
// again then).
live.signals = false;
live.chats.clear();
live.chat.clear();
live.spotlight = None;
if let Some(c) = live.client.as_mut() {
c.disconnect();
}
reset_for_reconnect(live);
}
live.status = st;
}
@@ -173,7 +177,10 @@ impl Session {
live.sent = None;
}
Event::PeerJoined(p) => {
self.message = Some(format!("{} joined{behind}", p.name));
// Someone already here was renamed: no newcomer to announce.
if !live.peers.contains_key(&p.client_id) {
self.message = Some(format!("{} joined{behind}", p.name));
}
live.peers.insert(p.client_id, p);
}
Event::PeerLeft(peer) => {
@@ -246,6 +253,9 @@ impl Session {
if live.spotlighting {
self.live.send(id, [ClientMsg::Signal(Signal::spotlight(true))]);
}
// Renamed while away, or before the session read signals: the host's hello had
// the old name.
self.live_send_name(id);
}
(Signal::CHAT, Some(_)) if sig.text.is_empty() => {
live.chats.remove(&peer);
@@ -272,6 +282,28 @@ impl Session {
}
}
/// Your name from Preferences changed: every session you're in shows you by it.
pub(crate) fn live_rename(&mut self) {
let ids: Vec<SessionId> = self.live.sessions.keys().copied().collect();
for id in ids {
self.live_send_name(id);
}
}
/// Tell session `id` your name from Preferences when it shows you by another one. The default
/// name isn't sent: the name the session gave you ("Guest N") stays. A host older than
/// renaming ignores it.
fn live_send_name(&mut self, id: SessionId) {
let name = display_name(&self.prefs);
let Some(live) = self.live.sessions.get_mut(&id) else { return };
if name.is_empty() || !live.signals {
return;
}
let Some(info) = live.info.as_mut().filter(|i| i.name != name) else { return };
info.name.clone_from(&name);
self.live.send(id, [ClientMsg::Signal(Signal::name(&name))]);
}
/// Why cursor chat or a spotlight can't go out now, if it can't.
pub(super) fn signal_blocker(&self) -> Option<&'static str> {
let live = self.live();
+1 -1
View File
@@ -18,7 +18,7 @@ pub const DEFAULT_SERVER: &str = match option_env!("FIGCRAFT_DEFAULT_SERVER") {
pub const MAX_SELECTION: usize = 10_000;
/// Longest name shown to others (characters).
pub const MAX_NAME: usize = 64;
pub const MAX_NAME: usize = Signal::MAX_NAME;
/// The colours people get in a session, in the order the host hands them out (`#RRGGBB`, white
/// text reads on each).
+1 -2
View File
@@ -1645,7 +1645,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "تبقى نسختك من الملف مفتوحة"
"Your copy stays open; the file stays on the server" = "تبقى نسختك مفتوحة؛ ويبقى الملف على الخادم"
"Your name" = "اسمك"
"Your name in comments" = "اسمك في التعليقات"
"Your system asks for reduced motion: animations stay off." = "يطلب نظامك تقليل الحركة؛ تبقى التحريكات متوقفة."
"Z" = "Z"
"Z to A" = "من Z إلى A"
@@ -2196,7 +2195,7 @@ status = "draft — needs native review"
"pref.showDimensions" = "إظهار الأبعاد على الكائنات"
"pref.startOnHome" = "البدء من الشاشة الرئيسية"
"pref.uiScale" = "حجم الواجهة"
"pref.userName" = "اسمك في التعليقات"
"pref.userName" = "اسمك"
"proto.action.add" = "إضافة إجراء للنموذج التفاعلي"
"proto.action.move" = "إعادة ترتيب إجراء النموذج التفاعلي"
"proto.action.remove" = "إزالة إجراء النموذج التفاعلي"
-1
View File
@@ -1589,7 +1589,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "ফাইলের আপনার কপিটি খোলা থাকে"
"Your copy stays open; the file stays on the server" = "আপনার কপি খোলা থাকে; ফাইলটি সার্ভারেই থাকে"
"Your name" = "আপনার নাম"
"Your name in comments" = "মন্তব্যে আপনার নাম"
"Your system asks for reduced motion: animations stay off." = "আপনার সিস্টেম কম মোশনের অনুরোধ জানায়: অ্যানিমেশন বন্ধ থাকে।"
"Z" = "Z"
"Z to A" = "Z থেকে A"
+1 -2
View File
@@ -1555,7 +1555,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Vaše kopie souboru zůstane otevřená"
"Your copy stays open; the file stays on the server" = "Vaše kopie zůstane otevřená; soubor zůstane na serveru"
"Your name" = "Vaše jméno"
"Your name in comments" = "Vaše jméno v komentářích"
"Your system asks for reduced motion: animations stay off." = "Váš systém požaduje omezení pohybu: animace zůstanou vypnuté."
"Z" = "Z"
"Z to A" = "Od Z do A"
@@ -2088,7 +2087,7 @@ status = "draft — needs native review"
"pref.reduceMotion" = "Omezit pohyb"
"pref.renameDuplicates" = "Přejmenovávat duplikované vrstvy"
"pref.showDimensions" = "Zobrazit rozměry objektů"
"pref.userName" = "Vaše jméno v komentářích"
"pref.userName" = "Vaše jméno"
"proto.action.add" = "Přidat akci prototypu"
"proto.action.move" = "Změnit pořadí akce prototypu"
"proto.action.remove" = "Odebrat akci prototypu"
+1 -2
View File
@@ -1516,7 +1516,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Ihre Kopie der Datei bleibt geöffnet"
"Your copy stays open; the file stays on the server" = "Ihre Kopie bleibt geöffnet; die Datei bleibt auf dem Server"
"Your name" = "Ihr Name"
"Your name in comments" = "Ihr Name in Kommentaren"
"Your system asks for reduced motion: animations stay off." = "Ihr System verlangt reduzierte Bewegung: Animationen bleiben aus."
"Z" = "Z"
"Zoom" = "Zoom"
@@ -2042,7 +2041,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "Duplizierte Ebenen umbenennen"
"pref.showDimensions" = "Abmessungen an Objekten anzeigen"
"pref.startOnHome" = "Mit Start beginnen"
"pref.userName" = "Ihr Name in Kommentaren"
"pref.userName" = "Ihr Name"
"proto.action.add" = "Prototyp-Aktion hinzufügen"
"proto.action.move" = "Prototyp-Aktion neu anordnen"
"proto.action.remove" = "Prototyp-Aktion entfernen"
-1
View File
@@ -1656,7 +1656,6 @@ name = "English"
"Your copy of the file stays open" = "Your copy of the file stays open"
"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 name in comments" = "Your name in comments"
"Your system asks for reduced motion: animations stay off." = "Your system asks for reduced motion: animations stay off."
"Z" = "Z"
"Z to A" = "Z to A"
+1 -2
View File
@@ -1633,7 +1633,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Tu copia del archivo sigue abierta"
"Your copy stays open; the file stays on the server" = "Tu copia sigue abierta; el archivo se queda en el servidor"
"Your name" = "Tu nombre"
"Your name in comments" = "Tu nombre en los comentarios"
"Your system asks for reduced motion: animations stay off." = "Tu sistema pide reducir el movimiento: las animaciones siguen desactivadas."
"Z" = "Z"
"Z to A" = "De la Z a la A"
@@ -2185,7 +2184,7 @@ status = "draft — needs native review"
"pref.showDimensions" = "Mostrar dimensiones en los objetos"
"pref.startOnHome" = "Empezar en Inicio"
"pref.uiScale" = "Tamaño de la interfaz"
"pref.userName" = "Tu nombre en los comentarios"
"pref.userName" = "Tu nombre"
"proto.action.add" = "Añadir acción de prototipo"
"proto.action.move" = "Reordenar acción de prototipo"
"proto.action.remove" = "Quitar acción de prototipo"
+1 -2
View File
@@ -1594,7 +1594,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Oma kopiosi tiedostosta pysyy auki"
"Your copy stays open; the file stays on the server" = "Oma kopiosi pysyy auki, ja tiedosto jää palvelimelle"
"Your name" = "Nimesi"
"Your name in comments" = "Nimesi kommenteissa"
"Your system asks for reduced motion: animations stay off." = "Järjestelmäsi pyytää vähennettyä liikettä. Animaatiot pidetään pois käytöstä."
"Z" = "Z"
"Z to A" = "Z:sta A:han"
@@ -2134,7 +2133,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "Nimeä monistetut tasot uudelleen"
"pref.showDimensions" = "Näytä objektien mitat"
"pref.startOnHome" = "Aloita Aloitus-näkymästä"
"pref.userName" = "Nimesi kommenteissa"
"pref.userName" = "Nimesi"
"proto.action.add" = "Lisää prototyyppitoiminto"
"proto.action.move" = "Muuta prototyyppitoimintojen järjestystä"
"proto.action.remove" = "Poista prototyyppitoiminto"
+1 -2
View File
@@ -1597,7 +1597,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Votre copie du fichier reste ouverte"
"Your copy stays open; the file stays on the server" = "Votre copie reste ouverte ; le fichier reste sur le serveur"
"Your name" = "Votre nom"
"Your name in comments" = "Votre nom dans les commentaires"
"Your system asks for reduced motion: animations stay off." = "Votre système demande de réduire les animations : elles restent désactivées."
"Z" = "Z"
"Z to A" = "Z à A"
@@ -2136,7 +2135,7 @@ status = "draft — needs native review"
"pref.showDimensions" = "Afficher les dimensions sur les objets"
"pref.startOnHome" = "Démarrer sur l’Accueil"
"pref.uiScale" = "Taille de l’interface"
"pref.userName" = "Votre nom dans les commentaires"
"pref.userName" = "Votre nom"
"proto.action.add" = "Ajouter une action de prototype"
"proto.action.move" = "Réordonner l’action de prototype"
"proto.action.remove" = "Retirer l’action de prototype"
+1 -2
View File
@@ -1559,7 +1559,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Salinan file Anda tetap terbuka"
"Your copy stays open; the file stays on the server" = "Salinan Anda tetap terbuka; file tetap ada di server"
"Your name" = "Nama Anda"
"Your name in comments" = "Nama Anda di komentar"
"Your system asks for reduced motion: animations stay off." = "Sistem Anda meminta gerakan dikurangi: animasi tetap nonaktif."
"Z" = "Z"
"Z to A" = "Z ke A"
@@ -2092,7 +2091,7 @@ status = "draft — needs native review"
"pref.reduceMotion" = "Kurangi gerakan"
"pref.renameDuplicates" = "Ganti nama layer duplikat"
"pref.showDimensions" = "Tampilkan dimensi pada objek"
"pref.userName" = "Nama Anda di komentar"
"pref.userName" = "Nama Anda"
"proto.action.add" = "Tambah aksi prototype"
"proto.action.move" = "Ubah urutan aksi prototype"
"proto.action.remove" = "Hapus aksi prototype"
+1 -2
View File
@@ -1533,7 +1533,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "La tua copia del file resta aperta"
"Your copy stays open; the file stays on the server" = "La tua copia resta aperta; il file resta sul server"
"Your name" = "Il tuo nome"
"Your name in comments" = "Il tuo nome nei commenti"
"Your system asks for reduced motion: animations stay off." = "Il tuo sistema richiede movimento ridotto: le animazioni restano disattivate."
"Z" = "Z"
"Z to A" = "Dalla Z alla A"
@@ -2049,7 +2048,7 @@ status = "draft — needs native review"
"pref.showDimensions" = "Mostra dimensioni sugli oggetti"
"pref.startOnHome" = "Avvia dalla schermata Home"
"pref.uiScale" = "Dimensione dell'interfaccia"
"pref.userName" = "Il tuo nome nei commenti"
"pref.userName" = "Il tuo nome"
"proto.action.add" = "Aggiungi azione del prototipo"
"proto.action.move" = "Riordina azione del prototipo"
"proto.action.remove" = "Rimuovi azione del prototipo"
+1 -2
View File
@@ -1578,7 +1578,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "あなたのファイルのコピーは開いたまま"
"Your copy stays open; the file stays on the server" = "手元のコピーは開いたまま。ファイルはサーバーに残る"
"Your name" = "あなたの名前"
"Your name in comments" = "コメントに表示する名前"
"Your system asks for reduced motion: animations stay off." = "システムで動きを減らす設定がオンのため、アニメーションはオフのまま。"
"Z" = "Z"
"Z to A" = "降順(Z→A)"
@@ -2114,7 +2113,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "複製したレイヤーの名前を変更"
"pref.showDimensions" = "オブジェクトに寸法を表示"
"pref.startOnHome" = "起動時にホームを表示"
"pref.userName" = "コメントに表示する名前"
"pref.userName" = "あなたの名前"
"proto.action.add" = "プロトタイプのアクションを追加"
"proto.action.move" = "プロトタイプのアクションの順序を変更"
"proto.action.remove" = "プロトタイプのアクションを削除"
+1 -2
View File
@@ -1615,7 +1615,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Your copy of the file stays open" = "თქვენი ფაილის ასლი ღია რჩება"
"Your copy stays open; the file stays on the server" = "თქვენი ასლი ღია რჩება; ფაილი სერვერზე რჩება"
"Your name" = "თქვენი სახელი"
"Your name in comments" = "თქვენი სახელი კომენტარებში"
"Your system asks for reduced motion: animations stay off." = "თქვენი სისტემა ითხოვს მოძრაობის შემცირებას: ანიმაციები გამორთული რჩება."
"Z" = "Z"
"Z to A" = "ჰ-დან ა-მდე"
@@ -2188,7 +2187,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"pref.showDimensions" = "ზომების ჩვენება ობიექტებზე"
"pref.startOnHome" = "საწყისი ეკრანით გაშვება"
"pref.uiScale" = "ინტერფეისის ზომა"
"pref.userName" = "თქვენი სახელი კომენტარებში"
"pref.userName" = "თქვენი სახელი"
"proto.action.add" = "პროტოტიპის მოქმედების დამატება"
"proto.action.move" = "პროტოტიპის მოქმედების გადალაგება"
"proto.action.remove" = "პროტოტიპის მოქმედების წაშლა"
+1 -2
View File
@@ -1507,7 +1507,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "내 파일 사본은 열린 상태로 유지됨"
"Your copy stays open; the file stays on the server" = "내 사본은 열린 채로 유지됨. 파일은 서버에 남음"
"Your name" = "내 이름"
"Your name in comments" = "댓글에 표시할 이름"
"Your system asks for reduced motion: animations stay off." = "시스템에서 동작 줄이기를 요청하므로 애니메이션이 꺼져 있음."
"Z" = "Z"
"Zoom" = "확대/축소"
@@ -2148,7 +2147,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "복제한 레이어 이름 바꾸기"
"pref.showDimensions" = "개체에 치수 표시"
"pref.startOnHome" = "홈에서 시작"
"pref.userName" = "댓글에 표시할 이름"
"pref.userName" = "내 이름"
"proto.action.add" = "프로토타입 액션 추가"
"proto.action.move" = "프로토타입 액션 순서 변경"
"proto.action.remove" = "프로토타입 액션 제거"
+1 -2
View File
@@ -1594,7 +1594,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Je kopie van het bestand blijft open"
"Your copy stays open; the file stays on the server" = "Jouw kopie blijft open; het bestand blijft op de server"
"Your name" = "Jouw naam"
"Your name in comments" = "Je naam in reacties"
"Your system asks for reduced motion: animations stay off." = "Jouw systeem vraagt om minder beweging: animaties blijven uit."
"Z" = "Z"
"Z to A" = "Z tot A"
@@ -2133,7 +2132,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "Gedupliceerde lagen hernoemen"
"pref.showDimensions" = "Afmetingen op objecten tonen"
"pref.startOnHome" = "Starten op Home"
"pref.userName" = "Je naam in reacties"
"pref.userName" = "Jouw naam"
"proto.action.add" = "Prototypeactie toevoegen"
"proto.action.move" = "Volgorde van prototypeactie wijzigen"
"proto.action.remove" = "Prototype-actie verwijderen"
+1 -2
View File
@@ -1604,7 +1604,6 @@ name = "Polski"
"Your copy of the file stays open" = "Twoja kopia pliku pozostaje otwarta"
"Your copy stays open; the file stays on the server" = "Twoja kopia pozostaje otwarta; plik pozostaje na serwerze"
"Your name" = "Twoje imię i nazwisko"
"Your name in comments" = "Twoje imię w komentarzach"
"Your system asks for reduced motion: animations stay off." = "Twój system preferuje ograniczony ruch: animacje pozostają wyłączone."
"Z" = "Z"
"Z to A" = "Od Z do A"
@@ -2147,7 +2146,7 @@ name = "Polski"
"pref.showDimensions" = "Pokazuj wymiary przy zaznaczeniu"
"pref.startOnHome" = "Rozpoczynaj od ekranu głównego"
"pref.uiScale" = "Rozmiar interfejsu"
"pref.userName" = "Twoja nazwa w komentarzach"
"pref.userName" = "Twoje imię i nazwisko"
"proto.action.add" = "Dodaj akcję prototypu"
"proto.action.move" = "Zmień kolejność akcji prototypu"
"proto.action.remove" = "Usuń akcję prototypu"
+1 -2
View File
@@ -1505,7 +1505,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Sua cópia do arquivo continua aberta"
"Your copy stays open; the file stays on the server" = "Sua cópia continua aberta; o arquivo continua no servidor"
"Your name" = "Seu nome"
"Your name in comments" = "Seu nome nos comentários"
"Your system asks for reduced motion: animations stay off." = "Seu sistema pede movimento reduzido: as animações ficam desativadas."
"Z" = "Z"
"Zoom" = "Zoom"
@@ -2145,7 +2144,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "Renomear camadas duplicadas"
"pref.showDimensions" = "Mostrar dimensões nos objetos"
"pref.startOnHome" = "Começar no Início"
"pref.userName" = "Seu nome nos comentários"
"pref.userName" = "Seu nome"
"proto.action.add" = "Adicionar ação de protótipo"
"proto.action.move" = "Reordenar ação de protótipo"
"proto.action.remove" = "Remover ação de protótipo"
+1 -2
View File
@@ -1604,7 +1604,6 @@ name = "Русский"
"Your copy of the file stays open" = "Ваша копия файла останется открытой"
"Your copy stays open; the file stays on the server" = "Ваша копия останется открытой; файл останется на сервере"
"Your name" = "Ваше имя"
"Your name in comments" = "Ваше имя в комментариях"
"Your system asks for reduced motion: animations stay off." = "В системе включено уменьшение движения: анимации отключены."
"Z" = "Z"
"Z to A" = "От Я до А"
@@ -2147,7 +2146,7 @@ name = "Русский"
"pref.showDimensions" = "Показать размеры объектов"
"pref.startOnHome" = "Начинать с главной"
"pref.uiScale" = "Размер интерфейса"
"pref.userName" = "Ваше имя в комментариях"
"pref.userName" = "Ваше имя"
"proto.action.add" = "Добавить действие прототипа"
"proto.action.move" = "Изменить порядок действия прототипа"
"proto.action.remove" = "Удалить действие прототипа"
+1 -2
View File
@@ -1640,7 +1640,6 @@ name = "Svenska"
"Your copy of the file stays open" = "Din kopia av filen förblir öppen"
"Your copy stays open; the file stays on the server" = "Din kopia förblir öppen; filen ligger kvar på servern"
"Your name" = "Ditt namn"
"Your name in comments" = "Ditt namn i kommentarer"
"Your system asks for reduced motion: animations stay off." = "Systemet begär minskad rörelse: animeringar förblir avstängda."
"Z" = "Z"
"Z to A" = "Ö till A"
@@ -2192,7 +2191,7 @@ name = "Svenska"
"pref.showDimensions" = "Visa dimensioner på objekt"
"pref.startOnHome" = "Börja på Start"
"pref.uiScale" = "Gränssnittsstorlek"
"pref.userName" = "Ditt namn i kommentarer"
"pref.userName" = "Ditt namn"
"proto.action.add" = "Lägg till prototypåtgärd"
"proto.action.move" = "Ändra ordning på prototypåtgärd"
"proto.action.remove" = "Ta bort prototypåtgärd"
+1 -2
View File
@@ -1584,7 +1584,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "สำเนาไฟล์ของคุณยังเปิดอยู่"
"Your copy stays open; the file stays on the server" = "สำเนาของคุณยังเปิดอยู่ ไฟล์ยังอยู่บนเซิร์ฟเวอร์"
"Your name" = "ชื่อของคุณ"
"Your name in comments" = "ชื่อของคุณในความคิดเห็น"
"Your system asks for reduced motion: animations stay off." = "ระบบของคุณขอให้ลดการเคลื่อนไหว: แอนิเมชันจะปิดอยู่"
"Z" = "Z"
"Z to A" = "Z ถึง A"
@@ -2110,7 +2109,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "เปลี่ยนชื่อเลเยอร์ที่ทำสำเนา"
"pref.showDimensions" = "แสดงขนาดบนออบเจกต์"
"pref.startOnHome" = "เริ่มที่หน้าจอหลัก"
"pref.userName" = "ชื่อของคุณในความคิดเห็น"
"pref.userName" = "ชื่อของคุณ"
"proto.action.add" = "เพิ่มการกระทำโปรโตไทป์"
"proto.action.move" = "เรียงลำดับการกระทำโปรโตไทป์ใหม่"
"proto.action.remove" = "นำการกระทำโปรโตไทป์ออก"
+1 -2
View File
@@ -1559,7 +1559,6 @@ name = "Türkçe"
"Your copy of the file stays open" = "Dosyanın kopyanız açık kalır"
"Your copy stays open; the file stays on the server" = "Kopyanız açık kalır; dosya sunucuda kalır"
"Your name" = "Adınız"
"Your name in comments" = "Yorumlardaki adınız"
"Your system asks for reduced motion: animations stay off." = "Sisteminiz hareketin azaltılmasını istiyor: animasyonlar kapalı kalır."
"Z" = "Z"
"Z to A" = "Z'den A'ya"
@@ -2092,7 +2091,7 @@ name = "Türkçe"
"pref.reduceMotion" = "Hareketi azalt"
"pref.renameDuplicates" = "Çoğaltılan katmanları yeniden adlandır"
"pref.showDimensions" = "Nesnelerde boyutları göster"
"pref.userName" = "Yorumlardaki adınız"
"pref.userName" = "Adınız"
"proto.action.add" = "Prototip eylemi ekle"
"proto.action.move" = "Prototip eylemini yeniden sırala"
"proto.action.remove" = "Prototip eylemini kaldır"
+1 -2
View File
@@ -1534,7 +1534,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "Bản sao tệp của bạn vẫn mở"
"Your copy stays open; the file stays on the server" = "Bản sao của bạn vẫn mở; tệp vẫn được lưu trên máy chủ"
"Your name" = "Tên của bạn"
"Your name in comments" = "Tên của bạn trong bình luận"
"Your system asks for reduced motion: animations stay off." = "Hệ thống của bạn yêu cầu giảm chuyển động: các hoạt ảnh sẽ được tắt."
"Z" = "Z"
"Z to A" = "Z đến A"
@@ -2050,7 +2049,7 @@ status = "draft — needs native review"
"pref.showDimensions" = "Hiện kích thước trên đối tượng"
"pref.startOnHome" = "Khởi động tại màn hình Trang chủ"
"pref.uiScale" = "Kích thước giao diện"
"pref.userName" = "Tên của bạn trong bình luận"
"pref.userName" = "Tên của bạn"
"proto.action.add" = "Thêm hành động nguyên mẫu"
"proto.action.move" = "Sắp xếp lại hành động nguyên mẫu"
"proto.action.remove" = "Xóa hành động nguyên mẫu"
+1 -2
View File
@@ -1515,7 +1515,6 @@ status = "draft — needs native review"
"Your copy of the file stays open" = "你的文件副本保持打开"
"Your copy stays open; the file stays on the server" = "你的副本保持打开;文件保留在服务器上"
"Your name" = "你的名字"
"Your name in comments" = "评论中显示的名字"
"Your system asks for reduced motion: animations stay off." = "系统要求减少动态效果:动画保持关闭。"
"Z" = "Z"
"Zoom" = "缩放"
@@ -2045,7 +2044,7 @@ status = "draft — needs native review"
"pref.renameDuplicates" = "重命名副本图层"
"pref.showDimensions" = "在对象上显示尺寸"
"pref.startOnHome" = "启动时显示主页"
"pref.userName" = "评论中显示的名字"
"pref.userName" = "你的名字"
"proto.action.add" = "添加原型操作"
"proto.action.move" = "调整原型操作顺序"
"proto.action.remove" = "移除原型操作"
+2 -4
View File
@@ -264,10 +264,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(tr("Your name in comments"));
if let Some(name) = crate::widgets::text_field(ui, egui::Id::unique("pref_user_name"), &prefs.user_name, 160.0, tr("You")) {
app.run("pref.userName", json!({"name": name}));
}
ui.label(tr("Your name"));
crate::live::name_field(app, ui, egui::Id::unique("pref_user_name"), 160.0);
});
ui.add_space(8.0);
ui.horizontal(|ui| {
+63
View File
@@ -0,0 +1,63 @@
//! Your name, one for comments and live sessions (#1380): "Your name" in Preferences, shown and
//! editable in the Join dialog, and kept as soon as it changes.
use std::sync::{Arc, Mutex};
use egui::{Event, Key, Modifiers};
use figcraft_engine::Prefs;
use crate::Services;
use crate::live_tests::{Harness, has_text, text_center};
/// Keeps the name of each preferences save.
struct Kept(Arc<Mutex<Vec<String>>>);
impl Services for Kept {
fn save_prefs(&mut self, prefs: &Prefs) {
self.0.lock().unwrap().push(prefs.user_name.clone());
}
}
fn harness() -> (Harness, Arc<Mutex<Vec<String>>>) {
let (mut h, _) = Harness::new();
let kept = Arc::new(Mutex::new(Vec::new()));
h.app.services = Box::new(Kept(kept.clone()));
(h, kept)
}
/// Replace the text of the field showing `old` with `new` and press Enter.
fn retype(h: &mut Harness, old: &str, new: &str) {
let shapes = h.frames(1);
h.click(text_center(&shapes, old));
let key = |key, modifiers| Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
h.frame(vec![key(Key::A, Modifiers::COMMAND)]);
h.frame(vec![Event::Text(new.into())]);
h.frame(vec![key(Key::Enter, Modifiers::NONE)]);
h.frames(1);
}
#[test]
fn the_join_dialog_shows_your_name_and_changes_it() {
let (mut h, kept) = harness();
h.app.session.execute("pref.userName", &serde_json::json!({"name": "Ada"})).unwrap();
crate::menus::invoke(&mut h.app, "collab.join");
let shapes = h.frames(3);
assert!(has_text(&shapes, "Your name") && has_text(&shapes, "Ada"), "the name others will see");
retype(&mut h, "Ada", "Bob");
assert_eq!(h.app.session.prefs.user_name, "Bob");
assert_eq!(kept.lock().unwrap().last().map(String::as_str), Some("Bob"), "kept at once");
assert!(h.app.ui.live.join.is_some(), "the dialog stays to join");
}
#[test]
fn preferences_call_it_your_name_and_the_default_shows_empty() {
let (mut h, kept) = harness();
h.app.ui.preferences = true;
let shapes = h.frames(3);
assert!(has_text(&shapes, "Your name") && !has_text(&shapes, "Your name in comments"));
assert!(!has_text(&shapes, "You"), "the default isn't a name to edit");
h.app.session.execute("pref.userName", &serde_json::json!({"name": "Ada"})).unwrap();
retype(&mut h, "Ada", "Lin");
assert_eq!(h.app.session.prefs.user_name, "Lin");
assert_eq!(kept.lock().unwrap().as_slice(), ["Lin"]);
}
+2
View File
@@ -79,6 +79,8 @@ mod dev_mode_tests;
#[cfg(test)]
mod devmode_tail_tests;
#[cfg(test)]
mod display_name_tests;
#[cfg(test)]
mod duplicate_paint_tests;
#[cfg(test)]
mod export_preview_tests;
+21 -7
View File
@@ -798,13 +798,7 @@ pub(crate) fn failure_text(reason: &str) -> Cow<'_, str> {
/// Not in a session: your name, how to share (online or on the local network), start; or join
/// one with a code. Everything else is under Advanced.
fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
ui.horizontal(|ui| {
widgets::label(ui, tr("Your name"), t.text);
let name = app.session.prefs.user_name.clone();
if let Some(n) = widgets::text_field(ui, egui::Id::unique("live_name"), &name, 160.0, tr("Your name")) {
app.run("pref.userName", json!({"name": n}));
}
});
name_row(app, ui, t, egui::Id::unique("live_name"));
if !app.session.live.can_host() {
widgets::label(ui, tr("Sharing a file needs the desktop app; here you can join one."), t.text_secondary);
widgets::label(ui, tr("Join with a code"), t.text);
@@ -876,6 +870,25 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
});
}
/// "Your name" and its field (the Share popover, the Join dialog).
fn name_row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, id: egui::Id) {
ui.horizontal(|ui| {
widgets::label(ui, tr("Your name"), t.text);
name_field(app, ui, id, 160.0);
});
}
/// The field for your name (`pref.userName`), the one name comments are signed with and others
/// see you as in live sessions: empty while it is the default. A new name is kept at once (the
/// browser has no quitting to keep it at).
pub fn name_field(app: &mut FigApp, ui: &mut Ui, id: egui::Id, width: f32) {
let shown = figcraft_engine::live::display_name(&app.session.prefs);
if let Some(name) = widgets::text_field(ui, id, &shown, width, tr("Your name")) {
app.run("pref.userName", json!({"name": name}));
app.services.save_prefs(&app.session.prefs);
}
}
/// Under what starts a share: the join field, then the Advanced part (`advanced` draws it while
/// it is open).
fn join_and_advanced(app: &mut FigApp, ui: &mut Ui, t: &Tokens, advanced: impl FnOnce(&mut FigApp, &mut Ui, &Tokens)) {
@@ -1205,6 +1218,7 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) {
go = join_field(app, ui, &mut link, egui::Id::unique("live_join_link"), 300.0, focus).is_some();
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked();
});
name_row(app, ui, &t, egui::Id::unique("live_join_name"));
widgets::label(ui, tr("You'll get the shared file as a new tab and edit it together."), t.text_secondary);
if !app.session.live.can_host() {
widgets::label(ui, tr(BROWSER_NOTE), t.text_secondary);
+9 -4
View File
@@ -198,7 +198,10 @@ an older client never gets a message it can't read. A server that takes them ans
client, right after its welcome, with a `ready` signal about the client itself; a client sends
signals only after that, so an older server never gets one either (the app says the host runs an
older version). A signal is a kind and a text, so kinds added later are skipped by clients and
dropped by servers that don't know them.
dropped by servers that don't know them. The `name` kind (a rename) came after the first signals:
a server that knows it keeps the new name and tells everyone with the client's `PeerJoined`
(apps take a `PeerJoined` for someone already there as their new name and colour); an older
server drops it, so the session keeps showing the name the client joined with.
**Open hosting and reports** came later within version 2 the same way. A connection that opens
with `OpenHosting` asks a server about its hosting, publishes without the key when the server is
@@ -247,7 +250,7 @@ Client → server:
| `Admin {publish {name, snapshot} \| files \| links {file} \| createLink {file, access, label} \| revoke {file, link} \| remove {file}}` | A request on a managing connection; each gets one `Admin` reply, in order. |
| `OpenLibrary {protocol, file, token, client_version}` | Instead of `Hello`: use a file's team library with one of the file's share links. |
| `Library {fetch {have?, usage?} \| publish {archive} \| usage}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`. |
| `Signal {kind, text}` | Ephemeral presence, only after the server's `ready`: `chat` (the cursor chat message; empty: closed) or `spotlight` (`"on"`, or empty: stopped). |
| `Signal {kind, text}` | Ephemeral presence, only after the server's `ready`: `chat` (the cursor chat message; empty: closed), `spotlight` (`"on"`, or empty: stopped) or `name` (your new name: the server keeps it, cleaned like a hello's, and sends everyone, you too, your `PeerJoined`; never passed on as a signal). |
| `OpenPlugins {protocol, key, client_version}` | Instead of `Hello`: use the server's plugin directory; `key` empty to browse, the server key to publish too. |
| `OpenHosting {protocol, client_version, file, owner}` | Instead of `Hello`: ask about the server's hosting; publish without the key (`Admin publish`) when it is open to the public; with a file's id and owner token, manage that file (`Admin links \| createLink \| revoke \| rename \| remove` of it). Answered with `Hosting` or `Refused`. |
| `Admin {rename {file, name} \| listing \| reports \| block {ip} \| unblock {ip} \| blocked}` | Later managing requests: rename a file on the server, the files with what the operator moderates by, the reports, blocking addresses (an IPv6 address: its /64). Owner tokens may only rename their file. |
@@ -438,8 +441,10 @@ resuming with its offline edits, and leaving the session going back to snapshot
With another document in front (or before the welcome arrives), cursors show in page
coordinates on whatever page you look at and nothing is outlined.
- Colours and names: the host hands out the first free colour of an eight-colour palette and
"Guest N" to people without a name (Preferences: your name, `pref.userName`; also in the Share
popover). After a newcomer's `Welcome` the host sends it its own `PeerJoined`, which is how it
"Guest N" to people without a name (Preferences ▸ Your name, `pref.userName`; also in the Share
popover and the Join dialog). Changing it while in a session renames you there at once (a
`name` signal, sent again after a reconnect, whose hello has the old name); clearing it keeps
the session's name until you join again. After a newcomer's `Welcome` the host sends it its own `PeerJoined`, which is how it
learns its name and colour.
- Idle cost is zero: network threads block, wake the window (`request_repaint`) only when a
message arrives, cursors only ask for frames while they glide, and text carets never do.
+3 -2
View File
@@ -1,6 +1,6 @@
# Collaboration parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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,
@@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Left: server archives beyond Linux | 25–55 |
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). Left: server archives beyond Linux | 25–55 |
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 |
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
@@ -28,6 +28,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Date | Change | Summary |
|---|---|---|
| 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 | M9, #1381: joining a live session shows its progress. From the click on Join (the Share popover, the home screen or the Join dialog, which an invite link opens) the Join dialog says "Joining live session…" with a spinner and Cancel until the shared file is in its own tab and ready, then the file shows; while it is on its way the join fields don't start a second one. A join that fails or times out says why in the dialog, with Try again (a new join with the same code or link) and Cancel; Escape and the close button cancel too. `collab.leave` takes `session` to end a session by its id, whichever tab is in front. Tests: `live_joining_tests`. Numbers unchanged |
| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files (join codes, links, renames, removals, blocks, publishing with the key) a budget of 60 a minute per address and block (`Action::Admin`), on every server, with the server key and with an owner token alike; listing and looking aren't counted. Before, a managing connection with the key had no limit at all and an owner token only the message rate, so one address could ask for hundreds of 10,000-round code stretches and `links.json` writes a second (100 renames in a row: 100 served before, 60 now, then "too many changes to this server's files from your address…"; another address is unaffected). A typed or new join code is found through an index of code hashes to the files holding them, instead of opening every link's code on the server; results are the same, a copied file folder holding the same code included. The protocol is unchanged. Tests: `admin_budget_tests`. Numbers unchanged |
| 2026-10-29 | minor | M9, #1415: `comment.list` (CLI, MCP `run_command`, the `figcraft://comments` resource) gives every message, the first and each reply, its `mentions`: `{name, known, you}` for each name it @mentions, by the rule the thread view highlights them with (`figcraft_doc::mentions`, which the highlighting now shares). A name resolves against the file's people and you, as the file spells it. An unknown name is reported with `known: false`. `a@b.com` mentions nobody. Filtering threads by a mentioned name and listing mentioned names in `comment.people` stay open. Numbers unchanged |
+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); 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); 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-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 |
| 2026-10-29 | minor | M9, #1075: `figcraft-server` gives requests that change its files a budget of 60 a minute per address, with the server key and owner tokens alike, and finds join codes through an index of their hashes. Tests: `admin_budget_tests` |