From 1f6e75e0b6b26be5fddbca02d6f9b1f15069e24b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 12 Oct 2026 02:52:54 +0700 Subject: [PATCH] M8: cursor chat and spotlight in live sessions Two presence features for live sessions, with no accounts needed: - **Cursor chat.** In a live session, `/` on the canvas opens a bubble in your colour at your pointer. What you type goes to everyone as you type, and others see it next to your cursor in place of your name tag. Enter (or clicking away) sends it and closes the box. On every screen it fades a few seconds after its last change (5 s, then a 1 s fade; with reduced motion it vanishes without fading). Escape takes it back at once and doesn't deselect. A box left open without typing closes by itself. Limits: 100 characters, control characters become spaces. Agents use `collab.cursorChat {text}`. - **Spotlight.** Your avatar (top of the right panel) now has a menu with Spotlight me / Stop spotlighting (`collab.spotlight {on?}`). Everyone else follows your view and page, using the existing follow. People who join later are told by the server and follow too. Anyone who pans, zooms or presses Escape stops following, and a canvas banner ("Hana is spotlighting", Follow) lets them follow again. A newer spotlight takes over the old one. The presenter leaving or stopping ends it. The presenter sees a banner with Stop. After a reconnect, your spotlight goes out again. - **Protocol: signals.** New `ClientMsg::Signal {kind, text}` and `ServerMsg::Signal {client_id, signal}` carry ephemeral presence. Signals never go into the document, the log or a saved state; the server keeps only who is spotlighting, for newcomers. The server cleans each signal: unknown kinds, malformed spotlights, a client's `ready` and anything over 1 KiB are dropped, and chat is capped and has control characters replaced. The app checks what arrives again before showing it. The hub rate-limits signals per connection (20 a second, bursts of 40) and drops the excess without closing the connection. - **Compatibility, both directions.** A client that reads signals announces it with the word `signals` in its hello's `client_version`, and the server sends signals only to those clients, so older clients never see a message they can't decode. A server that takes signals answers with a `ready` signal after the welcome, and the client sends signals only after that, so older servers never get one; the app says the host runs an older version. Signals are kind plus text, so clients skip kinds added later and servers drop them. - Engine: `Live` keeps `signals`, `chats`, `chat`, `spotlight` and `spotlighting`, all reported by `collab.peers`. UI: chat bubbles, the chat box, the spotlight banner (the existing canvas banner widget), the avatar menu and `ui.inspect` fields. Keymap: `/` maps to `collab.cursorChat` (Essential). Spanish labels for both commands, plus en/es UI strings. - Docs: collab-protocol (messages, compatibility, Presence, security limits, agents), mcp.md, collaboration-parity rows, target-app-parity, ROADMAP, gaps, ui-parity. --- ROADMAP.md | 15 +- crates/collab-net/src/host.rs | 12 +- crates/collab-net/src/hub.rs | 17 +- crates/collab-net/src/join.rs | 2 + crates/collab-net/src/loopback_tests.rs | 56 +++++- crates/collab-net/src/session_tests.rs | 37 ++++ crates/collab/src/client.rs | 5 +- crates/collab/src/codec_tests.rs | 9 +- crates/collab/src/lib.rs | 4 +- crates/collab/src/message.rs | 81 +++++++++ crates/collab/src/server.rs | 39 ++++- crates/collab/src/signal_tests.rs | 123 +++++++++++++ crates/collab/src/sim.rs | 2 +- crates/engine/src/keymap.rs | 2 + crates/engine/src/lib.rs | 2 + crates/engine/src/live.rs | 168 +++++++++++++++++- crates/engine/src/live_signal_tests.rs | 158 +++++++++++++++++ crates/engine/src/live_tests.rs | 2 + crates/ui-egui/locales/en.toml | 8 + crates/ui-egui/locales/es.toml | 10 ++ crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/live.rs | 218 +++++++++++++++++++++++- crates/ui-egui/src/live_chat_tests.rs | 119 +++++++++++++ crates/ui-egui/src/live_tests.rs | 7 +- docs/collab-protocol.md | 45 ++++- docs/collaboration-parity.md | 11 +- docs/gaps.md | 11 +- docs/mcp.md | 18 +- docs/target-app-parity.md | 21 +-- docs/ui-parity.md | 5 +- 31 files changed, 1155 insertions(+), 57 deletions(-) create mode 100644 crates/collab/src/signal_tests.rs create mode 100644 crates/engine/src/live_signal_tests.rs create mode 100644 crates/ui-egui/src/live_chat_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 1373676..b34797c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~170–305 h away (one Opus 5.5 agent; ~35–70 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **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 @@ -17,15 +17,15 @@ start with [`docs/gaps.md`](docs/gaps.md). | Feature breadth | **~84%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Ready for real work | **~52%** | estimated, same doc (written dimension weights and scores) | | Essentials user | **~57%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | -| Mainstream practitioner | **~49%** | estimated: weekly areas 72.4% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | +| Mainstream practitioner | **~49%** | estimated: weekly areas 72.6% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~170–305 h** one agent | estimated | -| Remaining to full parity | **~289–564 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~286–558 h** one agent (~86–150 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~52% | ~294–566 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~52% | ~291–560 h | multiplayer (42–84), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~49% | ~130–244 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | @@ -59,7 +59,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Features (depth) | 63% (core editor 72%) | 29–68 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 59% (`.fig` read best effort with editable vectors, no write) | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 49–98 | [collaboration-parity](docs/collaboration-parity.md) | +| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) | | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | @@ -88,7 +88,7 @@ Figma's own languages on the landed localization layer (15–25 h). | Import/export | 84% | 57% | 5–10 | [file-format-parity](docs/file-format-parity.md) | | Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | -| Multiplayer & sharing | 15% | 5% | 45–90 | [collaboration-parity](docs/collaboration-parity.md) | +| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 45% | 28% | 15–40 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | @@ -124,7 +124,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. 4. Languages on the landed localization layer: ja, ko, pt-BR, fr, de, and a native review of es: 15–30 h. -5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 45–90 h. +5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. ## Progress log @@ -222,6 +222,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page until they move their own. Ephemeral presence the host validates and rate-limits, passed only to clients that read it (older clients keep working). Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; collaboration 13% unchanged, 49–98 h → 46–92 h; feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h | | 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes (several layers dropped together, Collapse layers, eye and padlock sweeps, Rename layers dialog with the documented number codes, page dragging, dividers, "Copy of" duplicates, Assets folders, hidden-from-publishing names both ways). Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | | 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | diff --git a/crates/collab-net/src/host.rs b/crates/collab-net/src/host.rs index e58c3ea..0d51735 100644 --- a/crates/collab-net/src/host.rs +++ b/crates/collab-net/src/host.rs @@ -11,7 +11,7 @@ use figcraft_collab::{Outgoing, Server, same_secret}; use figcraft_doc::Document; use figcraft_engine::live::{Event, PEOPLE}; -use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name}; +use crate::hub::{Admission, Backend, Closing, Hosting, Local, clean_name, to_local}; use crate::inbox::Inbox; pub use crate::join::ENDED; use crate::net::NetLimits; @@ -85,16 +85,16 @@ impl Host { token: String::new(), name, color: PEOPLE[0].to_string(), - client_version: env!("CARGO_PKG_VERSION").to_string(), + client_version: figcraft_collab::message::client_version(), resume: None, }; let (me, outs) = server.join(hello).map_err(|r| io::Error::other(format!("{r:?}")))?; // The host's window syncs through its own client too: its welcome carries the document. for o in outs { - if let Outgoing::To(id, w @ ServerMsg::Welcome { .. }) = o - && id == me - { - inbox.push(Event::Sync(w)); + match o { + Outgoing::To(id, w @ ServerMsg::Welcome { .. }) if id == me => inbox.push(Event::Sync(w)), + Outgoing::To(id, ref m @ ServerMsg::Signal { .. }) if id == me => to_local(&inbox, me, m), + _ => {} } } let me = server.peers().find(|p| p.client_id == me).cloned().ok_or_else(|| io::Error::other("the host didn't join its own session"))?; diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index b464527..881ed7e 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -42,6 +42,11 @@ use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; /// How long a closing connection waits for the peer to hang up. const LINGER: Duration = Duration::from_secs(2); +/// Signals (cursor chat, spotlight) a client may send per second on average, and in a burst; +/// beyond that they are dropped. +pub const SIGNAL_RATE: f64 = 20.0; +pub const SIGNAL_BURST: f64 = 40.0; + /// Most messages the hub handles before it sends what they produced (bounds the delay a burst /// adds). const BURST: usize = 256; @@ -425,13 +430,20 @@ fn serve(stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, _ fn read_loop(ws: &mut WebSocket, conn: u64, hub: &Sender, limits: NetLimits, end: &dyn Fn(Option<&str>)) { let mut bucket = Bucket::new(limits.rate, limits.burst); + let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST); loop { match next_message(ws, limits.max_client_message) { Ok(Some(msg)) => { - if !bucket.take(Instant::now()) { + let now = Instant::now(); + if !bucket.take(now) { log::info!("live session: connection {conn} sends too much; disconnecting it"); return end(Some("too many messages")); } + // Typing sends a chat signal per keystroke; more than this is dropped. + if matches!(msg, ClientMsg::Signal(_)) && !signals.take(now) { + log::debug!("live session: connection {conn} sends signals too fast; dropping one"); + continue; + } if hub.send(HubIn::Msg { conn, msg }).is_err() { return; } @@ -863,12 +875,13 @@ impl Hub { } /// What the hosting person's window should know. -fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) { +pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) { match msg { ServerMsg::PeerJoined(p) if p.client_id != local => inbox.push(Event::PeerJoined(p.clone())), ServerMsg::PeerLeft { client_id } => inbox.push(Event::PeerLeft(*client_id)), ServerMsg::Presence { client_id, presence } => inbox.presence(*client_id, presence.clone()), ServerMsg::Changes { .. } | ServerMsg::Reject { .. } => inbox.push(Event::Sync(msg.clone())), + ServerMsg::Signal { client_id, signal } => inbox.push(Event::Signal(*client_id, signal.clone())), _ => {} } } diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index da09ca1..b29086f 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -207,6 +207,8 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se inbox.push(Event::Sync(m)); } ServerMsg::Reject { .. } => inbox.push(Event::Sync(m)), + // Checked again by the engine (the host is as untrusted as any peer). + ServerMsg::Signal { client_id, signal } => inbox.push(Event::Signal(client_id, signal)), // Answers for managing and library connections: not this one's. ServerMsg::Pong | ServerMsg::Admin(_) | ServerMsg::Library(_) => {} } diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 828ea41..e2e2a58 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -7,7 +7,7 @@ use std::net::{SocketAddr, TcpStream}; use std::sync::Arc; use std::time::{Duration, Instant}; -use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg}; +use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal}; use figcraft_collab::{Client, Limits}; use figcraft_doc::{Document, NodeId}; use figcraft_engine::live::{Event, PEOPLE, Status}; @@ -557,3 +557,57 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() { assert!(!hp.left().contains(&ada)); host.stop(); } + +#[test] +fn signals_reach_only_clients_that_read_them_and_are_rate_limited() { + let (mut host, mut hp, invite) = start(); + let addr: SocketAddr = invite.server.host.parse().unwrap(); + hp.wait("the host's own ready", |e| e.iter().any(|e| matches!(e, Event::Signal(_, s) if s.kind == Signal::READY))); + // A client from before signals: its hello doesn't announce them. + let mut old = Raw::connect(addr); + let ClientMsg::Hello(h) = Client::hello(FILE, TOKEN, "Old", "") else { panic!("not a hello") }; + assert!(old.send(&ClientMsg::Hello(Hello { client_version: "0.3.1".into(), ..h }))); + assert!(matches!(old.next(), Some(ServerMsg::Welcome { .. }))); + let mut new = Raw::hello(addr, TOKEN); + let Some(ServerMsg::Welcome { client_id: me, .. }) = new.next() else { panic!("no welcome") }; + assert_eq!(new.next(), Some(ServerMsg::Signal { client_id: me, signal: Signal::ready() })); + // The host chats, then moves its pointer: the old client gets the pointer only. + host.send(ClientMsg::Signal(Signal::chat("hi"))); + host.presence(presence(1.0, 2.0)); + let until_presence = |r: &mut Raw| { + let mut got = Vec::new(); + loop { + match r.next() { + Some(ServerMsg::Presence { .. }) | None => return got, + Some(m) => got.push(m), + } + } + }; + let old_got = until_presence(&mut old); + assert!(!old_got.iter().any(|m| matches!(m, ServerMsg::Signal { .. })), "{old_got:?}"); + let new_got = until_presence(&mut new); + let hana = host.me().client_id; + assert!(new_got.contains(&ServerMsg::Signal { client_id: hana, signal: Signal::chat("hi") }), "{new_got:?}"); + // A flood of signals: the excess is dropped (the connection stays), the host gets them cleaned. + for i in 0..200 { + assert!(new.send(&ClientMsg::Signal(Signal::chat(&format!("{i}\u{7}"))))); + } + assert!(new.send(&ClientMsg::Presence(presence(3.0, 4.0)))); + hp.wait("the flood's end", |e| e.iter().any(|e| matches!(e, Event::Presence(id, _) if *id == me))); + let chats: Vec<&Signal> = hp.events.iter().filter_map(|e| if let Event::Signal(id, s) = e { (*id == me).then_some(s) } else { None }).collect(); + assert!(chats.len() >= 40 && chats.len() < 200, "{} of 200 got through", chats.len()); + assert!(chats.iter().all(|s| s.kind == Signal::CHAT && s.text.ends_with(' ')), "cleaned on the way"); + assert!(new.send(&ClientMsg::Ping)); + assert_eq!(until_presence_or_pong(&mut new), Some(ServerMsg::Pong), "still connected"); + host.stop(); +} + +/// The next message that isn't someone's presence or signal. +fn until_presence_or_pong(r: &mut Raw) -> Option { + loop { + match r.next() { + Some(ServerMsg::Presence { .. } | ServerMsg::Signal { .. }) => {} + m => return m, + } + } +} diff --git a/crates/collab-net/src/session_tests.rs b/crates/collab-net/src/session_tests.rs index 05e6909..8d85545 100644 --- a/crates/collab-net/src/session_tests.rs +++ b/crates/collab-net/src/session_tests.rs @@ -212,3 +212,40 @@ fn edits_sync_between_apps() { assert!(same(&hd, c.doc().unwrap()), "the server's document is everyone's"); host.execute("collab.stop", &json!({})).unwrap(); } + +#[test] +fn cursor_chat_and_spotlight_between_apps() { + 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); + // The guest chats next to its cursor; the host sees it, cleaned, and then sees it close. + guest.execute("collab.cursor", &json!({"x": 40, "y": 30})).unwrap(); + guest.execute("collab.cursorChat", &json!({"text": "over\there"})).unwrap(); + pump(&mut host, &mut guest, "the chat", |h, _| h.live.chats.get(&ada).is_some_and(|t| t == "over here")); + guest.execute("collab.cursorChat", &json!({"text": ""})).unwrap(); + pump(&mut host, &mut guest, "the chat closing", |h, _| h.live.chats.is_empty()); + // The host spotlights: the guest follows its view, page included. + let second = host.execute("page.add", &json!({"name": "Two"})).unwrap()["id"].as_u64().map(NodeId).unwrap(); + pump(&mut host, &mut guest, "the new page at the guest", |_, g| g.doc().unwrap().pages.contains(&second)); + assert_eq!(host.execute("collab.spotlight", &json!({})).unwrap()["spotlighting"], true); + { + let ds = host.ds_mut().unwrap(); + ds.page = second; + ds.view.cx = 777.0; + ds.view.cy = -55.0; + } + pump(&mut host, &mut guest, "the guest following the spotlight", |_, g| { + let ds = g.ds().unwrap(); + g.live.spotlight == Some(hana) && g.live.follow == Some(hana) && ds.page == second && (ds.view.cx - 777.0).abs() < 1e-6 + }); + // A late joiner is told and follows too. + let mut late = session("Lin"); + late.execute("collab.join", &json!({"link": host.live.info.as_ref().unwrap().link.clone()})).unwrap(); + pump(&mut host, &mut late, "the late joiner following", |_, l| l.live.follow == Some(hana) && l.live.spotlight == Some(hana)); + // The guest moves: it stops following, the spotlight stays; the host stops it for everyone. + guest.ds_mut().unwrap().view.cx += 300.0; + guest.live_sync(); + assert_eq!((guest.live.follow, guest.live.spotlight), (None, Some(hana))); + host.execute("collab.spotlight", &json!({"on": false})).unwrap(); + pump(&mut host, &mut guest, "the spotlight ending", |_, g| g.live.spotlight.is_none()); + pump(&mut host, &mut late, "the spotlight ending for the late joiner", |_, l| l.live.spotlight.is_none() && l.live.follow.is_none()); +} diff --git a/crates/collab/src/client.rs b/crates/collab/src/client.rs index 52b67fc..c13de37 100644 --- a/crates/collab/src/client.rs +++ b/crates/collab/src/client.rs @@ -77,7 +77,7 @@ impl Client { token: token.to_string(), name: name.to_string(), color: color.to_string(), - client_version: env!("CARGO_PKG_VERSION").to_string(), + client_version: crate::message::client_version(), resume: None, }) } @@ -449,7 +449,8 @@ impl Client { self.presence.remove(&client_id); Ok(Vec::new()) } - ServerMsg::Pong => Ok(Vec::new()), + // Cursor chat and spotlights are the app's (the transport hands them over). + ServerMsg::Pong | ServerMsg::Signal { .. } => Ok(Vec::new()), ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())), ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => { if session != self.session || client_id != self.id { diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 4ae3ea2..57e68dc 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -5,7 +5,8 @@ use figcraft_doc::{Document, NodeId, NodeKind}; use crate::change::{Change, Json, Prop}; use crate::message::{ Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, FileUsage, Hello, LibraryReply, LibraryReq, LinkInfo, Manage, - NewLink, OpenLibrary, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Usage, WIRE_VERSION, decode, decode_json, encode, encode_json, + NewLink, OpenLibrary, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, Usage, WIRE_VERSION, decode, decode_json, encode, + encode_json, }; use crate::sim::Rng; use crate::{Error, Replica, diff}; @@ -60,6 +61,8 @@ fn samples() -> (Vec, Vec) { ClientMsg::Library(LibraryReq::Fetch { have: Some(3), usage: Some(Usage { file: "Checkout".into(), components: vec![(NodeId(7), 12)] }) }), ClientMsg::Library(LibraryReq::Publish { archive: vec![0x50, 0x4B, 3, 4] }), ClientMsg::Library(LibraryReq::Usage), + ClientMsg::Signal(Signal::chat("Hi ☕")), + ClientMsg::Signal(Signal::spotlight(true)), ]; let snapshot = Replica::new(Document::new("Snap")).snapshot().unwrap(); let file = FileInfo { id: "f".into(), name: "Café".into(), created: 1, updated: 2, seq: 3, bytes: 4, people: 5 }; @@ -108,6 +111,8 @@ fn samples() -> (Vec, Vec) { updated: 9, }]))), ServerMsg::Library(Box::new(LibraryReply::Failed { reason: "no".into() })), + ServerMsg::Signal { client_id: 4, signal: Signal::chat("Hi ☕") }, + ServerMsg::Signal { client_id: 4, signal: Signal::ready() }, ]; (client, server) } @@ -127,6 +132,8 @@ fn every_message_round_trips_in_both_forms() { let json = encode_json(&moved_layer()).unwrap(); assert!(json.starts_with(r#"{"changes":{"base_seq":1234,"batch_id":56,"changes":[{"set":"#), "{json}"); assert!(json.contains(r#""prop":"transform","value":[1.0,0.0,0.0,1.0,164.5,92.25]"#), "{json}"); + let chat = encode_json(&ClientMsg::Signal(Signal::chat("hi"))).unwrap(); + assert_eq!(chat, r#"{"signal":{"kind":"chat","text":"hi"}}"#); // The binary form starts with the wire version and carries values as JSON text. let bin = encode(&moved_layer()).unwrap(); assert_eq!(bin[0], WIRE_VERSION); diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index e990915..419f7db 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -41,7 +41,7 @@ pub use client::{Client, HISTORY_LIMIT}; pub use diff::{Diff, diff}; pub use figcraft_doc::{Document, NodeId}; pub use history::{BatchKind, Notice}; -pub use message::{Accepted, Access, CatchUp, ClientMsg, EditingText, Hello, Peer, Presence, Processed, Resume, ServerMsg}; +pub use message::{Accepted, Access, CatchUp, ClientMsg, EditingText, Hello, Peer, Presence, Processed, Resume, ServerMsg, Signal}; pub use replica::{Applied, Mode, Replica}; pub use server::{Journal, Outgoing, Server, SessionState, VIEW_ONLY}; @@ -138,6 +138,8 @@ mod reconnect_tests; #[cfg(test)] mod server_tests; #[cfg(test)] +mod signal_tests; +#[cfg(test)] mod sim_tests; #[cfg(test)] mod undo_tests; diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index b573214..561e144 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -15,6 +15,20 @@ pub const PROTOCOL: u32 = 2; /// The first byte of every binary message. pub const WIRE_VERSION: u8 = 1; +/// The word a client puts in its [`Hello::client_version`] when it reads [`ServerMsg::Signal`]: a +/// server sends signals only to clients that say so (older ones couldn't read the message). +pub const SIGNALS: &str = "signals"; + +/// The `client_version` this build announces: its version, then what it reads beyond the protocol. +pub fn client_version() -> String { + format!("{} {SIGNALS}", env!("CARGO_PKG_VERSION")) +} + +/// A [`Hello::client_version`] that announces [`SIGNALS`]. +pub fn reads_signals(client_version: &str) -> bool { + client_version.split_whitespace().any(|w| w == SIGNALS) +} + /// Join a file's session. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Hello { @@ -102,6 +116,65 @@ pub struct EditingText { pub selection: Option<(usize, usize)>, } +/// Presence beyond [`Presence`]: someone's cursor chat, or their spotlight. Ephemeral like presence +/// (never in the document or the log), latest per kind wins. Kinds a side doesn't know are +/// 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`]. + pub kind: String, + /// Chat: the message (empty: closed). Spotlight: `"on"`, or empty when it stopped. + pub text: String, +} + +impl Signal { + /// The message typed next to someone's pointer. + pub const CHAT: &str = "chat"; + /// Someone asks everyone to follow their view. + 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"; + /// 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). + pub const MAX_BYTES: usize = 1024; + + pub fn chat(text: &str) -> Signal { + Signal { kind: Signal::CHAT.into(), text: text.into() } + } + + pub fn spotlight(on: bool) -> Signal { + Signal { kind: Signal::SPOTLIGHT.into(), text: if on { "on".into() } else { String::new() } } + } + + pub fn ready() -> Signal { + Signal { kind: Signal::READY.into(), text: String::new() } + } + + /// 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). + pub fn clean(self) -> Option { + if self.kind.len() + self.text.len() > Signal::MAX_BYTES { + return None; + } + match self.kind.as_str() { + Signal::CHAT => { + let text: String = self.text.chars().map(|c| if c.is_control() { ' ' } else { c }).take(Signal::MAX_CHAT).collect(); + Some(Signal::chat(if text.trim().is_empty() { "" } else { &text })) + } + Signal::SPOTLIGHT if self.text.is_empty() || self.on() => Some(self), + Signal::READY if self.text.is_empty() => Some(self), + _ => None, + } + } +} + /// A batch the server accepted: its sequence number, who sent it and their batch id. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Accepted { @@ -326,6 +399,8 @@ pub enum ClientMsg { OpenLibrary(OpenLibrary), /// A request on a library connection. Library(LibraryReq), + /// Your cursor chat or spotlight (only to a server that sent [`Signal::READY`]). + Signal(Signal), } /// Server → client. @@ -373,6 +448,12 @@ pub enum ServerMsg { Admin(Box), /// The answer to a library connection's request. Library(Box), + /// Someone's cursor chat or spotlight; sent only to clients whose hello announced + /// [`SIGNALS`]. + Signal { + client_id: u32, + signal: Signal, + }, } /// The binary form of `msg`: [`WIRE_VERSION`], then postcard. diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index f2e832a..723cfcf 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -7,7 +7,7 @@ use figcraft_doc::{Document, NodeId}; use serde::{Deserialize, Serialize}; use crate::change::{Change, Entry, Prop, Target}; -use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg}; +use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg, Signal, reads_signals}; use crate::replica::{Mode, Replica, create_change}; use crate::{Error, Limits, docprops, same_secret}; @@ -69,6 +69,10 @@ pub struct Server { viewers: BTreeSet, /// What changed the lasting state since it was last taken (`None`: not kept). journal: Option>, + /// The connected clients that read signals (cursor chat, spotlight). + signals: BTreeSet, + /// Who spotlights now (a newcomer is told). + spotlight: Option, } /// The most client ids a session remembers for reconnecting (the oldest disconnected ones go @@ -115,6 +119,8 @@ impl Server { log_changes: 0, viewers: BTreeSet::new(), journal: None, + signals: BTreeSet::new(), + spotlight: None, } } @@ -287,6 +293,7 @@ impl Server { }; let processed = self.issued.get(&id).cloned().unwrap_or_default(); self.presence.remove(&id); + let signals = reads_signals(&hello.client_version); let peer = Peer { client_id: id, name: hello.name, color: hello.color }; let others = self.peers.values().filter(|p| p.client_id != id).cloned().collect(); let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access }; @@ -296,7 +303,17 @@ impl Server { } else { self.viewers.remove(&id); } - Ok((id, vec![Outgoing::To(id, welcome), Outgoing::Others(id, ServerMsg::PeerJoined(peer))])) + let mut outs = vec![Outgoing::To(id, welcome), Outgoing::Others(id, ServerMsg::PeerJoined(peer))]; + if signals { + self.signals.insert(id); + outs.push(Outgoing::To(id, ServerMsg::Signal { client_id: id, signal: Signal::ready() })); + if let Some(p) = self.spotlight.filter(|p| *p != id) { + outs.push(Outgoing::To(id, ServerMsg::Signal { client_id: p, signal: Signal::spotlight(true) })); + } + } else { + self.signals.remove(&id); + } + Ok((id, outs)) } /// Client `id`'s connection closed. @@ -304,6 +321,10 @@ impl Server { self.presence.remove(&id); self.acked.remove(&id); self.viewers.remove(&id); + self.signals.remove(&id); + if self.spotlight == Some(id) { + self.spotlight = None; + } match self.peers.remove(&id) { Some(_) => vec![Outgoing::Others(id, ServerMsg::PeerLeft { client_id: id })], None => Vec::new(), @@ -343,6 +364,20 @@ impl Server { self.presence.insert(from, p.clone()); vec![Outgoing::Others(from, ServerMsg::Presence { client_id: from, presence: p })] } + // Checked and cleaned, then passed on to those who read signals; never stored but the + // current spotlight. + ClientMsg::Signal(sig) => { + let Some(sig) = sig.clean().filter(|s| s.kind != Signal::READY) else { return Vec::new() }; + if sig.kind == Signal::SPOTLIGHT { + if sig.on() { + self.spotlight = Some(from); + } else if self.spotlight == Some(from) { + self.spotlight = None; + } + } + let to = self.signals.iter().filter(|id| **id != from); + to.map(|id| Outgoing::To(*id, ServerMsg::Signal { client_id: from, signal: sig.clone() })).collect() + } ClientMsg::Ack { seq } => { self.acked.insert(from, seq.min(self.seq)); Vec::new() diff --git a/crates/collab/src/signal_tests.rs b/crates/collab/src/signal_tests.rs new file mode 100644 index 0000000..a37933d --- /dev/null +++ b/crates/collab/src/signal_tests.rs @@ -0,0 +1,123 @@ +//! Signals (cursor chat, spotlight): who gets them, how the server cleans them, and what a +//! newcomer is told. + +use figcraft_doc::Document; + +use crate::message::{ClientMsg, Hello, ServerMsg, Signal, client_version, reads_signals}; +use crate::{Client, Limits, Outgoing, Server}; + +fn hello(version: &str) -> Hello { + let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!("not a hello") }; + Hello { client_version: version.into(), ..h } +} + +fn server() -> Server { + Server::new(Document::new("Shared"), "", Limits::default()) +} + +/// The signals among `out` for client `to`, as (from, signal). +fn signals(out: &[Outgoing], to: u32) -> Vec<(u32, Signal)> { + out.iter() + .filter_map(|o| match o { + Outgoing::To(t, ServerMsg::Signal { client_id, signal }) if *t == to => Some((*client_id, signal.clone())), + Outgoing::All(ServerMsg::Signal { .. }) | Outgoing::Others(_, ServerMsg::Signal { .. }) => panic!("signals go to readers only"), + _ => None, + }) + .collect() +} + +#[test] +fn the_client_version_announces_signals() { + assert!(reads_signals(&client_version())); + assert!(reads_signals("0.4.0 signals")); + assert!(!reads_signals("0.3.1")); + assert!(!reads_signals("0.3.1 signalsx")); +} + +#[test] +fn only_clients_that_read_signals_get_them() { + let mut s = server(); + let (new, out) = s.join(hello(&client_version())).unwrap(); + assert_eq!(signals(&out, new), vec![(new, Signal::ready())], "the server says it takes signals, after the welcome"); + assert!(matches!(out.first(), Some(Outgoing::To(_, ServerMsg::Welcome { .. })))); + let (old, out) = s.join(hello("0.3.1")).unwrap(); + assert!(signals(&out, old).is_empty(), "an older client can't read them"); + let (other, _) = s.join(hello(&client_version())).unwrap(); + // The old client's chat reaches the readers; nothing goes back to it or to the sender. + let out = s.receive(old, ClientMsg::Signal(Signal::chat("hi"))); + assert_eq!(signals(&out, new), vec![(old, Signal::chat("hi"))]); + assert_eq!(signals(&out, other), vec![(old, Signal::chat("hi"))]); + assert_eq!(out.len(), 2); + let out = s.receive(new, ClientMsg::Signal(Signal::chat("yo"))); + assert!(signals(&out, old).is_empty() && signals(&out, new).is_empty()); + assert_eq!(signals(&out, other), vec![(new, Signal::chat("yo"))]); + // Gone: no more signals for it. + s.leave(other); + assert!(s.receive(new, ClientMsg::Signal(Signal::chat("still here?"))).is_empty()); +} + +#[test] +fn signals_are_cleaned_and_unknown_ones_dropped() { + let mut s = server(); + let (a, _) = s.join(hello(&client_version())).unwrap(); + let (b, _) = s.join(hello(&client_version())).unwrap(); + let send = |s: &mut Server, sig: Signal| signals(&s.receive(a, ClientMsg::Signal(sig)), b); + // Control characters become spaces; long messages are cut to the limit (in characters). + let got = send(&mut s, Signal::chat("one\ntwo\u{7}\u{202e}")); + assert_eq!(got[0].1.text, "one two \u{202e}"); + let long = "é".repeat(300); + let got = send(&mut s, Signal::chat(&long)); + assert_eq!(got[0].1.text.chars().count(), Signal::MAX_CHAT); + // Blank is closed. + assert_eq!(send(&mut s, Signal::chat(" \t ")), vec![(a, Signal::chat(""))]); + // Unknown kinds, oversized ones, a client's "ready" and malformed spotlights go nowhere. + for junk in [ + Signal { kind: "highFive".into(), text: String::new() }, + Signal { kind: "chat".into(), text: "x".repeat(Signal::MAX_BYTES * 4) }, + Signal::ready(), + Signal { kind: "spotlight".into(), text: "yes".into() }, + Signal { kind: String::new(), text: String::new() }, + ] { + assert!(s.receive(a, ClientMsg::Signal(junk.clone())).is_empty(), "{junk:?}"); + } +} + +#[test] +fn a_newcomer_learns_who_spotlights() { + let mut s = server(); + let (a, _) = s.join(hello(&client_version())).unwrap(); + s.receive(a, ClientMsg::Signal(Signal::spotlight(true))); + let (b, out) = s.join(hello(&client_version())).unwrap(); + assert_eq!(signals(&out, b), vec![(b, Signal::ready()), (a, Signal::spotlight(true))]); + // Someone else's spotlight takes over; stopping one that isn't current changes nothing. + s.receive(b, ClientMsg::Signal(Signal::spotlight(true))); + s.receive(a, ClientMsg::Signal(Signal::spotlight(false))); + let (c, out) = s.join(hello(&client_version())).unwrap(); + assert_eq!(signals(&out, c), vec![(c, Signal::ready()), (b, Signal::spotlight(true))]); + // The presenter leaving ends it. + s.leave(b); + let (d, out) = s.join(hello(&client_version())).unwrap(); + assert_eq!(signals(&out, d), vec![(d, Signal::ready())]); + // Stopping it does too. + s.receive(c, ClientMsg::Signal(Signal::spotlight(true))); + s.receive(c, ClientMsg::Signal(Signal::spotlight(false))); + let (e, out) = s.join(hello(&client_version())).unwrap(); + assert_eq!(signals(&out, e), vec![(e, Signal::ready())]); +} + +#[test] +fn signals_never_reach_the_document_or_the_log() { + let mut s = server(); + s.journaling(true); + let (a, _) = s.join(hello(&client_version())).unwrap(); + s.take_journal(); + let seq = s.seq(); + let before = s.state().unwrap(); + s.receive(a, ClientMsg::Signal(Signal::chat("hello"))); + s.receive(a, ClientMsg::Signal(Signal::spotlight(true))); + assert_eq!(s.seq(), seq); + let after = s.state().unwrap(); + assert_eq!(after.snapshot, before.snapshot); + assert_eq!(after.log, before.log); + assert!(s.take_journal().is_empty(), "nothing for the store to keep"); +} diff --git a/crates/collab/src/sim.rs b/crates/collab/src/sim.rs index ab546f9..d6129b1 100644 --- a/crates/collab/src/sim.rs +++ b/crates/collab/src/sim.rs @@ -140,7 +140,7 @@ impl Sim { let mut rest = Vec::new(); for o in out { match o { - Outgoing::To(to, m) if to == id => welcome = Some(m), + Outgoing::To(to, m @ ServerMsg::Welcome { .. }) if to == id => welcome = Some(m), o => rest.push(o), } } diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index 98476e0..146453a 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -171,6 +171,8 @@ pub static KEYS: &[Binding] = &[ k(Essential, "file.saveVersion", &["Alt+Cmd+S"]), k(Essential, "file.close", &["Cmd+W"]), k(Essential, "edit.findReplace", &["Cmd+F"]), + // In a live session. + k(Essential, "collab.cursorChat", &["/"]), // Ctrl+Alt+P is Pixel preview on Windows and Linux here. k(Essential, "plugins.runLast", &["Alt+Cmd+P"]).win(&["Ctrl+Alt+Shift+P"]), // Tools diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 13b9ad7..4476f0a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1316,6 +1316,8 @@ mod library_variables_tests; #[cfg(test)] mod live_access_tests; #[cfg(test)] +mod live_signal_tests; +#[cfg(test)] mod live_tests; #[cfg(test)] mod mask_tests; diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index c98b50d..69aea5d 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -21,12 +21,17 @@ //! syncs that tab through the server, whose share links let others edit or only view. A view link //! makes the synced document read-only here (every edit is refused before it is sent; the server //! refuses it too). +//! +//! Cursor chat (`collab.cursorChat`) and spotlights (`collab.spotlight`) travel as signals: +//! short-lived presence the host passes on to everyone, never stored. A spotlight makes everyone +//! follow the person who started it; moving your own view stops following, as with any follow. use std::collections::BTreeMap; use std::net::IpAddr; use std::sync::Arc; use figcraft_collab::Client; +pub use figcraft_collab::message::Signal; use figcraft_collab::message::{ClientMsg, EditingText, LibraryReply, LibraryReq, ServerMsg}; pub use figcraft_collab::message::Access; @@ -171,6 +176,8 @@ pub enum Event { PeerJoined(Peer), PeerLeft(u32), Presence(u32, Presence), + /// Someone's cursor chat or spotlight, or (about you) the host's [`Signal::READY`]. + Signal(u32, Signal), /// For the document sync client: the welcome (with the whole document), or a batch of /// changes the server ordered, or the rejection of one of yours. Sync(ServerMsg), @@ -310,6 +317,16 @@ pub struct Live { pub links: Option>, /// The file you published to a server in this session, with its links. pub published: Option, + /// The host passes cursor chat and spotlights on (it said so after the welcome). + pub signals: bool, + /// Others' open cursor chat messages. + pub chats: BTreeMap, + /// Your cursor chat message (empty: none). + pub chat: String, + /// Who spotlights (you follow them until you move your view). + pub spotlight: Option, + /// You spotlight: everyone follows you. + pub spotlighting: bool, } impl Live { @@ -352,6 +369,11 @@ impl Live { self.deferred.clear(); self.hold = 0; self.published = None; + self.signals = false; + self.chats.clear(); + self.chat.clear(); + self.spotlight = None; + self.spotlighting = false; } /// The document sync client (once the welcome arrived): peers, pending batches, the user's @@ -532,6 +554,12 @@ impl Session { 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(); } @@ -563,6 +591,10 @@ impl Session { } Event::PeerLeft(id) => { live.presence.remove(&id); + live.chats.remove(&id); + if live.spotlight == Some(id) { + live.spotlight = None; + } if let Some(p) = live.peers.remove(&id) { self.message = Some(format!("{} left", p.name)); } @@ -576,6 +608,7 @@ impl Session { live.presence.insert(id, p); } } + Event::Signal(id, sig) => self.live_signal_in(id, sig), // Back after a dropped connection: the client catches up in order with the rest. Event::Sync(msg @ ServerMsg::Welcome { .. }) if live.client.is_some() => live.deferred.push(msg), Event::Sync(ServerMsg::Welcome { .. }) => { @@ -598,6 +631,98 @@ impl Session { } } + /// Someone's cursor chat or spotlight (checked again: the host is untrusted too), or the host + /// saying it reads them. + fn live_signal_in(&mut self, id: u32, sig: Signal) { + let Some(sig) = sig.clean() else { return }; + let live = &mut self.live; + let name = live.peers.get(&id).map(|p| p.name.clone()); + match (sig.kind.as_str(), name) { + (Signal::READY, _) if live.info.as_ref().is_some_and(|i| i.me == id) => { + live.signals = true; + if live.spotlighting + && let Some(l) = live.link.as_mut() + { + l.send(ClientMsg::Signal(Signal::spotlight(true))); + } + } + (Signal::CHAT, Some(_)) if sig.text.is_empty() => { + live.chats.remove(&id); + } + (Signal::CHAT, Some(_)) => { + live.chats.insert(id, sig.text); + } + (Signal::SPOTLIGHT, Some(name)) if sig.on() => { + // Theirs takes over from yours or anyone else's. + live.spotlight = Some(id); + live.spotlighting = false; + live.follow = Some(id); + live.followed = None; + self.message = Some(format!("{name} is spotlighting: you follow their view (move it to stop)")); + } + (Signal::SPOTLIGHT, Some(name)) if live.spotlight == Some(id) => { + live.spotlight = None; + if live.follow == Some(id) { + live.follow = None; + live.followed = None; + } + self.message = Some(format!("{name} stopped spotlighting")); + } + _ => {} + } + } + + /// Why cursor chat or a spotlight can't go out now, if it can't. + fn signal_blocker(&self) -> Option<&'static str> { + if self.live.status != Status::Live { + Some("cursor chat and spotlight need a live session") + } else if !self.live.signals { + Some("the session's host runs an older version without cursor chat and spotlight") + } else { + None + } + } + + fn live_signal_out(&mut self, sig: Signal) { + if let Some(l) = self.live.link.as_mut() { + l.send(ClientMsg::Signal(sig)); + } + } + + /// Show `text` next to your pointer for everyone (empty closes it); returns it as sent + /// (control characters out, at most [`Signal::MAX_CHAT`] characters). + pub fn live_chat(&mut self, text: &str) -> Result { + let sig = Signal::chat(text).clean().unwrap_or_default(); + if sig.text.is_empty() && self.live.chat.is_empty() { + return Ok(String::new()); + } + if let Some(why) = self.signal_blocker() { + return Err(EngineError::Disabled("collab.cursorChat".into(), why.into())); + } + if sig.text != self.live.chat { + self.live.chat.clone_from(&sig.text); + self.live_signal_out(sig); + } + Ok(self.live.chat.clone()) + } + + /// Start (`on`) or stop asking everyone to follow your view. + pub fn live_spotlight(&mut self, on: bool) -> Result<()> { + if on == self.live.spotlighting { + return Ok(()); + } + if on && let Some(why) = self.signal_blocker() { + return Err(EngineError::Disabled("collab.spotlight".into(), why.into())); + } + if on { + self.live_unfollow(); + self.live.spotlight = None; + } + self.live.spotlighting = on; + self.live_signal_out(Signal::spotlight(on)); + Ok(()) + } + /// The welcome: start the sync client; the host keeps editing the document it shared, a guest /// opens the host's file as a new tab (or gets it again in that tab after a reconnect). fn live_welcome(&mut self, welcome: ServerMsg) { @@ -905,7 +1030,8 @@ impl Session { .map(|p| { let presence = l.presence.get(&p.client_id); let typing = presence.and_then(|pr| pr.editing_text.as_deref()).map(editing); - json!({"id": p.client_id, "name": p.name, "color": p.color, "editing": typing, "presence": presence}) + let chat = l.chats.get(&p.client_id); + json!({"id": p.client_id, "name": p.name, "color": p.color, "editing": typing, "chat": chat, "presence": presence}) }) .collect(); let mine = synced.and_then(|d| d.text_edit.as_ref()).map(|t| editing(&editing_of(t))); @@ -929,6 +1055,10 @@ impl Session { "readOnly": self.active_read_only().is_some(), "published": l.published, "follow": l.follow, + "signals": l.signals, + "chat": l.chat, + "spotlight": l.spotlight, + "spotlighting": l.spotlighting, "syncing": l.syncing(), "document": l.doc_uid.and_then(|u| self.docs.iter().position(|d| d.uid == u)), "editing": mine, @@ -1030,6 +1160,28 @@ fn cursor(s: &mut Session, p: &Value) -> Result { Ok(json!({"cursor": s.live.cursor.map(|(x, y)| [x, y])})) } +fn cursor_chat(s: &mut Session, p: &Value) -> Result { + let Some(v) = get(p, "text") else { + // The app's chat box (programmatic calls never open dialogs). + let open = s.signal_blocker().is_none(); + if open { + s.request_ui("dialog.cursorChat", json!({})); + } + return Ok(json!({"open": open, "chat": s.live.chat})); + }; + let text = v.as_str().ok_or_else(|| bad("collab.cursorChat", "`text` must be a string (empty closes the chat)"))?; + Ok(json!({"chat": s.live_chat(text)?})) +} + +fn spotlight(s: &mut Session, p: &Value) -> Result { + let on = match get(p, "on") { + None => !s.live.spotlighting, + Some(v) => v.as_bool().ok_or_else(|| bad("collab.spotlight", "`on` must be true or false"))?, + }; + s.live_spotlight(on)?; + Ok(json!({"spotlighting": s.live.spotlighting})) +} + pub fn specs() -> Vec { vec![ spec!( @@ -1070,5 +1222,19 @@ pub fn specs() -> Vec { false, cursor ), + spec!( + "collab.cursorChat", + "Cursor chat", + "{text? (shown next to your cursor for everyone, at most 100 characters; empty closes it; omit to open the chat box in the app)} → {chat} (or {open}); others see it fade after 5 s without changes", + false, + cursor_chat + ), + spec!( + "collab.spotlight", + "Spotlight me", + "{on? (default: toggle)} → {spotlighting}: everyone in the session follows your view and page until they move theirs", + false, + spotlight + ), ] } diff --git a/crates/engine/src/live_signal_tests.rs b/crates/engine/src/live_signal_tests.rs new file mode 100644 index 0000000..b398a4a --- /dev/null +++ b/crates/engine/src/live_signal_tests.rs @@ -0,0 +1,158 @@ +//! Cursor chat and spotlights in the engine's live state, against the scripted link. + +use std::sync::{Arc, Mutex}; + +use figcraft_collab::message::ClientMsg; +use serde_json::json; + +use crate::live::{Event, Peer, Presence, Role, Signal}; +use crate::live_tests::{Script, info, push, session}; +use crate::{EngineError, Session}; + +fn peer(id: u32, name: &str) -> Peer { + Peer { client_id: id, name: name.into(), color: "#9747FF".into() } +} + +/// The signals sent so far. +fn sent(script: &Arc>) -> Vec { + script.lock().unwrap().msgs.iter().filter_map(|m| if let ClientMsg::Signal(s) = m { Some(s.clone()) } else { None }).collect() +} + +/// Joined as client 2 with Hana (1) and Ada (3) in the session; `ready`: the host reads signals. +fn joined(ready: bool) -> (Session, Arc>) { + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + let mut events = vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana"), peer(3, "Ada")] }]; + if ready { + events.push(Event::Signal(2, Signal::ready())); + } + push(&script, events); + s.live_poll(); + (s, script) +} + +#[test] +fn chat_needs_a_session_whose_host_reads_it() { + let (mut s, _) = session(); + assert!(matches!(s.execute("collab.cursorChat", &json!({"text": "hi"})), Err(EngineError::Disabled(..)))); + // Opening the box outside a session does nothing (the `/` key stays quiet). + assert_eq!(s.execute("collab.cursorChat", &json!({})).unwrap()["open"], false); + assert!(s.ui_requests.is_empty()); + assert!(s.execute("collab.spotlight", &json!({"on": true})).is_err()); + let (mut s, script) = joined(false); + let err = s.execute("collab.cursorChat", &json!({"text": "hi"})).unwrap_err().to_string(); + assert!(err.contains("older version"), "{err}"); + assert!(sent(&script).is_empty()); + // Someone else's "ready" isn't the host speaking to you. + push(&script, vec![Event::Signal(1, Signal::ready())]); + s.live_poll(); + assert!(!s.live.signals); + push(&script, vec![Event::Signal(2, Signal::ready())]); + s.live_poll(); + assert!(s.live.signals); + assert_eq!(s.live_summary()["signals"], true); +} + +#[test] +fn your_chat_goes_out_cleaned_once_per_change() { + let (mut s, script) = joined(true); + assert_eq!(s.execute("collab.cursorChat", &json!({})).unwrap()["open"], true); + assert_eq!(s.ui_requests.last().map(|r| r.kind.as_str()), Some("dialog.cursorChat")); + let r = s.execute("collab.cursorChat", &json!({"text": "hello\nthere"})).unwrap(); + assert_eq!(r["chat"], "hello there"); + s.live_chat("hello there").unwrap(); + let long = "x".repeat(500); + assert_eq!(s.live_chat(&long).unwrap().len(), Signal::MAX_CHAT); + s.execute("collab.cursorChat", &json!({"text": ""})).unwrap(); + s.execute("collab.cursorChat", &json!({"text": ""})).unwrap(); + let texts: Vec = sent(&script).into_iter().map(|s| s.text).collect(); + assert_eq!(texts, ["hello there".to_string(), "x".repeat(Signal::MAX_CHAT), String::new()], "unchanged text isn't sent again"); + assert!(s.execute("collab.cursorChat", &json!({"text": 5})).is_err()); + assert_eq!(s.live_summary()["chat"], ""); +} + +#[test] +fn others_chat_shows_until_closed_or_they_leave() { + let (mut s, script) = joined(true); + push( + &script, + vec![ + Event::Signal(1, Signal::chat("hey")), + Event::Signal(3, Signal::chat("bad\u{1b}[2J")), + // Not in the session, unknown kinds and malformed signals: ignored. + Event::Signal(9, Signal::chat("ghost")), + Event::Signal(1, Signal { kind: "highFive".into(), text: String::new() }), + Event::Signal(1, Signal { kind: "spotlight".into(), text: "maybe".into() }), + ], + ); + s.live_poll(); + assert_eq!(s.live.chats.get(&1).map(String::as_str), Some("hey")); + assert_eq!(s.live.chats.get(&3).map(String::as_str), Some("bad [2J"), "control characters never reach the canvas"); + assert_eq!(s.live.chats.len(), 2); + assert_eq!(s.live.spotlight, None); + let peers = s.live_summary()["peers"].clone(); + assert_eq!(peers[0]["chat"], "hey"); + push(&script, vec![Event::Signal(1, Signal::chat("")), Event::PeerLeft(3)]); + s.live_poll(); + assert!(s.live.chats.is_empty()); +} + +#[test] +fn a_spotlight_makes_you_follow_until_you_move() { + let (mut s, script) = joined(true); + push(&script, vec![Event::Signal(1, Signal::spotlight(true)), Event::Presence(1, Presence::default())]); + s.live_poll(); + assert_eq!((s.live.spotlight, s.live.follow), (Some(1), Some(1))); + assert!(s.message.take().unwrap().contains("Hana is spotlighting")); + // Moving your view stops following; the spotlight goes on (the banner offers to follow again). + push(&script, vec![Event::Presence(1, Presence { viewport: Some((s.page().unwrap(), 0.0, 0.0, 600.0, 400.0)), ..Default::default() })]); + s.live_poll(); + s.ds_mut().unwrap().view.cx += 50.0; + s.live_sync(); + assert_eq!((s.live.spotlight, s.live.follow), (Some(1), None)); + s.execute("collab.follow", &json!({"client": 1})).unwrap(); + // Ada takes over; then she stops and nobody is followed. + push(&script, vec![Event::Signal(3, Signal::spotlight(true))]); + s.live_poll(); + assert_eq!((s.live.spotlight, s.live.follow), (Some(3), Some(3))); + push(&script, vec![Event::Signal(1, Signal::spotlight(false))]); + s.live_poll(); + assert_eq!(s.live.spotlight, Some(3), "an old presenter stopping changes nothing"); + push(&script, vec![Event::Signal(3, Signal::spotlight(false))]); + s.live_poll(); + assert_eq!((s.live.spotlight, s.live.follow), (None, None)); + // The presenter leaving ends it too. + push(&script, vec![Event::Signal(1, Signal::spotlight(true)), Event::PeerLeft(1)]); + s.live_poll(); + assert_eq!((s.live.spotlight, s.live.follow), (None, None)); +} + +#[test] +fn spotlighting_yourself() { + let (mut s, script) = joined(true); + s.execute("collab.follow", &json!({"client": 1})).unwrap(); + assert_eq!(s.execute("collab.spotlight", &json!({})).unwrap()["spotlighting"], true); + assert_eq!(s.live.follow, None, "a presenter follows nobody"); + s.execute("collab.spotlight", &json!({"on": true})).unwrap(); + assert_eq!(sent(&script), vec![Signal::spotlight(true)], "on twice is sent once"); + assert!(s.execute("collab.spotlight", &json!({"on": "yes"})).is_err()); + // Someone else's spotlight takes over yours. + push(&script, vec![Event::Signal(3, Signal::spotlight(true))]); + s.live_poll(); + assert!(!s.live.spotlighting); + assert_eq!(s.live.follow, Some(3)); + s.execute("collab.spotlight", &json!({"on": true})).unwrap(); + // A reconnect: the host hears it again once it reads signals again. + push(&script, vec![Event::Status(crate::live::Status::Reconnecting { attempt: 1 })]); + s.live_poll(); + assert!(!s.live.signals && s.live.spotlighting); + push(&script, vec![Event::Welcome { info: info(Role::Guest, 2), peers: vec![peer(1, "Hana")] }, Event::Signal(2, Signal::ready())]); + s.live_poll(); + assert_eq!(sent(&script).last(), Some(&Signal::spotlight(true))); + assert_eq!(sent(&script).len(), 3); + s.execute("collab.spotlight", &json!({})).unwrap(); + assert_eq!(sent(&script).last(), Some(&Signal::spotlight(false))); + // Leaving forgets it all. + s.execute("collab.leave", &json!({})).unwrap(); + assert!(!s.live.spotlighting && !s.live.signals && s.live.chat.is_empty()); +} diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index 34109a7..e7207f7 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -288,6 +288,8 @@ fn collab_commands_survive_junk() { json!([1, 2]), json!({"port": 1e300, "bind": 5, "link": 7, "client": -3, "x": "NaN", "y": {}}), json!({"link": "", "client": 4_294_967_296u64}), + json!({"text": 5, "on": "yes"}), + json!({"text": "\u{0}\u{7f}".repeat(10_000), "on": null}), ]; for cmd in crate::all_commands().iter().filter(|c| c.id.starts_with("collab.")) { for j in &junk { diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index bb8622d..9612734 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -301,6 +301,7 @@ name = "English" "Ctrl+scroll" = "Ctrl+scroll" "Current name" = "Current name" "Current version" = "Current version" +"Cursor chat" = "Cursor chat" "Custom" = "Custom" "Cut" = "Cut" "Dark" = "Dark" @@ -466,6 +467,7 @@ name = "English" "Fold autosaves" = "Fold autosaves" "Folder: click to collapse" = "Folder: click to collapse" "Folder: click to expand" = "Folder: click to expand" +"Follow" = "Follow" "Follow {name}" = "Follow {name}" "Following" = "Following" "Following {name} · {key} to stop" = "Following {name} · {key} to stop" @@ -995,6 +997,7 @@ name = "English" "Save version {key}" = "Save version {key}" "Saved" = "Saved" "Saved {name}" = "Saved {name}" +"Say something" = "Say something" "Scale" = "Scale" "Scatter" = "Scatter" "Scoping: where pickers offer it" = "Scoping: where pickers offer it" @@ -1088,6 +1091,7 @@ name = "English" "Space+drag" = "Space+drag" "Spacing" = "Spacing" "Splay" = "Splay" +"Spotlight me" = "Spotlight me" "Spread" = "Spread" "Square" = "Square" "Standard" = "Standard" @@ -1102,8 +1106,10 @@ name = "English" "Start point" = "Start point" "Status" = "Status" "Sticky" = "Sticky" +"Stop" = "Stop" "Stop following {name}" = "Stop following {name}" "Stop session" = "Stop session" +"Stop spotlighting" = "Stop spotlighting" "Stop {n} {hex}" = "Stop {n} {hex}" "Stretch" = "Stretch" "Strikethrough" = "Strikethrough" @@ -1274,6 +1280,7 @@ name = "English" "Yesterday" = "Yesterday" "You" = "You" "You'll get the host's file as a new tab and edit it together." = "You'll get the host's file as a new tab and edit it together." +"You're spotlighting: everyone follows your view" = "You're spotlighting: everyone follows your view" "Your copy of the file stays open" = "Your copy of the file stays open" "Your name" = "Your name" "Your name in comments" = "Your name in comments" @@ -1311,6 +1318,7 @@ name = "English" "{instances} in {files}" = "{instances} in {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: draft translation, waiting for a native speaker's review." "{length} bleed" = "{length} bleed" +"{name} is spotlighting" = "{name} is spotlighting" "{name}\nClick to apply to the selection" = "{name}\nClick to apply to the selection" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." "{name} (from {library})" = "{name} (from {library})" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index cd9c015..c41ed33 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -302,6 +302,7 @@ status = "draft — needs native review" "Ctrl+scroll" = "Ctrl+desplazar" "Current name" = "Nombre actual" "Current version" = "Versión actual" +"Cursor chat" = "Chat del cursor" "Custom" = "Personalizado" "Cut" = "Cortar" "Dark" = "Oscuro" @@ -467,6 +468,7 @@ status = "draft — needs native review" "Fold autosaves" = "Plegar autoguardados" "Folder: click to collapse" = "Carpeta: clic para contraer" "Folder: click to expand" = "Carpeta: clic para expandir" +"Follow" = "Seguir" "Follow {name}" = "Seguir a {name}" "Following" = "Siguiendo" "Following {name} · {key} to stop" = "Siguiendo a {name} · {key} para parar" @@ -996,6 +998,7 @@ status = "draft — needs native review" "Save version {key}" = "Guardar versión {key}" "Saved" = "Guardado" "Saved {name}" = "Guardado {name}" +"Say something" = "Escribe algo" "Scale" = "Escala" "Scatter" = "Dispersión" "Scoping: where pickers offer it" = "Ámbito: dónde la ofrecen los selectores" @@ -1089,6 +1092,7 @@ status = "draft — needs native review" "Space+drag" = "Espacio+arrastrar" "Spacing" = "Espaciado" "Splay" = "Apertura" +"Spotlight me" = "Ponerme en el foco" "Spread" = "Extensión" "Square" = "Cuadrado" "Standard" = "Estándar" @@ -1103,8 +1107,10 @@ status = "draft — needs native review" "Start point" = "Punto inicial" "Status" = "Estado" "Sticky" = "Adherido" +"Stop" = "Parar" "Stop following {name}" = "Dejar de seguir a {name}" "Stop session" = "Detener sesión" +"Stop spotlighting" = "Salir del foco" "Stop {n} {hex}" = "Parada {n} {hex}" "Stretch" = "Estirar" "Strikethrough" = "Tachado" @@ -1275,6 +1281,7 @@ status = "draft — needs native review" "Yesterday" = "Ayer" "You" = "Tú" "You'll get the host's file as a new tab and edit it together." = "Recibirás el archivo del anfitrión en una pestaña nueva para editarlo en conjunto." +"You're spotlighting: everyone follows your view" = "Estás en el foco: todos siguen tu vista" "Your copy of the file stays open" = "Tu copia del archivo sigue abierta" "Your name" = "Tu nombre" "Your name in comments" = "Tu nombre en los comentarios" @@ -1312,6 +1319,7 @@ status = "draft — needs native review" "{instances} in {files}" = "{instances} en {files}" "{language}: draft translation, waiting for a native speaker's review." = "{language}: traducción en borrador, pendiente de revisión por un hablante nativo." "{length} bleed" = "sangrado de {length}" +"{name} is spotlighting" = "{name} está en el foco" "{name}\nClick to apply to the selection" = "{name}\nHaz clic para aplicarlo a la selección" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nArrastra al lienzo o haz clic para colocarlo en el centro de la vista.\nMantén Alt (Opción) al soltar sobre una instancia para intercambiarla." "{name} (from {library})" = "{name} (de {library})" @@ -1414,6 +1422,7 @@ status = "draft — needs native review" "codeConnect.remove" = "Desconectar del código" "codeConnect.set" = "Conectar con código" "collab.cursor" = "Mostrar cursor en" +"collab.cursorChat" = "Chat del cursor" "collab.diff" = "Mostrar cambios sincronizados" "collab.follow" = "Seguir" "collab.host" = "Iniciar sesión en vivo" @@ -1422,6 +1431,7 @@ status = "draft — needs native review" "collab.peers" = "Personas en este archivo" "collab.publish" = "Publicar en el servidor" "collab.registerLinks" = "Abrir enlaces de invitación en FigCraft" +"collab.spotlight" = "Ponerme en el foco" "collab.stop" = "Detener sesión en vivo" "command.batch" = "Ejecutar varios comandos" "comment.add" = "Añadir comentario" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 61a8b97..1a6109b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -214,6 +214,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { crate::comments::draw(app, &painter); crate::live_text::draw(app, &painter); crate::live::draw(app, &painter, &ctx); + crate::live::chat_box(app, &ctx); + crate::live::spotlight_banner(app, &ctx, rect); crate::comments::popovers(app, &ctx); crate::versions::preview_banner(app, &ctx, rect); crate::branch_ui::banner(app, &ctx, rect); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7ae5779..e297a0c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -85,6 +85,8 @@ mod layers_keys_tests; #[cfg(test)] mod layers_rename_tests; #[cfg(test)] +mod live_chat_tests; +#[cfg(test)] mod live_join_tests; #[cfg(test)] mod live_server_tests; @@ -601,6 +603,7 @@ impl FigApp { } "dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")), "dialog.plugins" => self.ui.plugins_window = true, + "dialog.cursorChat" => live::open_chat(self), "window.new" | "window.focus" => self.window_requests.push(r), _ => {} } diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 429f436..f5dc2fb 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -5,11 +5,15 @@ //! Publish to server dialog. Everything acts through the `collab.*` commands; the state is the engine's //! (`Session::live`). The network wakes the UI when something arrives, and cursors only ask for //! frames while they glide, so an idle session draws nothing. +//! +//! Cursor chat: `/` opens a bubble at your pointer to type in; others see it next to your cursor +//! and it fades a few seconds after the last change (frames are asked for only until then). +//! Spotlight: your avatar's menu asks everyone to follow you; a banner says so and stops it. use std::collections::BTreeMap; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2}; -use figcraft_engine::live::{Access, PEOPLE, Role, Status, initials, parse_color, presence_page}; +use figcraft_engine::live::{Access, PEOPLE, Role, Signal, Status, initials, parse_color, presence_page}; use serde_json::json; use crate::FigApp; @@ -40,6 +44,24 @@ const PUBLISHED_NOTE: &str = tr_noop( const GLIDE: f64 = 20.0; /// Most of one person's selection outlined. const MAX_OUTLINES: usize = 500; +/// A cursor chat message shows this long after its last change, then fades out over +/// `CHAT_FADE` (seconds); your chat box closes when you haven't typed for both. +const CHAT_HOLD: f64 = 5.0; +const CHAT_FADE: f64 = 1.0; +/// Widest a chat bubble's text gets before it wraps. +const CHAT_WIDTH: f32 = 220.0; +/// A speech bubble's corners: sharp at the pointer. +const BUBBLE: CornerRadius = CornerRadius { nw: 2, ne: 14, sw: 14, se: 14 }; + +/// Your cursor chat. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ChatBox { + pub text: String, + /// When you last typed (UI clock; `None` until the box first shows). + typed: Option, + /// Typing; else sent, showing until it fades. + pub editing: bool, +} /// UI-only live session state. #[derive(Default)] @@ -62,6 +84,10 @@ pub struct LiveUi { focus_publish: bool, /// The last server address published to (kept for this run only: it carries the server key). last_server: String, + /// Others' chat messages as last seen, with when they changed (UI clock): they fade from then. + chat_seen: BTreeMap, + /// Your cursor chat (open when `Some`). + pub chat: Option, } /// What the Publish to server dialog asks for. @@ -121,6 +147,41 @@ pub(crate) fn name_tag(painter: &egui::Painter, anchor: Pos2, align: Align2, nam painter.galley(r.min + vec2(6.0, 3.0), galley, text_on(fill)); } +/// How opaque a chat message `age` seconds after its last change is (0: gone); asks for the frame +/// where it next looks different. +fn chat_fade(ctx: &egui::Context, age: f64, reduce: bool) -> f32 { + let after = |s: f64| ctx.request_repaint_after(std::time::Duration::from_secs_f64(s.max(0.0))); + if age < CHAT_HOLD { + after(CHAT_HOLD - age); + 1.0 + } else if age >= CHAT_HOLD + CHAT_FADE { + 0.0 + } else if reduce { + after(CHAT_HOLD + CHAT_FADE - age); + 1.0 + } else { + ctx.request_repaint(); + (1.0 - (age - CHAT_HOLD) / CHAT_FADE) as f32 + } +} + +/// A cursor chat bubble next to the pointer at `tip`: the person's name (when given) over their +/// message, in their colour, `alpha` opaque. +fn chat_bubble(painter: &egui::Painter, tip: Pos2, name: Option<&str>, text: &str, fill: Color32, alpha: f32) { + let fg = text_on(fill).gamma_multiply(alpha); + let body = painter.layout(text.to_string(), regular(12.0), fg, CHAT_WIDTH); + let head = name.map(|n| painter.layout_no_wrap(n.to_string(), semibold(10.0), fg.gamma_multiply(0.8))); + let head_h = head.as_ref().map_or(0.0, |g| g.size().y + 2.0); + let w = body.size().x.max(head.as_ref().map_or(0.0, |g| g.size().x)); + let r = Rect::from_min_size(tip + vec2(12.0, 16.0), vec2(w + 20.0, head_h + body.size().y + 12.0)); + painter.rect_filled(r, BUBBLE, fill.gamma_multiply(alpha)); + let x = r.min.x + 10.0; + if let Some(g) = head { + painter.galley(pos2(x, r.min.y + 6.0), g, fg); + } + painter.galley(pos2(x, r.min.y + 6.0 + head_h), body, fg); +} + /// Hand the pointer's page position to the session when it moved (or the view under it did). pub fn track_pointer(app: &mut FigApp, ctx: &egui::Context) { if app.session.live.status != Status::Live { @@ -139,8 +200,18 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter, ctx: &egui::Context) { let live = &app.session.live; if live.peers.is_empty() { app.ui.live.shown.clear(); + app.ui.live.chat_seen.clear(); return; } + let now = ctx.input(|i| i.time); + // A chat message fades from its last change. + let seen = &mut app.ui.live.chat_seen; + seen.retain(|id, _| live.chats.contains_key(id)); + for (id, text) in &live.chats { + if seen.get(id).is_none_or(|(t, _)| t != text) { + seen.insert(*id, (text.clone(), now)); + } + } let Ok(ds) = app.session.ds() else { return }; let (doc, page, shared) = (ds.doc.clone(), ds.page, app.session.live_shared()); let mut cursors = Vec::new(); @@ -169,10 +240,10 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter, ctx: &egui::Context) { if let Some(peer) = live.follow.and_then(|f| live.peers.get(&f)) { follow_frame(painter, app.canvas.rect, &peer.name, color(&peer.color)); } - let now = ctx.input(|i| i.time); let dt = (now - app.ui.live.last).clamp(0.0, 0.1); app.ui.live.last = now; - let k = if app.reduce_motion() { 1.0 } else { 1.0 - (-dt * GLIDE).exp() }; + let reduce = app.reduce_motion(); + let k = if reduce { 1.0 } else { 1.0 - (-dt * GLIDE).exp() }; let zoom = ds.view.zoom.max(1e-9); let mut gliding = false; let mut shown = BTreeMap::new(); @@ -189,7 +260,12 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter, ctx: &egui::Context) { shown.insert(id, (sx, sy)); let tip = app.to_screen(sx, sy); pointer(painter, tip, fill); - name_tag(painter, tip + vec2(12.0, 16.0), Align2::LEFT_TOP, &name, fill); + // Their chat message takes the name tag's place while it shows. + let chat = app.ui.live.chat_seen.get(&id).map(|(text, at)| (text, chat_fade(ctx, now - at, reduce))).filter(|(_, a)| *a > 0.0); + match chat { + Some((text, alpha)) => chat_bubble(painter, tip, Some(&name), text, fill, alpha), + None => name_tag(painter, tip + vec2(12.0, 16.0), Align2::LEFT_TOP, &name, fill), + } } app.ui.live.shown = shown; if gliding { @@ -197,6 +273,114 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter, ctx: &egui::Context) { } } +/// Open your cursor chat (`/`, `collab.cursorChat` without text), with your current message. +pub fn open_chat(app: &mut FigApp) { + if app.ui.live.chat.as_ref().is_none_or(|c| !c.editing) { + app.ui.live.chat = Some(ChatBox { text: app.session.live.chat.clone(), typed: None, editing: true }); + } +} + +/// Your cursor chat at your pointer: a bubble to type in. What you type shows to everyone as you +/// type; Enter (or clicking away) sends it and closes the box, and the message fades; Escape takes +/// it back at once. The box closes by itself when you haven't typed for a while. +pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) { + let Some(mut chat) = app.ui.live.chat.take() else { return }; + if app.session.live.status != Status::Live { + return; + } + let now = ctx.input(|i| i.time); + let first = chat.typed.is_none(); + let typed = *chat.typed.get_or_insert(now); + let live = &app.session.live; + let fill = live.info.as_ref().map_or(ACCENT, |i| color(&i.color)); + let tip = live.cursor.map_or(app.canvas.rect.center(), |(x, y)| app.to_screen(x, y)); + let reduce = app.reduce_motion(); + if !chat.editing { + let alpha = chat_fade(ctx, now - typed, reduce); + if alpha <= 0.0 || app.session.live.chat.is_empty() { + let _ = app.session.live_chat(""); + return; + } + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("live_chat"))); + chat_bubble(&painter, tip, None, &chat.text, fill, alpha); + app.ui.live.chat = Some(chat); + return; + } + let idle = now - typed; + if idle >= CHAT_HOLD + CHAT_FADE { + let _ = app.session.live_chat(""); + return; + } + ctx.request_repaint_after(std::time::Duration::from_secs_f64(CHAT_HOLD + CHAT_FADE - idle)); + let fg = text_on(fill); + let resp = egui::Area::new(egui::Id::new("live_chat_box")) + .order(egui::Order::Foreground) + .fixed_pos(tip + vec2(12.0, 16.0)) + .show(ctx, |ui| { + egui::Frame::NONE + .fill(fill) + .corner_radius(BUBBLE) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + let w = ui.painter().layout_no_wrap(chat.text.clone(), regular(12.0), fg).size().x + 8.0; + ui.add( + egui::TextEdit::singleline(&mut chat.text) + .id(egui::Id::new("live_chat_text")) + .frame(egui::Frame::NONE) + .font(regular(12.0)) + .text_color(fg) + .hint_text(egui::RichText::new(tr("Say something")).color(fg.gamma_multiply(0.7))) + .char_limit(Signal::MAX_CHAT) + .desired_width(w.clamp(110.0, CHAT_WIDTH)), + ) + }) + .inner + }) + .inner; + a11y::text(&resp, tr("Cursor chat"), &chat.text); + if first { + resp.request_focus(); + } + if resp.changed() { + chat.typed = Some(now); + if let Err(e) = app.session.live_chat(&chat.text) { + app.toast(e.to_string()); + return; + } + } + if resp.lost_focus() { + // Escape takes it back (before the canvas sees the key: it doesn't deselect). + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) || chat.text.trim().is_empty() { + let _ = app.session.live_chat(""); + return; + } + chat.editing = false; + chat.typed = Some(now); + } + app.ui.live.chat = Some(chat); +} + +/// While you spotlight, or someone does and you stopped following them: a banner on the canvas to +/// stop, or to follow again. +pub fn spotlight_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { + let live = &app.session.live; + let (text, button, cmd, params) = if live.spotlighting { + (tr("You're spotlighting: everyone follows your view").to_string(), tr("Stop"), "collab.spotlight", json!({"on": false})) + } else if let Some(p) = live.spotlight.filter(|p| live.follow != Some(*p)).and_then(|p| live.peers.get(&p)) { + (trf("{name} is spotlighting", &[("name", &p.name)]), tr("Follow"), "collab.follow", json!({"client": p.client_id})) + } else { + return; + }; + let t = Tokens::get(ctx); + let clicked = widgets::canvas_banner(ctx, egui::Id::new("live_spotlight"), canvas, |ui| { + ui.label(egui::RichText::new(text).font(regular(11.0)).color(t.text)); + widgets::button(ui, button, true, true).clicked() + }); + if clicked { + app.run(cmd, params); + } +} + /// A frame around the canvas in the followed person's colour, with their name at the top. fn follow_frame(painter: &egui::Painter, canvas: Rect, name: &str, fill: Color32) { painter.rect_stroke(canvas.shrink(1.0), CornerRadius::ZERO, Stroke::new(2.0, fill), egui::StrokeKind::Inside); @@ -222,12 +406,29 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let me_fill = live.info.as_ref().map_or(t.success, |i| color(&i.color)); let peers: Vec<(u32, String, Color32)> = live.peers.values().map(|p| (p.client_id, p.name.clone(), color(&p.color))).collect(); let follow = live.follow; + let (in_session, spotlighting) = (live.status == Status::Live, live.spotlighting); // Room for the avatars after yours and the chevron. let fit = (((area.width() - 2.0 * R - 18.0) / STEP).floor() as usize).max(1); let (show, more) = if peers.len() > fit { (fit - 1, peers.len() - (fit - 1)) } else { (peers.len(), 0) }; let mut x = area.min.x + R + 2.0; let ring = |ui: &Ui, c: Pos2| ui.painter().circle_stroke(c, R + 1.0, Stroke::new(2.0, t.panel)); avatar(ui.painter(), pos2(x, cy), R, &me_name, me_fill); + // Your avatar's menu: spotlight. + let mut spotlight = false; + if in_session { + let c = pos2(x, cy); + let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.id().with("live_me"), Sense::click()); + a11y::menu_button(&resp, tr("You"), a11y::menu_open(&resp)); + if spotlighting || resp.hovered() { + ui.painter().circle_stroke(c, R + 2.5, Stroke::new(2.0, me_fill)); + } + menu_popup(&resp).width(200.0).show(|ui| { + if menu_item(ui, if spotlighting { tr("Stop spotlighting") } else { tr("Spotlight me") }, "") { + spotlight = true; + ui.close(); + } + }); + } let mut clicked = None; for (id, name, fill) in peers.iter().take(show) { x += STEP; @@ -283,6 +484,9 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let params = if follow == Some(id) { json!({}) } else { json!({"client": id}) }; app.run("collab.follow", params); } + if spotlight { + app.run("collab.spotlight", json!({"on": !spotlighting})); + } } /// The Share button's popover: the live session (start, invite link, stop or leave) and export. @@ -566,6 +770,12 @@ pub fn inspect(app: &FigApp) -> serde_json::Value { "role": l.role(), "peers": l.peers.len(), "follow": l.follow, + "signals": l.signals, + "spotlight": l.spotlight, + "spotlighting": l.spotlighting, + "chat": l.chat, + "chats": l.chats, + "chatBox": app.ui.live.chat.as_ref().map(|c| json!({"text": c.text, "editing": c.editing})), "cursorsShown": app.ui.live.shown(), "joinDialog": app.ui.live.join.is_some(), "publishDialog": app.ui.live.publish.is_some(), diff --git a/crates/ui-egui/src/live_chat_tests.rs b/crates/ui-egui/src/live_chat_tests.rs new file mode 100644 index 0000000..cb22586 --- /dev/null +++ b/crates/ui-egui/src/live_chat_tests.rs @@ -0,0 +1,119 @@ +//! Cursor chat and spotlight drawn headless: others' chat bubbles fading, your chat box (`/`, +//! typing, Enter, Escape), the spotlight banner and your avatar's menu. + +use egui::{Event, Key, Modifiers, pos2}; +use figcraft_engine::live::{Event as LiveEvent, Signal}; + +use crate::live_tests::{Harness, cursor, has_text, text_center, texts}; + +fn key(h: &mut Harness, k: Key, text: Option<&str>) { + let mut down = vec![Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]; + down.extend(text.map(|t| Event::Text(t.into()))); + h.frame(down); + h.frame(vec![Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }]); +} + +/// Joined as Ada with Hana here, and the host reads signals. +fn ready() -> Harness { + let (mut h, _) = Harness::new(); + h.frames(3); + h.joined(); + h.send(vec![LiveEvent::Signal(2, Signal::ready())]); + h.frames(2); + assert!(h.app.session.live.signals); + h +} + +#[test] +fn others_chat_shows_by_their_cursor_then_fades() { + let mut h = ready(); + let page = h.page(); + h.send(vec![LiveEvent::Presence(1, cursor(page, 400.0, 300.0)), LiveEvent::Signal(1, Signal::chat("Looks good"))]); + let shapes = h.frames(2); + assert!(has_text(&shapes, "Looks good") && has_text(&shapes, "Hana"), "the message under her name"); + // Let the canvas render thread settle first: a tile landing asks for a frame of its own. + let delay = (0..30) + .find_map(|_| { + crate::idle_tests::wait_for_render(&h.app); + Some(h.frame(vec![]).1).filter(|d| !d.is_zero()) + }) + .unwrap_or_default(); + assert!(delay.as_secs_f64() > 4.0, "no frames until it starts fading: {delay:?} {:?}", h.ctx.repaint_causes()); + h.skip(5.5); + let (shapes, delay) = h.frame(vec![]); + assert!(has_text(&shapes, "Looks good") && delay.is_zero(), "fading"); + h.skip(1.0); + let shapes = h.frames(1); + assert!(!has_text(&shapes, "Looks good") && has_text(&shapes, "Hana"), "gone: the name tag is back"); + assert!(h.goes_quiet()); + // A new message shows again. + h.send(vec![LiveEvent::Signal(1, Signal::chat("Again"))]); + assert!(has_text(&h.frames(2), "Again")); +} + +#[test] +fn slash_opens_your_chat_and_enter_or_escape_ends_it() { + let mut h = ready(); + h.frame(vec![Event::PointerMoved(pos2(700.0, 500.0))]); + key(&mut h, Key::Slash, Some("/")); + h.frames(1); + assert!(h.app.ui.live.chat.as_ref().is_some_and(|c| c.editing), "the box opened"); + h.frame(vec![Event::Text("hello".into())]); + assert_eq!(h.app.session.live.chat, "hello", "sent as you type, without the slash"); + assert!(has_text(&h.frames(1), "hello")); + key(&mut h, Key::Enter, None); + assert!(h.app.ui.live.chat.as_ref().is_some_and(|c| !c.editing), "sent: the box closed, the message shows"); + assert_eq!(h.app.session.live.chat, "hello"); + h.skip(7.0); + h.frames(2); + assert!(h.app.ui.live.chat.is_none() && h.app.session.live.chat.is_empty(), "faded and taken back"); + // Escape takes it back at once and doesn't deselect. + let rect = h.app.session.doc().unwrap().pages.first().copied(); + let layer = h.app.session.doc().unwrap().node(rect.unwrap()).unwrap().children[0]; + h.app.session.execute("select.set", &serde_json::json!({"ids": [layer.0]})).unwrap(); + key(&mut h, Key::Slash, Some("/")); + h.frames(1); + h.frame(vec![Event::Text("oops".into())]); + assert_eq!(h.app.session.live.chat, "oops"); + key(&mut h, Key::Escape, None); + h.frames(1); + assert!(h.app.ui.live.chat.is_none() && h.app.session.live.chat.is_empty()); + assert_eq!(h.app.session.selection(), vec![layer], "Escape only closed the chat"); + // Left open without typing, the box closes by itself. + key(&mut h, Key::Slash, Some("/")); + h.frames(1); + h.skip(7.0); + h.frames(2); + assert!(h.app.ui.live.chat.is_none()); +} + +#[test] +fn spotlight_banner_and_your_avatar_menu() { + let mut h = ready(); + let page = h.page(); + h.send(vec![ + LiveEvent::Signal(1, Signal::spotlight(true)), + LiveEvent::Presence(1, figcraft_engine::live::Presence { viewport: Some((page, 0.0, 0.0, 600.0, 400.0)), ..Default::default() }), + ]); + let shapes = h.frames(2); + assert_eq!(h.app.session.live.follow, Some(1)); + assert!(texts(&shapes).iter().any(|(t, _, _)| t.starts_with("Following Hana"))); + // Stop following: the banner offers to follow again. + h.app.session.escape().unwrap(); + let shapes = h.frames(2); + assert!(has_text(&shapes, "Hana is spotlighting")); + h.click(text_center(&shapes, "Follow")); + assert_eq!(h.app.session.live.follow, Some(1)); + // Your avatar's menu: spotlight yourself, which takes over. + let shapes = h.frames(2); + h.click(text_center(&shapes, "AL")); + let shapes = h.frames(2); + h.click(text_center(&shapes, "Spotlight me")); + assert!(h.app.session.live.spotlighting); + assert_eq!(h.app.session.live.follow, None); + let shapes = h.frames(2); + assert!(has_text(&shapes, "You're spotlighting: everyone follows your view")); + h.click(text_center(&shapes, "Stop")); + assert!(!h.app.session.live.spotlighting); + assert!(!has_text(&h.frames(2), "You're spotlighting: everyone follows your view")); +} diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index 78295f4..c26e2d3 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -83,6 +83,11 @@ impl Harness { (flat(&out.shapes), delay) } + /// Let `secs` of UI time pass before the next frame. + pub(crate) fn skip(&mut self, secs: f64) { + self.time += secs; + } + pub(crate) fn frames(&mut self, n: usize) -> Vec { let mut last = Vec::new(); for _ in 0..n { @@ -191,7 +196,7 @@ fn outlined(shapes: &[Shape], c: Color32) -> usize { .count() } -fn cursor(page: NodeId, x: f64, y: f64) -> Presence { +pub(crate) fn cursor(page: NodeId, x: f64, y: f64) -> Presence { Presence { cursor: Some((page, x, y)), ..Presence::default() } } diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 34a3fdb..614efdf 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -10,7 +10,7 @@ handling and offline queueing with reconnect (`crates/collab`, wired into the en session and the transport). **Step 4 is implemented**: the browser build joins sessions a desktop app hosts, and `figcraft://` invite links open the app and join. Styles, variables, collections, modes and comments created in a session take ids from their creator's id space, and others' text -carets show on the canvas. **Step 5 is implemented**: the +carets, cursor chat and spotlights show live (see Presence). **Step 5 is implemented**: the standalone `figcraft-server` (`apps/figcraft-server`, running it: [`server.md`](server.md)) keeps many files with persistence and compaction, and share links that let people edit or only view; the app publishes to it, and a file's team library lives there for other files to use. The rest is the plan the next implementation PRs follow and update; @@ -161,6 +161,15 @@ server's files or use a file's team library instead of joining one; a client of refused with the reason. (The library messages came later within version 2: a server from before them can't read them and refuses the connection.) +**Signals (cursor chat, spotlight)** came later within version 2 too, without breaking older apps +in either direction. A client that reads them says so with the word `signals` in its hello's +`client_version` (`"0.3.1 signals"`); the server sends `Signal` messages only to those clients, so +an older client never gets a message it can't read. A server that takes them answers such a +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. + **Encoding (decided):** a binary message is a 1-byte wire version followed by **postcard** (serde), which is compact and needs no schema files. Property values inside are compact JSON text: the document types use tagged and flattened serde forms that only self-describing formats can read @@ -184,6 +193,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). | Server → client: @@ -197,6 +207,7 @@ Server → client: | `Pong` | Keep-alive. | | `Admin {ready \| published {file, edit, view} \| files \| links \| link \| done \| failed {reason}}` | A managing connection's answer: the key is right, a published file with its first two links (tokens shown once), listings, a new link, done, or why not. | | `Library {ready {access} \| library {version, archive} \| unchanged {version} \| empty \| published {version} \| usage [{usage, updated}] \| failed {reason}}` | A library connection's answer: the link is right (with its access), the library (a `.figcraft` archive holding it), no newer version, no library yet, kept, the usage reports, or why not. | +| `Signal {client_id, signal: {kind, text}}` | Someone's cursor chat or spotlight, cleaned by the server; `ready` (about the client itself, after its welcome): signals may be sent. Only to clients whose hello announced `signals`. | `Change` is one of: `Create {id, type, props}`, `Set {id, prop, value}`, `Delete {id}` (deleting a node deletes its subtree; ids are never allocated twice), plus the document-level `SetDoc`, `Put` @@ -355,6 +366,29 @@ resuming with its offline edits, and leaving the session going back to snapshot 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. +- **Cursor chat.** In a live session `/` on the canvas (`collab.cursorChat` without `text`) opens a + bubble in your colour at your pointer. What you type goes out as you type (a `chat` signal per + change, at most 100 characters; control characters become spaces), and others see it next to + your cursor in place of your name tag, under your name. Enter (or clicking away) sends it and + closes the box; it fades a few seconds after its last change on every screen (5 s, then a + 1 s fade; reduced motion makes it vanish without fading), and your app then closes it with an + empty `chat`. Escape takes it back at once (and doesn't deselect); a box left open without typing + closes the same way. Bubbles ask for frames only until they have faded. Agents use + `collab.cursorChat {text}` (their bubble fades on others' screens unless they send it again). +- **Spotlight.** Your avatar's menu (Spotlight me / Stop spotlighting, `collab.spotlight {on?}`) + sends a `spotlight` signal: everyone else follows your view and page as with a follow, the + session says who started it, and someone who pans, zooms or presses Escape stops following while + a banner on the canvas ("Hana is spotlighting", Follow) lets them follow again. People who join + later are told by the server and follow too. A newer spotlight takes over (the earlier presenter + follows it); the presenter leaving or stopping ends it. While you spotlight a banner says so, + with Stop. After a reconnect your spotlight goes out again once the host says it reads signals. + With no accounts, anyone in a session can spotlight. +- **Signals are presence**: never in the document, the log or a saved state; the server keeps only + who spotlights (for newcomers). It cleans each one (unknown kinds, malformed spotlights, a + client's `ready` and anything over 1 KiB are dropped; chat text is cut to 100 characters with + control characters replaced) and passes it on to the others that read signals; the app checks + what arrives again before showing it. A client may send 20 signals a second on average (bursts + of 40); more are dropped without closing the connection. ## Hosting and joining @@ -379,7 +413,7 @@ resuming with its offline edits, and leaving the session going back to snapshot Join live session…" (and "Join a session…" in the popover) asks for the link. Commands: `collab.host {port?, bind?: "local" | "lan" | }`, `collab.stop`, `collab.join {link}`, `collab.leave`, `collab.peers`, `collab.follow {client?}`, `collab.cursor {x?, y?}` (an agent's - pointer). + pointer), `collab.cursorChat {text?}`, `collab.spotlight {on?}`. - The host listens on port 47900 (any free port when it's taken, or `port`), on 127.0.0.1 unless the user chooses the local network (`lan`: the address of the interface the default route uses) or names an interface (`bind: ""`, e.g. a VPN's). It runs the core's `Server` as the @@ -592,7 +626,8 @@ host, publish and join sessions; an agent can be a participant with its own curs *Built*: the desktop app (control channel) and the MCP server's headless session (`figcraft-cli mcp`) both have the network link installed; `collab.join` returns at once with status `"connecting"` and `collab.peers` tells when it is live (or why it failed) and who is typing in -which text layer. An agent's pointer is `collab.cursor {x, y}`. The one-shot CLI (`figcraft-cli run`) has no link: there the commands say +which text layer. An agent's pointer is `collab.cursor {x, y}`; it can chat next to it +(`collab.cursorChat {text}`) and spotlight (`collab.spotlight`). The one-shot CLI (`figcraft-cli run`) has no link: there the commands say live sessions aren't available. ## Security @@ -623,7 +658,9 @@ chooses to share on the network; the default is the invite link with a random to core's 64 MiB cap: a welcome carries the whole document); a client sending more than 100 messages a second on average (bursts of 400) is disconnected, and so is one whose 1,024-message outgoing queue fills (it can't keep up); names are cut to 64 characters without control - characters; presence numbers must be finite and selections are cut to 10,000 ids. + characters; presence numbers must be finite and selections are cut to 10,000 ids; signals are + at most 1 KiB, chat 100 characters without control characters, 20 a second (bursts of 40, + the rest dropped), and only known kinds are passed on. - **Malformed input.** Anything that isn't a binary message of the current wire version that decodes in full is answered with `Refused` and a close; the first message must be `Hello` (for this file) or, on a server, `Manage`. The invite parser and the host's reader are fuzzed diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 01b376c..acc8889 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,20 +1,20 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (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-12 · **Change:** minor (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, server ordering, client reconciliation, convergence tests), and live sessions (preview) let several apps co-edit a file hosted by one desktop app or kept on a self-hosted `figcraft-server` with share links to edit or view, joined from the desktop app or the browser, with others' -cursors, selections and text carets shown live ([`collab-protocol.md`](collab-protocol.md), [`server.md`](server.md)). Remaining: 55–110 h -(multiplayer 45–90 h, shared libraries 10–20 h); branching landed (its tail: 1–2 h). +cursors, selections, text carets, cursor chat and spotlights shown live ([`collab-protocol.md`](collab-protocol.md), [`server.md`](server.md)). Remaining: 46–92 h +(multiplayer 42–84 h, shared libraries 4–8 h); branching landed (its tail: 1–2 h). | Feature | Figma | FigCraft | Hours | |---|---|---|---| | 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). 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 (Share ▸ 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). Left: server archives beyond Linux | 25–55 | -| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **partial, 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; no spotlight | 8–15 | -| Cursor chat, audio, high-fives | yes | no | 4–8 | +| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | +| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: `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. 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; 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. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | @@ -26,6 +26,7 @@ cursors, selections and text carets shown live ([`collab-protocol.md`](collab-pr | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change (Enter sends, Escape takes it back, 100 characters, control characters dropped); your avatar's menu (or `collab.spotlight`) asks everyone to follow your view and page, latecomers included, until they move their own view. Both travel as signals: ephemeral presence the host validates, rate-limits (20 a second per person) and passes only to clients that announce they read them, so older clients keep working; never in the document or the log. Live cursors / follow / spotlight row partial → done, 8–15 h → 2–4 h; cursor chat row no → partial (no audio or reactions), 4–8 h → 2–5 h; multiplayer 45–90 h → 42–84 h (it counted these rows only in part); remaining 55–110 h (stale: team libraries had already gone to 4–8 h) → 46–92 h; collaboration ~13% unchanged at this rounding | | 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | | 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions; percentages and hours unchanged here (comments and versions are graded with feature area 16 in target-app-parity) | | 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | diff --git a/docs/gaps.md b/docs/gaps.md index d6c058f..97653bf 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -15,7 +15,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | # | Gap | Kind | Hours | Doc | |---|---|---|---|---| | 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed; real-file checks, the small tail and export left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) | -| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 45–90 | [collaboration-parity](collaboration-parity.md) | +| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | | 5 | Localization: English and a Spanish draft only (the layer landed 2026-10-11) | Localization | 45–75 | [localization-parity](localization-parity.md) | @@ -56,13 +56,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Estimate:** 5–10 h for the read tail (real-file fixes, connector labels, table cells); export to `.fig` a further 15–30 h and lower priority. ### 2. Multiplayer and sharing are a preview, without accounts -- **Missing:** accounts, teams and projects, per-person permissions, spotlight, cursor chat, audio, +- **Missing:** accounts, teams and projects, per-person permissions, audio, shared comments and version history. Comments and versions are local to the file. - **Evidence:** the core landed in #103 (`crates/collab`: client-scoped ids, change sets, server ordering and validation, client reconciliation, binary messages, randomized convergence tests over real commands), and live sessions run over the network (`crates/collab-net`: hosting from the app, invite links that open the app, document sync between apps, live cursors, selections, - text carets, avatars and follow, and the browser build joins) with per-person undo, refused + text carets, avatars and follow, cursor chat and spotlight, and the browser build joins) with per-person undo, refused edits rolled back with a reason, reconnects that resume and send what was made offline, and styles, variables and comments that several people create at once all kept; a standalone `figcraft-server` keeps many files (snapshot plus log, compacted; restarts lose nothing @@ -72,7 +72,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe accounts (a link is the access) (`docs/collab-protocol.md`). - **Impact:** Figma's defining feature; most teams use it every day. Solo designers can work without it. -- **Estimate:** 45–90 h: accounts, teams and per-person permissions, shared comments and versions +- **Estimate:** 42–84 h: accounts, teams and per-person permissions, shared comments and versions (numbers from before the server landed; re-measured next). ### 3. Nothing checked against real Figma files @@ -324,6 +324,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Cursor chat and spotlight in live sessions (gap 2): `/` opens a chat bubble at your pointer, a spotlight makes everyone follow your view; ephemeral presence the host validates and rate-limits. Gap 2 45–90 h → 42–84 h | | 2026-10-12 | minor | Layers and Assets panels behaviour suite and fixes added to gap 4's landed list; ranking and hours unchanged | | 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Gap 9 2–5 h → 1–3 h (pen pressure on macOS and Linux tablets left, blocked on winit). Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | diff --git a/docs/mcp.md b/docs/mcp.md index 7a5462a..da9506a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -865,9 +865,12 @@ joined file opens as a new document). attempt?, role (host|guest), me, name, color, fileName, pages, link, url, address, network, access (edit|view), readOnly (the active document can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), - follow, syncing (the document arrived), document (the index of the synced open document), + follow, signals (the host passes cursor chat and spotlights on), chat (your cursor chat), + spotlight (who spotlights, or null), spotlighting (you do), syncing (the document arrived), + document (the index of the synced open document), editing (the text layer you are typing in: {node, layer, caret, selection}, or null), peers: [{id, - name, color, editing (the text layer they are typing in, same shape, or null), presence: {cursor: + name, color, editing (the text layer they are typing in, same shape, or null), chat (their open + cursor chat message, or null), presence: {cursor: [page, x, y], selection: [ids], viewport: [page, x, y, w, h], editing_text: {node, caret, selection?}}}]}`. Carets and selections count characters; someone's typing syncs when they finish, so their caret may be past the end of your copy until then. Starting `text.edit.begin` @@ -878,6 +881,17 @@ joined file opens as a new document). pointer. Your selection, page and view go out by themselves when they change. - `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any pan or zoom of your own, or Escape). +- `collab.cursorChat {text}` shows `text` next to your cursor for everyone in the session (put the + cursor somewhere first with `collab.cursor`): at most 100 characters, control characters become + spaces, `""` closes it. Others see it fade 5 seconds after its last change, so send it again to + keep it up. Without `text` it opens the chat box in the app (`/` on the canvas), which a + programmatic call never does: it returns `{open}`. Others' messages are each peer's `chat` in + `collab.peers`. +- `collab.spotlight {on?}` (no `on`: switch) asks everyone in the session to follow your view and + page; people who join later follow too, and anyone who pans or zooms stops following (the + spotlight goes on; they can follow again). Someone else's spotlight takes over yours. Returns + `{spotlighting}`. Both commands need a live session whose host is this version or newer (`signals` + in `collab.peers`); otherwise they say why. - Styles, variables, collections, modes and comments you create on the session's document take ids from your own id space (`S:`, `VariableID:`, … with large numbers), so two people creating them at once never overwrite each other's; outside a session ids look as before (`S:1`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 56cb279..f2f018d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -19,9 +19,9 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Feature breadth (does each Figma Design feature exist, weighted) | **~84%** (core editor areas 1–16: ~94%) | estimated from the area table below | | Ready for real work | **~52%** | estimated (dimension table below: written weights × written per-dimension scores) | | Essentials user | **~57%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.88) | -| Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 72.4% × discounts 0.9 × 0.9 × 0.85) | +| Mainstream practitioner | **~49%** | estimated ([below](#mainstream-practitioner): weekly areas 72.6% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~170–305 h** one Opus 5.5 agent (~35–70 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~289–564 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~286–558 h** one agent (~86–150 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -35,7 +35,7 @@ tool; presence was being graded as behaviour in places. | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~52% | ~294–566 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~52% | ~291–560 h | multiplayer (42–84), localization (45–75), plugins (15–40), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~49% | ~130–244 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~57% | ~60–110 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | @@ -110,12 +110,12 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 14 | Import/export | 6 | 84% | 57% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | | 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | -| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat ([collaboration-parity](collaboration-parity.md)) | 45–90 | +| 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | | 18 | Plugins & widgets | 4 | 45% | 28% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples), then plugin panels called back per button or changed value and per-plugin settings (ABI v2). Left: distribution, widgets; panels one at a time without a colour picker; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 15–40 | | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~84%** | **~63%** | | **128–276** | +| | **Weighted (÷113)** | 113 | **~84%** | **~63%** | | **125–270** | | | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 33–75 | ## By dimension (ready for real work) @@ -125,7 +125,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Features (depth) | 35 | 63% | 29–68 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | | File formats | 12 | 59% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), no `.fig` write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | -| Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | +| Collaboration | 10 | 13% | 52–104 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | | Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | | Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | | Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | @@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | | Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~52%** | **~289–564** | | | +| **Ready for real work** | 100 | **~52%** | **~286–558** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -187,7 +187,7 @@ libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a produc localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration and is left out as an area, but its effect on working with Figma users is counted in discount 3. -**2. Average depth** ("Ready" column, weighted): **72.4%**. +**2. Average depth** ("Ready" column, weighted): **72.6%**. **3. Discounts for what still stops real work:** @@ -197,7 +197,7 @@ and is left out as an area, but its effect on working with Figma users is counte | Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way | | File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) but writes nothing Figma opens as a document; incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) | -72.4% × 0.90 × 0.90 × 0.85 = **~49%** (estimated; 49.9%, rounded down). +72.6% × 0.90 × 0.90 × 0.85 = **~49%** (estimated; 49.98%, rounded down). **4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is @@ -612,6 +612,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page, latecomers included, until they move their own. Both are ephemeral presence the host validates and rate-limits, passed only to clients that read them. Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; the Collaboration dimension's hours 55–110 → 52–104 h (the dimension hours still add up to the total); feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h | | 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes: several layers dropped together in the layers panel land together (layers from apart split around the drop point); Collapse layers keeps the selection's rows open (it closed everything); the eye and padlock stay under the pointer (they vanished as it reached them, so clicks could miss) and dragging across them sets several layers in one undo step; several layers selected open a Rename layers dialog (match as text or a regular expression, Rename to, current name and number buttons, a start value, preview) instead of renaming the top one; `object.batchRename` counts `$n` up and `$N` down (they were swapped), pads with `$nnn`, takes `start`, regular expressions with `$1`…`$9`, `` $` `` / `$'` and codes in the replacement; pages are dragged to reorder, a blank page named with a hyphen is a divider (never opened, holds no layers), duplicates are named "Copy of …", the last page that isn't a divider can't be deleted; the Assets panel files components by page, frame and slash names in folders that fold away (`component.list` gives each `folder`); a leading `.` or `_` now keeps a component out of the library even after it was published, and removing it brings the component back. Pinned where the documentation is silent: Alt-click on an arrow, numbering in stacking order, hidden layers and locked frames taking drops, locking deselects. Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | | 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d10e233..230934e 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -101,7 +101,7 @@ interaction feel not yet compared. | Gradient and padding/gap handles on the canvas | done | | | Hover outline, selection colours, handle sizes | done | measured from UI3 screenshots | | Cursors per tool and modifier | partial | not compared | -| Multiplayer cursors, cursor chat, spotlight | missing | | +| Multiplayer cursors, cursor chat, spotlight | partial | in live sessions: named cursors, chat bubbles that fade, `/` to chat, spotlight from your avatar's menu with a banner; no audio or reactions | ## Accessibility (ours vs Figma's) @@ -118,6 +118,7 @@ interaction feel not yet compared. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Multiplayer cursors, cursor chat and spotlight: missing → partial (live sessions: chat bubbles at the pointer that fade, `/` to chat, spotlight from your avatar's menu with a banner to stop or follow again) | | 2026-10-12 | minor | Layers and Assets panels behaviour suite and fixes: several layers dropped together land together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers in one step, the toggles stay under the pointer, Rename layers dialog for several layers, pages dragged to reorder, dividers, "Copy of" duplicates, Assets folders by page, frame and slash names | | 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes | | 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite and fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame; a guide can be selected, deleted, nudged and removed from the context menu, Alt-dragged into a copy, and measured from with Alt while dragging; Auto column and row counts; layout grids hidden on rotated frames; outline mode clicks reach layers behind. Outline mode back to partial (two documented options missing) |