diff --git a/ROADMAP.md b/ROADMAP.md index fd0632b5..f54bf2a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -359,6 +359,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`#join=…`). A browser build served beside its server names it with ``, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged | +| 2026-10-30 | minor | M3, gap 10: team library analytics count detached instances. Detaching an instance of a library component counts it in the file (`libraries.detached`, saved with it, at most 1,000 components); the background library check reports the counts to the library's server after its fetch (`reportDetached`), which keeps them per file beside the file's usage report (same caps: 1,000 components, 200 files, names cleaned). **Show usage** and `library.usage` list detaches per component and per file (`detached`, `detachesKnown`); a component only detached shows just that. New request and reply variants appended to the library messages keep every older message's bytes, so older apps and servers are unaffected: an older server refuses the reports (ignored) and Show usage says it doesn't count detaches. Checked against a local figcraft-server with the app driven over the control channel. Numbers unchanged. Tests: `library_detach_tests` (server, end to end), `codec_tests::older_library_messages_keep_their_encoding`, `assets::library::tests::add_place_and_update_keep_overrides`, `cmd::library::detach_report_tests`. | | 2026-10-30 | patch | M5, gap 15: skipping the Open link notice with `proto.linkNotice` (the Actions palette, the control channel, MCP) changed the preference in memory only, so it was lost if the app crashed before quitting. A command can now mark the preferences to be kept at once (`Session::prefs_unsaved`), which the app saves at the end of the frame like installed plugins and live-session tokens; `proto.linkNotice` does, and the notice's own "Don't show this again" goes the same way. Numbers unchanged. Test: `present_links_tests::skipping_the_notice_by_command_is_saved_at_once`. | | 2026-10-30 | minor | M12, #1596: the bar over a selected connector on a board had the line type and one arrow button. It now has, beside the board colours and the line type, a colour wheel opening the colour picker for any colour (when only connectors are selected), menus for the start and end points (every line end the Design panel offers, each shown), and a line style menu (solid or dashed, weights 1, 2, 4 and 6), each control naming itself on hover. The arrow toggle gave way to the end point menu. UI only: `board.setStyle` caps and the stroke's dashes and weight, one undo step each. Tests: `board_line_tests.rs` (headless). | | 2026-10-30 | minor | M12, gap 24: cells copied from a spreadsheet paste onto a board as a table. The clipboard carries them as text: a row per line, a tab between cells, and a cell holding a tab, a line break or a quote in quotes. `board_table::spreadsheet_cells` reads that into `board.table`'s `cells`. The table is centred in the view, or with Paste here its top-left goes at the point. Up to 50 × 20 cells are kept, with a note when there were more. Text without tabs, comma-separated text and any paste into a design file stay a text layer, as before. Tests: `board_table_paste_tests`, `table_paste_tests`. | @@ -367,7 +368,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-30 | minor | M6, gap 6: plugin panels show pictures. An `image` control carries a PNG, JPEG, WebP, GIF, BMP or TIFF file as base64 (`data`, at most 256 KB; a panel spec may now be 1 MB) and is drawn at its size, at most a field wide and 240 pt tall, never scaled up; screen readers read its `label`. The app decodes it with the thumbnail decoder (its allocation limit), refuses pictures over 4,096 px on a side before decoding them, keeps the last 32 decoded, and shows the label instead of a picture it can't show. Images can't be clicked (`plugin.event` refuses them) and can't be command parameters. Also: a panel's or form's captions no longer wrap ("Palette" broke onto two lines when it was the widest). Numbers unchanged. Tests: `v2_tests`, `tests` (manifest), `plugin_panel_tests::panel_images_show_named_or_fall_back_to_their_label`. | | 2026-10-30 | minor | M6, gap 1: a `.fig` connector with a label but no saved snapshot of it lost the label. It now imports as a connector with a saved snapshot does: a frame holding its line (a vector with the connector's stroke and ends) and the label as text, centred on the middle of the line by length in a 200-wide box, in the connector's fill paints or black. The background the label sits on isn't known without the snapshot and is left out; the import report says so. Checked by rendering a synthetic file with figcraft-cli; not on real files. Numbers unchanged. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label`. | | 2026-10-30 | minor | M8, #1571 (part 4): every move of a drag copies the document from where the drag started and normalises it. The copy was already cheap, since layers are shared, not copied (about 0 ms). Normalising took 1.4 ms per move with 648 layers dragged, because each moved layer went through every step: variable bindings and style links, sizing coherence, text auto-size and the instance sync. Now `normalize` sends a layer whose transform is all that changed, and which is in no component or instance, only through the steps that follow what sits around it: groups refitting, auto layout, connectors, ready-for-dev marks and comment pins. Its bindings (never a position), style links, sizing and text have nothing to follow. This applies to any edit that only moves layers, not just drags. Normalising those 648 moves now takes 0.78 ms. Tests compare the result with a full normalisation for every layer, the top-level layers and every third layer of each example file moved at once, and for a drag of plain, bound, grouped, absolutely placed and component layers, while dragging and after the drop. | -| 2026-10-30 | minor | M10: Bosnian (`bs`, Bosanski) draft catalog: all 1,901 UI strings, 629 command labels and 31 menu wordings, machine-drafted in Latin script (ijekavian) and waiting for a native speaker's review. Bosnian's integer plural rule (one for 1, 21, 31… except 11; few for 2–4, 22–24… except 12–14; other otherwise) in `i18n::plural`. Numbers unchanged. Tests: `bosnian_tags_and_integer_plurals`. | +| 2026-10-30 | minor | M10: Bosnian (`bs`, Bosanski) draft catalog: all 1,906 UI strings, 629 command labels and 31 menu wordings, machine-drafted in Latin script (ijekavian) and waiting for a native speaker's review. Bosnian's integer plural rule (one for 1, 21, 31… except 11; few for 2–4, 22–24… except 12–14; other otherwise) in `i18n::plural`. Numbers unchanged. Tests: `bosnian_tags_and_integer_plurals`. | | 2026-10-30 | minor | Text style dedupe is O(spans) (M8, #1102): `shape_text`'s scan over collected styles replaced by a last-two cache and fingerprint buckets resolved with the full equality — same result, O(spans) for the styles that usually differ, never worse than the scan; the font-db write lock untouched (face loading needs it). `layout_dedupe_tests`. Numbers unchanged | | 2026-10-30 | minor | M6, #1646: a layer pasted from another design app whose image the clipboard didn't carry came in flat grey, like a grey the design chose, and the paste said only that some kinds of content were left out. Now the placeholder an import stores for an image it doesn't have is light grey crossed by darker diagonal stripes (REST JSON, `.fig` and paste alike), the fill keeps the image's own name, the `.fig` and paste note is a fixed, translated one per image ("image fills whose image isn't in the file show a missing-image placeholder and keep its name"), `edit.pasteDesignLayers` returns `missingImages` (the names of the pasted layers concerned), the paste's message names them, and a paste with notes opens the import report like an import. Tests: `fig/missing_image_tests.rs`, `paste_missing_image_tests.rs`, `import_report_tests.rs`. | | 2026-10-30 | minor | M6, #1638: layers pasted from another design app lost the colours of their shared paint styles: a component's glyphs, and the same glyphs in its instances, came in black. The clipboard links each style by the guid it has in the library it came from, which the clipboard doesn't hold, as well as by its library asset, whose copy it does hold; the `.fig` mapping took the guid and found nothing. Now a guid names a style, variable collection or other resource only when the file holds a node by it, and otherwise the asset it also names finds the file's copy, so `.fig` import and paste agree. A link to a style the file really doesn't hold keeps the colours stored with the layer, as before, now with a note. Tests: `paste_style_tests.rs` (design data built in code, read as paste reads it). | diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index 5ae6fe9b..8e61429b 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -105,6 +105,8 @@ mod hardening_tests; #[cfg(test)] mod idle_io_tests; #[cfg(test)] +mod library_detach_tests; +#[cfg(test)] mod library_tests; #[cfg(test)] mod owned_tests; diff --git a/apps/figcraft-server/src/library_detach_tests.rs b/apps/figcraft-server/src/library_detach_tests.rs new file mode 100644 index 00000000..ed0e7084 --- /dev/null +++ b/apps/figcraft-server/src/library_detach_tests.rs @@ -0,0 +1,146 @@ +//! Detach counts in a team library's analytics, end to end over loopback: a file that uses the +//! library detaches instances of its components and its app reports them with its usage; the +//! library's owner sees them beside the instances. Reports are cleaned and capped like usage, kept +//! per file, and the usage list a server wrote before detach counts still reads. + +use std::time::{Duration, Instant}; + +use figcraft_collab::message::{Access, ClientMsg, Detached, FileUsage, LibraryReply, LibraryReq, OpenLibrary, PROTOCOL, ServerMsg, Usage}; +use figcraft_collab_net::Invite; +use figcraft_collab_net::hub::Backend; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::hardening_tests::archive; +use crate::store::{FILES, Store}; +use crate::test_util::{Raw, TempDir, config, ip, publish, session, snapshot}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// `a`'s analytics of its file's library once `done` holds for them (the reports arrive from +/// another app's background requests). +fn usage_when(a: &mut Session, done: impl Fn(&Value) -> bool) -> Value { + let start = Instant::now(); + loop { + let u = ex(a, "library.usage", json!({})); + if done(&u) { + return u; + } + assert!(start.elapsed() < Duration::from_secs(30), "the reports never arrived: {u}"); + std::thread::sleep(Duration::from_millis(20)); + } +} + +fn answer(raw: &mut Raw) -> LibraryReply { + match raw.next() { + ServerMsg::Library(r) => *r, + other => panic!("not a library answer: {other:?}"), + } +} + +#[test] +fn detaches_are_reported_and_counted_beside_instances() { + let dir = TempDir::new("library-detach"); + let cfg = config(dir.path()); + let mut server = crate::start(&cfg).unwrap(); + let addr = server.addr(); + // Ada's library on the server: a "Button". + let mut a = session("Ada"); + let f = id(&ex(&mut a, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"}))); + ex(&mut a, "component.create", json!({"ids": [f]})); + let p = publish(&mut a, dir.path(), addr); + ex(&mut a, "library.publishToServer", json!({})); + + // Bo places two Buttons and detaches one. + let mut b = session("Bo"); + let lid = ex(&mut b, "library.add", json!({"path": p.view}))["id"].as_str().unwrap().to_string(); + let place = |b: &mut Session, x: i32| id(&ex(b, "library.place", json!({"library": lid, "component": "Button", "x": x, "y": 0}))); + let (one, two) = (place(&mut b, 0), place(&mut b, 200)); + ex(&mut b, "component.detach", json!({"ids": [two]})); + b.refresh_server_libraries(); + let u = usage_when(&mut a, |u| u["detached"] == 1 && u["instances"] == 1); + assert_eq!((u["instances"].clone(), u["detachesKnown"].clone()), (json!(1), json!(true)), "{u}"); + assert_eq!(u["components"][0]["name"], "Button", "{u}"); + assert_eq!((u["components"][0]["instances"].clone(), u["components"][0]["detached"].clone()), (json!(1), json!(1)), "{u}"); + assert_eq!(u["files"][0]["detached"], 1, "{u}"); + + // Detaching the other one too: no instances left, still listed with its detaches. + ex(&mut b, "component.detach", json!({"ids": [one]})); + b.refresh_server_libraries(); + let u = usage_when(&mut a, |u| u["detached"] == 2 && u["instances"] == 0); + assert_eq!(u["instances"], 0, "{u}"); + assert_eq!((u["components"][0]["name"].clone(), u["components"][0]["detached"].clone()), (json!("Button"), json!(2)), "{u}"); + server.stop(); +} + +/// Reports from the wire: names cleaned and counts capped as usage is, kept apart per file; a +/// file that only reported detaches isn't listed as using the library; the requests from before +/// detach counts are answered as they were. +#[test] +fn detach_reports_are_capped_per_file_and_older_requests_answer_as_before() { + let dir = TempDir::new("library-detach-wire"); + let cfg = config(dir.path()); + let mut server = crate::start(&cfg).unwrap(); + let addr = server.addr(); + let mut a = session("Ada"); + let f = id(&ex(&mut a, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"}))); + ex(&mut a, "component.create", json!({"ids": [f]})); + let p = publish(&mut a, dir.path(), addr); + ex(&mut a, "library.publishToServer", json!({})); + let view = Invite::parse(&p.view).unwrap(); + let mut raw = Raw::connect(addr); + raw.send(&ClientMsg::OpenLibrary(OpenLibrary { + protocol: PROTOCOL, + file: view.file.clone(), + token: view.token.clone(), + client_version: String::new(), + })); + assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::View }); + let flood = Detached { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() }; + raw.send(&ClientMsg::Library(LibraryReq::ReportDetached(flood))); + assert_eq!(answer(&mut raw), LibraryReply::Noted); + raw.send(&ClientMsg::Library(LibraryReq::ReportDetached(Detached { + file: "Shop".into(), + components: vec![(NodeId(3), 4), (NodeId(3), 9), (NodeId(5), 0)], + }))); + assert_eq!(answer(&mut raw), LibraryReply::Noted); + raw.send(&ClientMsg::Library(LibraryReq::Detached)); + let LibraryReply::Detached(files) = answer(&mut raw) else { panic!("no detaches") }; + let spam = files.iter().find(|f| f.detached.file.starts_with("Spam")).unwrap(); + assert!(spam.detached.file.chars().count() <= 256 && !spam.detached.file.contains('\n'), "names are cleaned and capped"); + assert_eq!(spam.detached.components.len(), 1_000, "reports are capped"); + let shop = files.iter().find(|f| f.detached.file == "Shop").unwrap(); + assert_eq!(shop.detached.components, [(NodeId(3), 4)], "one count per component, none of 0"); + // The usage list (what older apps ask for) is as it was: nobody reported using the library. + raw.send(&ClientMsg::Library(LibraryReq::Usage)); + assert_eq!(answer(&mut raw), LibraryReply::Usage(Vec::new())); + server.stop(); +} + +/// A usage list written before detach counts reads as it did, and gains them beside it. +#[test] +fn a_usage_list_from_before_detach_counts_still_reads() { + let dir = TempDir::new("library-detach-old"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap(); + store.publish_library(&file.id, &archive(0, 0)).unwrap(); + let old = vec![FileUsage { usage: Usage { file: "Checkout".into(), components: vec![(NodeId(7), 3)] }, updated: 1_700_000_000 }]; + std::fs::write(dir.path().join(FILES).join(&file.id).join(crate::disk::USAGE), serde_json::to_vec(&old).unwrap()).unwrap(); + drop(store); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + assert_eq!(store.library_usage(&file.id).unwrap(), old); + let report = Detached { file: "Checkout".into(), components: vec![(NodeId(7), 2)] }; + let r = store.library(ip("198.51.100.1"), &file.id, Access::View, LibraryReq::ReportDetached(report)); + assert_eq!(r, LibraryReply::Noted); + assert_eq!(store.library_usage(&file.id).unwrap(), old, "the usage report is untouched"); + let detached = store.library_detached(&file.id).unwrap(); + assert_eq!(detached.len(), 1); + assert_eq!(detached[0].detached.components, [(NodeId(7), 2)]); +} diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 6ca994b6..3e7df46a 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -39,10 +39,10 @@ use std::path::{Path, PathBuf}; use std::time::{Instant, SystemTime, UNIX_EPOCH}; use figcraft_collab::message::{ - Access, Admin, AdminReply, ClientMsg, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, MAX_OWNED, NewLink, OwnedFile, - PluginReply, PluginReq, ReportInfo, Usage, + Access, Admin, AdminReply, ClientMsg, Detached, FileDetached, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, + MAX_OWNED, NewLink, OwnedFile, PluginReply, PluginReq, ReportInfo, Usage, }; -use figcraft_collab::{Change, Replica, Server, same_secret}; +use figcraft_collab::{Change, NodeId, Replica, Server, same_secret}; use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_blocks, ip_key}; use figcraft_collab_net::token; use serde::{Deserialize, Serialize}; @@ -290,11 +290,28 @@ const TOUCH_EVERY: u64 = 3600; /// A library's usage reports in memory, and whether (and when) they were written. struct UsageCache { - list: Vec, + list: Vec, dirty: bool, written: u64, } +/// What one file reports about a library, as kept in `usage.json`: its last usage report (the +/// same JSON as a [`FileUsage`]; `updated` 0: none yet) and its last detach report. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +struct UsageEntry { + usage: Usage, + #[serde(default)] + updated: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + detached: Vec<(NodeId, u32)>, + #[serde(default, skip_serializing_if = "is_zero")] + detached_updated: u64, +} + +fn is_zero(n: &u64) -> bool { + *n == 0 +} + /// The data directory in use (it holds the directory's lock until dropped). pub struct Store { root: PathBuf, diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs index f633b021..0f088f70 100644 --- a/apps/figcraft-server/src/store/backend.rs +++ b/apps/figcraft-server/src/store/backend.rs @@ -270,6 +270,8 @@ impl Backend for Store { self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version }) } LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage), + LibraryReq::ReportDetached(d) => self.report_detached(file, d).map_or_else(failed, |()| LibraryReply::Noted), + LibraryReq::Detached => self.library_detached(file).map_or_else(failed, LibraryReply::Detached), } } diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs index 4e76bc5f..bd1312a2 100644 --- a/apps/figcraft-server/src/store/library.rs +++ b/apps/figcraft-server/src/store/library.rs @@ -1,7 +1,7 @@ use super::{ - Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED, - MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, clean_name, disk, fs, now, - write_json, + Action, BUSY_DOWNLOADS, Cursor, Detached, FILES, FileDetached, FileUsage, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, + MAX_REPORTED, MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, NodeId, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, + UsageEntry, clean_name, disk, fs, now, write_json, }; impl Store { @@ -103,31 +103,78 @@ impl Store { /// The usage reports kept for a file's library (none when it has no library). pub fn library_usage(&mut self, file: &str) -> Result, String> { - Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default()) + let list = self.usage_of(file)?.map(|u| u.list.as_slice()).unwrap_or_default(); + Ok(list.iter().filter(|e| e.updated > 0).map(|e| FileUsage { usage: e.usage.clone(), updated: e.updated }).collect()) + } + + /// The detach reports kept for a file's library (none when it has no library). + pub fn library_detached(&mut self, file: &str) -> Result, String> { + let list = self.usage_of(file)?.map(|u| u.list.as_slice()).unwrap_or_default(); + Ok(list + .iter() + .filter(|e| !e.detached.is_empty()) + .map(|e| FileDetached { detached: Detached { file: e.usage.file.clone(), components: e.detached.clone() }, updated: e.detached_updated }) + .collect()) } /// Keep how one file uses a file's library (in memory; written when it changed, at most every /// [`USAGE_WRITE_EVERY`] seconds). pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> { - let name = clean_name(&usage.file); + self.report(file, &usage.file, usage.components, |e, components| { + if e.updated > 0 && e.usage.components == components { + return false; + } + e.usage.components = components; + e.updated = now(); + true + }) + } + + /// Keep how many instances of a file's library components one file has detached (as + /// [`Self::report_usage`] keeps its usage, beside it). + pub fn report_detached(&mut self, file: &str, detached: Detached) -> Result<(), String> { + self.report(file, &detached.file, detached.components, |e, components| { + if e.detached == components { + return false; + } + e.detached = components; + e.detached_updated = now(); + true + }) + } + + /// Keep `counts` from the file called `name` (cleaned: positive counts, one per component, at + /// most [`MAX_REPORTED`]) in its entry with `set` (which says whether anything changed), + /// keeping at most [`MAX_USERS`] files, the ones heard from most recently. + fn report( + &mut self, + file: &str, + name: &str, + counts: Vec<(NodeId, u32)>, + set: impl FnOnce(&mut UsageEntry, Vec<(NodeId, u32)>) -> bool, + ) -> Result<(), String> { + let name = clean_name(name); if name.is_empty() { return Ok(()); } - let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect(); - components.sort(); - components.dedup_by_key(|c| c.0); - components.truncate(MAX_REPORTED); + let mut counts: Vec<_> = counts.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect(); + counts.sort(); + counts.dedup_by_key(|c| c.0); + counts.truncate(MAX_REPORTED); let Some(u) = self.usage_of(file)? else { return Ok(()) }; - match u.list.iter_mut().find(|f| f.usage.file == name) { - Some(f) if f.usage.components == components => return Ok(()), - Some(f) => { - f.usage.components = components; - f.updated = now(); + let at = match u.list.iter().position(|e| e.usage.file == name) { + Some(at) => at, + None => { + u.list.push(UsageEntry { usage: Usage { file: name, components: Vec::new() }, ..UsageEntry::default() }); + u.list.len() - 1 } - None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }), + }; + let Some(entry) = u.list.get_mut(at) else { return Ok(()) }; + if !set(entry, counts) { + return Ok(()); } if u.list.len() > MAX_USERS { - u.list.sort_by_key(|f| std::cmp::Reverse(f.updated)); + u.list.sort_by_key(|e| std::cmp::Reverse(e.updated.max(e.detached_updated))); u.list.truncate(MAX_USERS); } u.dirty = true; diff --git a/crates/collab-net/src/driver.rs b/crates/collab-net/src/driver.rs index 74775fd8..3ddd3b3c 100644 --- a/crates/collab-net/src/driver.rs +++ b/crates/collab-net/src/driver.rs @@ -18,6 +18,7 @@ use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg}; use figcraft_engine::live::{Event, Status}; +use std::time::Duration; // `std::time::Instant` on native builds; the browser's clock in the web build. use web_time::Instant; @@ -25,6 +26,7 @@ use crate::exchange::{self, Exchange, Then}; use crate::inbox::Inbox; use crate::invite::Endpoint; use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, ask, closed, hello, resolved, take, too_large, unresolved}; +use crate::limits::NetLimits; use crate::requests::Publish; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; @@ -55,6 +57,13 @@ impl Conn { fn new(now: Instant) -> Self { Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_ping: now, last_heard: now } } + + /// How long the host may stay silent. Until the welcome is whole, the document is arriving as + /// one message that doesn't count as heard before it is complete, so a large file on a slow + /// link gets the long reply timeout instead of the idle one. + fn silence_limit(&self, l: &NetLimits) -> Duration { + if self.welcomed { l.idle_timeout } else { l.request_reply_timeout } + } } /// A joined (or joining) session over a [`Socket`]. @@ -315,7 +324,7 @@ impl Driver { // A managing connection waits for each answer. (true, true) => [Some(c.last_heard + l.request_reply_timeout), None, None], (true, false) => { - [Some(c.last_ping + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }] + [Some(c.last_ping + l.keepalive), Some(c.last_heard + c.silence_limit(l)), if c.welcomed { self.throttle.due() } else { None }] } }); earliest(conn.into_iter().flatten().chain([self.reconnect_at])) @@ -332,7 +341,7 @@ impl Driver { } let l = self.cfg.limits; let Some(c) = self.conn.as_ref() else { return }; - let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping); + let (opened, started, last_heard, last_ping, silence) = (c.opened, c.started, c.last_heard, c.last_ping, c.silence_limit(&l)); if !opened { if now >= started + self.cfg.connect_timeout { self.dropped(format!("no answer from {}", self.server().display()), now); @@ -345,7 +354,7 @@ impl Driver { } return; } - if now >= last_heard + l.idle_timeout { + if now >= last_heard + silence { return self.end(Outcome::Retry("the host stopped answering".into()), now); } let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p))); diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs index a94ff064..ea790074 100644 --- a/crates/collab-net/src/driver_tests.rs +++ b/crates/collab-net/src/driver_tests.rs @@ -184,6 +184,38 @@ fn pings_go_out_while_presence_does_and_the_pongs_keep_the_connection() { assert!(pings >= 5, "a ping every 15 s ({pings})"); } +#[test] +fn a_welcome_still_downloading_gets_the_long_reply_timeout() { + let t0 = Instant::now(); + let (mut d, inbox) = start(Script::default(), t0); + d.opened(t0); + let limits = GuestConfig::new(invite(), "Ada".into()).limits; + // The welcome (the whole document) is one message that is not heard until complete: past the + // idle timeout the join must not be given up. + let past_idle = t0 + limits.idle_timeout + Duration::from_secs(1); + d.tick(past_idle); + assert!(!d.done()); + assert_eq!(d.sock.closes, 0); + assert_eq!(statuses(&inbox.drain()), vec![Status::Connecting]); + // It does arrive; from then on a silent host is dropped at the idle timeout. + d.message(&welcome(), past_idle); + let silent = past_idle + limits.idle_timeout; + d.tick(silent - ms(1)); + assert_eq!(d.sock.closes, 0); + d.tick(silent); + assert_eq!(d.sock.closes, 1); +} + +#[test] +fn a_host_that_never_welcomes_is_given_up_on_at_the_reply_timeout() { + let t0 = Instant::now(); + let (mut d, _inbox) = start(Script::default(), t0); + d.opened(t0); + let limit = t0 + GuestConfig::new(invite(), "Ada".into()).limits.request_reply_timeout; + d.tick(limit); + assert_eq!(d.sock.closes, 1); +} + #[test] fn a_silent_host_counts_as_dropped_and_the_client_reconnects() { let t0 = Instant::now(); diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index 68137981..e36a2dcc 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -233,8 +233,12 @@ impl LiveLink for NetLink { let (inbox, fetching, limits) = (self.libraries.clone(), self.fetching.clone(), self.limits); let spawned = std::thread::Builder::new().name("library-fetch".into()).spawn(move || { for (link, req) in jobs { + // A report's answer says nothing about the library (and an older server refuses it). + let report = matches!(req, LibraryReq::ReportDetached(_)); let reply = manage::library(&link, req, &limits, CONNECT); - inbox.push(Event::Library { link, reply }); + if !report { + inbox.push(Event::Library { link, reply }); + } } fetching.store(false, Ordering::SeqCst); }); diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index e4b48aac..4c6a0c9f 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -4,9 +4,9 @@ use figcraft_doc::{Document, NodeId, NodeKind}; use crate::change::{Change, Json, Prop}; use crate::message::{ - Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, FileRow, FileUsage, Hello, HostInfo, LibraryReply, LibraryReq, - LinkInfo, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, Peer, PluginEntry, PluginReply, PluginReq, Presence, Processed, - ReportInfo, Resume, ServerMsg, Signal, Usage, WIRE_VERSION, decode, decode_json, encode, encode_json, + Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, Detached, EditingText, FileDetached, FileInfo, FileRow, FileUsage, Hello, HostInfo, + LibraryReply, LibraryReq, LinkInfo, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, Peer, PluginEntry, PluginReply, PluginReq, + Presence, Processed, ReportInfo, Resume, ServerMsg, Signal, Usage, WIRE_VERSION, decode, decode_json, encode, encode_json, }; use crate::sim::Rng; use crate::{Error, Replica, diff}; @@ -71,6 +71,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::Library(LibraryReq::ReportDetached(Detached { file: "Checkout".into(), components: vec![(NodeId(7), 2)] })), + ClientMsg::Library(LibraryReq::Detached), ClientMsg::Signal(Signal::chat("Hi ☕")), ClientMsg::Signal(Signal::spotlight(true)), ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL, key: String::new(), client_version: "0.3.1".into() }), @@ -139,6 +141,11 @@ fn samples() -> (Vec, Vec) { ServerMsg::Admin(Box::new(AdminReply::Done)), ServerMsg::Admin(Box::new(AdminReply::Failed { reason: "no".into() })), ServerMsg::Library(Box::new(LibraryReply::Ready { access: Access::View })), + ServerMsg::Library(Box::new(LibraryReply::Noted)), + ServerMsg::Library(Box::new(LibraryReply::Detached(vec![FileDetached { + detached: Detached { file: "Checkout".into(), components: vec![(NodeId(7), 2)] }, + updated: 1_700_000_000, + }]))), ServerMsg::Library(Box::new(LibraryReply::Library { version: 2, archive: vec![1, 2, 3] })), ServerMsg::Library(Box::new(LibraryReply::Unchanged { version: 2 })), ServerMsg::Library(Box::new(LibraryReply::Empty)), @@ -294,3 +301,16 @@ fn message_sizes() { assert!(cb <= 32, "a cursor update is tiny: {cb}"); assert!(eb <= 96, "a move is small: {eb}"); } + +/// The library messages from before detach counts keep their bytes (the newer ones were added +/// after them), so apps and servers on either side of the change still read each other. +#[test] +fn older_library_messages_keep_their_encoding() { + let body = |b: Vec| b[1..].to_vec(); + assert_eq!(body(encode(&LibraryReq::Fetch { have: None, usage: None }).unwrap()), [0, 0, 0]); + assert_eq!(body(encode(&LibraryReq::Usage).unwrap()), [2]); + assert_eq!(body(encode(&LibraryReply::Usage(Vec::new())).unwrap()), [5, 0]); + assert_eq!(body(encode(&LibraryReply::Failed { reason: "x".into() }).unwrap()), [6, 1, b'x']); + let usage = FileUsage { usage: Usage { file: "A".into(), components: vec![(NodeId(7), 2)] }, updated: 3 }; + assert_eq!(body(encode(&usage).unwrap()), [1, b'A', 1, 7, 2, 3]); +} diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 4a5954a6..01577bce 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -608,6 +608,21 @@ pub struct FileUsage { pub updated: u64, } +/// How many instances of each library component one file has detached, as its app reports it: +/// the file's name and the counts (by the component's id in the library), each file's own total. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Detached { + pub file: String, + pub components: Vec<(NodeId, u32)>, +} + +/// A file's last report of its detaches, with when it came (seconds since 1970, UTC). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FileDetached { + pub detached: Detached, + pub updated: u64, +} + /// A request on a library connection; each gets one [`LibraryReply`], in order. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -620,6 +635,13 @@ pub enum LibraryReq { Publish { archive: Vec }, /// The usage reports of the files that use the library. Usage, + // Variants below are newer: a server from before them can't read them (it refuses the + // request), and the ones above keep their encoding. + /// How the asking file has detached the library's components (answered with + /// [`LibraryReply::Noted`]). + ReportDetached(Detached), + /// The detach reports of the files that use the library. + Detached, } /// The answer to a [`LibraryReq`] (the first one on a connection answers [`OpenLibrary`]). @@ -649,6 +671,10 @@ pub enum LibraryReply { Failed { reason: String, }, + // Newer replies, only ever sent to the newer requests above. + /// A report was kept. + Noted, + Detached(Vec), } /// Opens a connection to a server's plugin directory (instead of [`Hello`]). Anyone who can reach diff --git a/crates/doc/src/library.rs b/crates/doc/src/library.rs index 348c4544..fb1a333e 100644 --- a/crates/doc/src/library.rs +++ b/crates/doc/src/library.rs @@ -204,9 +204,36 @@ pub struct Libraries { /// Library variables copied into the document's variables. #[serde(skip_serializing_if = "Vec::is_empty")] pub variables: Vec, + /// How many instances of each library component this document has detached (reported to + /// the library's server with its usage); at most [`MAX_DETACH_COUNTS`] components. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub detached: Vec, +} + +/// Most library components a document counts detaches for. +pub const MAX_DETACH_COUNTS: usize = 1_000; + +/// Instances of one library component a document has detached. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DetachCount { + /// The library's id. + pub library: String, + /// The component's (or set's) id in the library. + pub key: NodeId, + pub count: u32, } impl Libraries { + /// Count one more detached instance of component `key` of library `library` (a component not + /// counted yet is left out once [`MAX_DETACH_COUNTS`] are). + pub fn count_detach(&mut self, library: &str, key: NodeId) { + if let Some(d) = self.detached.iter_mut().find(|d| d.library == library && d.key == key) { + d.count = d.count.saturating_add(1); + } else if self.detached.len() < MAX_DETACH_COUNTS { + self.detached.push(DetachCount { library: library.to_string(), key, count: 1 }); + } + } + pub fn is_empty(&self) -> bool { self.enabled.is_empty() && self.store.is_none() @@ -214,6 +241,7 @@ impl Libraries { && self.styles.is_empty() && self.collections.is_empty() && self.variables.is_empty() + && self.detached.is_empty() } /// Serde helper: leave the field out of files that use no libraries. diff --git a/crates/engine/src/assets/library/tests.rs b/crates/engine/src/assets/library/tests.rs index c8ef2e49..fd765522 100644 --- a/crates/engine/src/assets/library/tests.rs +++ b/crates/engine/src/assets/library/tests.rs @@ -239,6 +239,25 @@ fn library_updates_flow(s: &mut Session, lib: &mut Session, k: &Kit, lid: &str, card_inst } +/// Detaching an instance of a library component counts it, for the library's analytics: kept with +/// the file and reported to the library's server with its usage. +fn detaching_a_library_instance_counts_it(s: &mut Session, d: &std::path::Path, lid: &str, inst: NodeId) { + ex(s, "component.detach", json!({"ids": [inst]})); + let detached = s.doc().unwrap().libraries.detached.clone(); + assert_eq!(detached.len(), 1, "{detached:?}"); + assert_eq!((detached[0].library.as_str(), detached[0].count), (lid, 1)); + let report = crate::library_server::detached(s.doc().unwrap(), lid).expect("a report"); + assert_eq!(report.components, [(detached[0].key, 1)]); + let again = path_str(&d.join("app-detached.figcraft")); + ex(s, "file.saveAs", json!({"path": again})); + let mut t = Session::empty(); + ex(&mut t, "file.open", json!({"path": again})); + assert_eq!(t.doc().unwrap().libraries.detached, detached, "kept with the file"); + // A plain frame detaches nothing (and counts nothing). + assert!(s.execute("component.detach", &json!({"ids": [inst]})).is_err()); + assert_eq!(s.doc().unwrap().libraries.detached, detached); +} + #[test] fn add_place_and_update_keep_overrides() { let d = dir("flow"); @@ -299,8 +318,9 @@ fn add_place_and_update_keep_overrides() { healthy(&t); assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries); + detaching_a_library_instance_counts_it(&mut s, &d, &lid, card_inst); + // Removing the library keeps the copies instances use and drops the rest. - ex(&mut s, "component.detach", json!({"ids": [card_inst]})); ex(&mut s, "library.remove", json!({"id": lid})); let doc = s.doc().unwrap(); assert!(doc.libraries.enabled.is_empty()); diff --git a/crates/engine/src/assets/library_server.rs b/crates/engine/src/assets/library_server.rs index 5950b681..a55ea585 100644 --- a/crates/engine/src/assets/library_server.rs +++ b/crates/engine/src/assets/library_server.rs @@ -14,7 +14,7 @@ use std::io::Cursor; use std::sync::Arc; -use figcraft_collab::message::{LibraryReply, LibraryReq, Usage}; +use figcraft_collab::message::{Detached, LibraryReply, LibraryReq, Usage}; use figcraft_doc::{Document, NodeId, NodeKind, PublishedLibrary}; use crate::live::{NetReply, NetRequest}; @@ -91,6 +91,14 @@ pub fn usage(doc: &Document, library: &str) -> Usage { Usage { file: doc.name.clone(), components: counts.into_iter().take(MAX_USAGE).collect() } } +/// How many instances of `library`'s components `doc` has detached, as its server keeps them; +/// `None` when it has detached none. +pub fn detached(doc: &Document, library: &str) -> Option { + let components: Vec<(NodeId, u32)> = + doc.libraries.detached.iter().filter(|d| d.library == library && d.count > 0).map(|d| (d.key, d.count)).take(MAX_USAGE).collect(); + (!components.is_empty()).then(|| Detached { file: doc.name.clone(), components }) +} + /// A reply that doesn't answer the request. fn unexpected(r: &LibraryReply) -> EngineError { EngineError::Other(format!("the server answered {r:?}")) @@ -194,12 +202,15 @@ impl Session { .enabled .iter() .filter(|l| is_server_link(&l.path) && self.library_allowed(&l.path)) - .map(|l| { + .flat_map(|l| { let have = match self.libraries.cached(&l.path) { Some(Ok(lib)) => Some(lib.manifest.version), _ => None, }; - (l.path.clone(), LibraryReq::Fetch { have, usage: Some(usage(doc, &l.id)) }) + // Then the detaches, when there are any (a server from before them refuses the + // request, and nothing comes of it). + let report = detached(doc, &l.id).map(|d| (l.path.clone(), LibraryReq::ReportDetached(d))); + std::iter::once((l.path.clone(), LibraryReq::Fetch { have, usage: Some(usage(doc, &l.id)) })).chain(report) }) .collect(); if let (false, Some(net)) = (jobs.is_empty(), self.live.link()) { diff --git a/crates/engine/src/cmd/component/instance.rs b/crates/engine/src/cmd/component/instance.rs index 4a73e44a..86af6d54 100644 --- a/crates/engine/src/cmd/component/instance.rs +++ b/crates/engine/src/cmd/component/instance.rs @@ -18,6 +18,13 @@ pub(super) fn detach(s: &mut Session, p: &Value) -> Result { if inside_instance(doc, i) { return Err(EngineError::Disabled(cmd.into(), "a nested instance can't be detached on its own: detach the outer instance first".into())); } + // A library component's instance counts as detached, for the library's analytics. + if let NodeKind::Instance { component, .. } = doc.get(i)?.kind + && let Some(root) = crate::library::asset_root(doc, component) + && let Some((library, key)) = doc.libraries.components.iter().find(|r| r.root == root).map(|r| (r.library.clone(), r.key)) + { + doc.libraries_mut().count_detach(&library, key); + } // Own layers lose their link (slot content stays as it is); nested instances stay // instances of their own mains. let nested = unlink_layers(doc, i, Some(i)); diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index dd361ddc..08f07d4a 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use figcraft_doc::{Collection, Document, ImportedComponent, ImportedRecord, LibraryLink, LibraryManifest, NodeId, PublishedLibrary, VariableId}; use serde_json::{Value, json}; -use figcraft_collab::message::{LibraryReply, LibraryReq}; +use figcraft_collab::message::{FileDetached, LibraryReply, LibraryReq}; use super::CommandSpec; use crate::library::{ @@ -60,7 +60,7 @@ pub fn specs() -> Vec { spec!( "library.usage", "Library usage", - "{library? (an enabled team library: id, name or link; omit for this file's library on its server)} → what files report: {id, name, instances, files: [{name, updated, instances}], components: [{id, name, instances, files}]}", + "{library? (an enabled team library: id, name or link; omit for this file's library on its server)} → what files report: {id, name, instances, detached, detachesKnown (false: the server keeps no detaches), files: [{name, updated, instances, detached}], components: [{id, name, instances, files, detached}]}; a detach is an instance of the component a file detached", false, usage_cmd ), @@ -432,9 +432,10 @@ fn usage_cmd(s: &mut Session, p: &Value) -> Result { LibraryReply::Usage(f) => f, other => return Err(unexpected(&other)), }; + let detached = detach_reports(s.library_request(&link, LibraryReq::Detached))?; let m = &lib.manifest; - // Component → (instances, files using it). - let mut per: std::collections::BTreeMap = std::collections::BTreeMap::new(); + // Component → (instances, files using it, instances detached). + let mut per: std::collections::BTreeMap = std::collections::BTreeMap::new(); let mut rows = Vec::new(); for f in &files { let mut n = 0u64; @@ -444,21 +445,39 @@ fn usage_cmd(s: &mut Session, p: &Value) -> Result { e.1 += 1; n += u64::from(*k); } - rows.push(json!({"name": f.usage.file, "updated": f.updated, "instances": n})); + let gone = detached.iter().flatten().filter(|d| d.detached.file == f.usage.file).flat_map(|d| &d.detached.components); + rows.push(json!({"name": f.usage.file, "updated": f.updated, "instances": n, "detached": gone.map(|(_, k)| u64::from(*k)).sum::()})); } - let mut comps: Vec<(NodeId, u64, u64)> = per.into_iter().map(|(c, (n, f))| (c, n, f)).collect(); - comps.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); - let components: Vec = - comps.iter().map(|(c, n, f)| json!({"id": c, "name": m.component(*c).map(|a| a.name.clone()), "instances": n, "files": f})).collect(); + for (c, k) in detached.iter().flatten().flat_map(|d| &d.detached.components) { + per.entry(*c).or_default().2 += u64::from(*k); + } + let mut comps: Vec<(NodeId, (u64, u64, u64))> = per.into_iter().collect(); + comps.sort_by(|a, b| b.1.0.cmp(&a.1.0).then(b.1.2.cmp(&a.1.2)).then(a.0.cmp(&b.0))); + let components: Vec = comps + .iter() + .map(|(c, (n, f, d))| json!({"id": c, "name": m.component(*c).map(|a| a.name.clone()), "instances": n, "files": f, "detached": d})) + .collect(); Ok(json!({ "id": m.id, "name": m.name, - "instances": comps.iter().map(|c| c.1).sum::(), + "instances": comps.iter().map(|c| c.1.0).sum::(), + "detached": comps.iter().map(|c| c.1.2).sum::(), + "detachesKnown": detached.is_some(), "files": rows, "components": components, })) } +/// The detach reports a server keeps; `None` from a server from before they were kept (it refuses +/// the request). Waiting for the answer is still waiting. +fn detach_reports(reply: Result) -> Result>> { + match reply { + Ok(LibraryReply::Detached(d)) => Ok(Some(d)), + Err(EngineError::Waiting) => Err(EngineError::Waiting), + _ => Ok(None), + } +} + fn swap_cmd(s: &mut Session, p: &Value) -> Result { let cmd = "library.swap"; let from = req_string(cmd, p, "from")?; @@ -703,3 +722,20 @@ fn apply_style(s: &mut Session, p: &Value) -> Result { super::styles::apply(s, &with(p, &["ids", "id", "target"], json!({"style": local})))?; Ok(json!({"style": local})) } + +#[cfg(test)] +mod detach_report_tests { + use super::*; + use figcraft_collab::message::Detached; + + /// A server that keeps detaches answers with them; one from before them refuses the request, + /// which leaves the analytics without detaches instead of failing; waiting stays waiting. + #[test] + fn detaches_are_unknown_on_an_older_server() { + let d = vec![FileDetached { detached: Detached { file: "Checkout".into(), components: vec![(NodeId(7), 2)] }, updated: 1 }]; + assert_eq!(detach_reports(Ok(LibraryReply::Detached(d.clone()))).unwrap(), Some(d)); + let old = Err(EngineError::Other("the server's message couldn't be read".into())); + assert_eq!(detach_reports(old).unwrap(), None); + assert!(matches!(detach_reports(Err(EngineError::Waiting)), Err(EngineError::Waiting))); + } +} diff --git a/crates/engine/src/sharing/background/tests.rs b/crates/engine/src/sharing/background/tests.rs index d71a44be..f7a995cf 100644 --- a/crates/engine/src/sharing/background/tests.rs +++ b/crates/engine/src/sharing/background/tests.rs @@ -91,6 +91,8 @@ fn library(w: &mut Wire, req: LibraryReq) -> LibraryReply { LibraryReply::Published { version: 1 } } (LibraryReq::Usage, _) => LibraryReply::Usage(Vec::new()), + (LibraryReq::ReportDetached(_), _) => LibraryReply::Noted, + (LibraryReq::Detached, _) => LibraryReply::Detached(Vec::new()), } } diff --git a/crates/ui-egui/locales/bs.toml b/crates/ui-egui/locales/bs.toml index 306d0522..12f088d3 100644 --- a/crates/ui-egui/locales/bs.toml +++ b/crates/ui-egui/locales/bs.toml @@ -23,6 +23,7 @@ status = "draft — needs native review" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "Jedan sloj se poravnava prema svom okviru: odaberite više slojeva ili jedan sloj u okviru" "A responsive product page: hero, features, figures, a quote and a footer" = "Responzivna stranica proizvoda: naslovni dio, mogućnosti, brojke, citat i podnožje" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Opruga traje dok se ne smiri: njena masa, krutost i prigušenje određuju koliko dugo" +"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Tabela prima najviše {rows} redova i {columns} kolona: ostatak nije zalijepljen" "A title, an agenda, a big figure, a chart and a closing slide" = "Naslov, dnevni red, velika brojka, grafikon i završni slajd" "A to Z" = "A do Ž" "About" = "O aplikaciji" @@ -822,6 +823,8 @@ status = "draft — needs native review" "Main menu" = "Glavni meni" "Make a component" = "Napravi komponentu" "Make slot" = "Napravi slot" +"Pasted design layers" = "Zalijepljeni slojevi dizajna" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Zalijepljeni slojevi dizajna; međuspremnik nije prenio slike za: {layers}" "Scroll wheel zoom" = "Zumiranje kotačićem miša" "Slot name" = "Ime slota" "Mar" = "mar." @@ -1710,6 +1713,7 @@ status = "draft — needs native review" "connector label was skipped: text style data is missing" = "oznaka konektora je preskočena: nedostaju podaci o stilu teksta" "connector label was skipped: text transform is invalid" = "oznaka konektora je preskočena: transformacija teksta nije ispravna" "connector labels were skipped" = "oznake konektora su preskočene" +"connector labels without a saved snapshot are centred on the line, without their background" = "oznake konektora bez sačuvanog snimka centrirane su na liniji, bez svoje pozadine" "connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "slojevi konektora se uvoze kao statični snimci okvira/vektora s oznakama ako postoje; žive veze krajnjih tačaka se ne zadržavaju" "connector snapshot vector data is missing; connector was left empty" = "nedostaju vektorski podaci snimka konektora; konektor je ostavljen prazan" "connectors whose ends can't be placed are empty" = "konektori čiji se krajevi ne mogu postaviti su prazni" @@ -1731,6 +1735,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "lista veličina traka mreže premašuje 1000 unosa; trake koriste zadanu jednaku veličinu" "hidden" = "skriveno" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "ispune slikom čija slika nije u datoteci prikazuju zamjenski prikaz nedostajuće slike i zadržavaju njeno ime" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "filteri slike su dvosmisleni jer su prisutna oba kontejnera filtera; pretvaranje filtera je preskočeno" "image filters use FigCraft's approximate renderer" = "filteri slike koriste FigCraftov približni renderer" "instance swap properties whose default component isn't in the file were skipped" = "preskočena su svojstva zamjene instance čija zadana komponenta nije u datoteci" @@ -1753,6 +1758,7 @@ status = "draft — needs native review" "layers without a type were skipped" = "slojevi bez vrste su preskočeni" "legacy image filter container is unsupported; filter conversion was skipped" = "stari kontejner filtera slike nije podržan; pretvaranje filtera je preskočeno" "linked vector region fill styles keep resolved appearance; live style links were left out" = "povezani stilovi ispune vektorskih oblasti zadržavaju razriješeni izgled; žive veze sa stilovima su izostavljene" +"links to shared styles that aren't in the file kept the layers' own values" = "veze na dijeljene stilove koji nisu u datoteci zadržale su vlastite vrijednosti slojeva" "locked" = "zaključano" "malformed image filters container was skipped" = "neispravan kontejner filtera slike je preskočen" "malformed image paintFilter container was skipped" = "neispravan kontejner paintFilter slike je preskočen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 197c864a..215cef14 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1529,6 +1529,7 @@ name = "English" "This frame isn't on the branch." = "This frame isn't on the branch." "This link opens a website outside the prototype:" = "This link opens a website outside the prototype:" "This link opens your mail app to write a message:" = "This link opens your mail app to write a message:" +"This server doesn't count detached instances" = "This server doesn't count detached instances" "This server is open to the public: no key needed." = "This server is open to the public: no key needed." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." "This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced." @@ -1873,6 +1874,7 @@ name = "English" "{n} components and styles have newer versions" = { one = "{n} component or style has a newer version", other = "{n} components and styles have newer versions" } "{n} conflicts to resolve" = { one = "{n} conflict to resolve", other = "{n} conflicts to resolve" } "{n} days ago" = { one = "{n} day ago", other = "{n} days ago" } +"{n} detached" = { one = "{n} detached", other = "{n} detached" } "{n} files" = { one = "{n} file", other = "{n} files" } "{n} hours ago" = { one = "{n} hour ago", other = "{n} hours ago" } "{n} instances" = { one = "{n} instance", other = "{n} instances" } @@ -1911,6 +1913,7 @@ name = "English" "{server} · No longer on the server" = "{server} · No longer on the server" "{server} · {shared} · Opened {date}" = "{server} · {shared} · Opened {date}" "{tool} hold {key}" = "{tool} hold {key}" +"{used} · {detached}" = "{used} · {detached}" "{value} isn't a colour" = "{value} isn't a colour" "{when} · edited" = "{when} · edited" "← ↑ → ↓" = "← ↑ → ↓" diff --git a/crates/ui-egui/src/panels/assets_panel.rs b/crates/ui-egui/src/panels/assets_panel.rs index 1dab99f9..2da51042 100644 --- a/crates/ui-egui/src/panels/assets_panel.rs +++ b/crates/ui-egui/src/panels/assets_panel.rs @@ -1010,13 +1010,28 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { let instances = v["instances"].as_u64().unwrap_or(0); let files_n = |n: u64| trn("{n} files", n as usize, &[]); let instances_n = |n: u64| trn("{n} instances", n as usize, &[]); - hint(ui, &trf("Used in {files} · {instances}", &[("files", &files_n(files as u64)), ("instances", &instances_n(instances))])); + // Detached instances, beside what they are counted with (a server from before them keeps none). + let known = v["detachesKnown"].as_bool().unwrap_or(false); + let with_detached = |used: String, n: u64| { + if known && n > 0 { trf("{used} · {detached}", &[("used", &used), ("detached", &trn("{n} detached", n as usize, &[]))]) } else { used } + }; + let used = trf("Used in {files} · {instances}", &[("files", &files_n(files as u64)), ("instances", &instances_n(instances))]); + hint(ui, &with_detached(used, v["detached"].as_u64().unwrap_or(0))); + if !known { + hint(ui, tr("This server doesn't count detached instances")); + } let t = Tokens::get(ui.ctx()); for c in v["components"].as_array().into_iter().flatten().take(USAGE_ROWS) { let name = c["name"].as_str().unwrap_or(tr("(no longer published)")); let (n, f) = (c["instances"].as_u64().unwrap_or(0), c["files"].as_u64().unwrap_or(0)); let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover()); - let right = trf("{instances} in {files}", &[("instances", &instances_n(n)), ("files", &files_n(f))]); + let gone = c["detached"].as_u64().unwrap_or(0); + // A component only detached (no instances left anywhere) says just that. + let right = if n == 0 && known && gone > 0 { + trn("{n} detached", gone as usize, &[]) + } else { + with_detached(trf("{instances} in {files}", &[("instances", &instances_n(n)), ("files", &files_n(f))]), gone) + }; let rg = ui.painter().layout_no_wrap(right, regular(10.0), t.text_secondary); ui.painter().galley(pos2(r.max.x - PAD - rg.size().x, r.center().y - rg.size().y / 2.0), rg.clone(), t.text_secondary); crate::bidi::paint_name(ui.painter(), pos2(r.min.x + PAD, r.center().y), name, regular(11.0), t.text, w - PAD * 3.0 - rg.size().x); diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 2f8b15a7..3b5159e7 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -257,7 +257,7 @@ Client → server: | `Manage {protocol, key, client_version}` | Instead of `Hello`: manage a server's files with its key. | | `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)]}`. | +| `Library {fetch {have?, usage?} \| publish {archive} \| usage \| reportDetached {file, components} \| detached}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`; `reportDetached` gives the asking file's detached instances per component the same way (answered `noted`), `detached` asks for every file's. The last two are newer: a server from before them can't read them and refuses the request. | | `Signal {kind, text}` | Ephemeral presence, only after the server's `ready`: `chat` (the cursor chat message; empty: closed), `spotlight` (`"on"`, or empty: stopped) or `name` (your new name: the server keeps it, cleaned like a hello's, and sends everyone, you too, your `PeerJoined`; never passed on as a signal). | | `OpenPlugins {protocol, key, client_version}` | Instead of `Hello`: use the server's plugin directory; `key` empty to browse, the server key to publish too. | | `OpenHosting {protocol, client_version, file, owner}` | Instead of `Hello`: ask about the server's hosting; publish without the key (`Admin publish`) when it is open to the public; with a file's id and owner token, manage that file (`Admin links \| createLink \| revoke \| rename \| remove` of it). Answered with `Hosting` or `Refused`. | @@ -279,7 +279,7 @@ Server → client: | `Refused {reason}` | The connection is refused (protocol version, token, session full, a message out of place). | | `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. | +| `Library {ready {access} \| library {version, archive} \| unchanged {version} \| empty \| published {version} \| usage [{usage, updated}] \| failed {reason} \| noted \| detached [{detached, updated}]}` | 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; then (newer) a detach report kept, and the detach reports. | | `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`. | | `Hosting {open, operator, terms, idle_secs, max_file_mb, expires?}` | The answer to `OpenHosting`, and after a welcome to clients whose hello announced `hosting`: anyone may publish, how to reach the operator, the terms (a URL), how long files published without the key are kept unopened, the largest file, and this file's expiry (seconds since 1970). | | `Admin {hosted {file, edit, view, owner, expires?} \| listing [row] \| reports [report] \| blocked [ip]}` | A file published without the key with its links and owner token (shown once), and the answers to the later managing requests. | @@ -718,7 +718,9 @@ resuming with its offline edits, and leaving the session going back to snapshot (resuming as the same client after a drop), presence held until the welcome and changes made for an earlier connection dropped (the sync client sends them again once caught up), presence throttled to about 30 updates a second (latest wins), a `Ping` after 15 s without sending (browsers don't expose WebSocket ping frames; - the host answers `Pong`), a connection silent for 45 s counted as dropped, reconnects with the + the host answers `Pong`), a connection silent for 45 s counted as dropped (5 minutes before the + welcome: it carries the whole document as one message, which a large file on a slow link takes + a while to download, and nothing counts as heard until it is complete), reconnects with the same growing pauses after a live connection drops, refusals and the end of the session final, and a first join that can't reach the host failing at once. The browser layer only feeds it the socket's events and one `setTimeout` for its next deadline, and every arrival wakes egui @@ -813,6 +815,11 @@ resuming with its offline edits, and leaving the session going back to snapshot - **Usage.** Each fetch carries the asking file's name and its instances of each library component; the server keeps the latest report per file name (`usage.json`, rewritten only when it changes, capped) and answers `usage` with them. `library.usage` adds them up with the components' names. + After the fetch, a file that has detached instances of the library's components sends + `reportDetached` with its counts; the server keeps them beside the file's usage report (in the + same `usage.json` entry, capped the same way) and answers `detached` with them. The variants are + appended to the enums, so the older messages keep their bytes; an older server refuses the new + requests, and the app ignores that. *Built* (the plugin directory; operating it: [`server.md`](server.md#plugin-directory), publishing and installing: [`plugins.md`](plugins.md#plugin-directories)): diff --git a/docs/components-parity.md b/docs/components-parity.md index 406ae1ed..b9d00c4d 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -52,7 +52,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Enable a library in a file; read-only linked copies | done | | | Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) | | Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) | -| Library analytics | partial | instances per component and the files using them, as apps report them; nothing over time, no detach counts | +| Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; nothing over time, no insertion counts | | Swap library (instances, styles and variables to another library's of the same names, overrides kept) | done | what has no match stays and is listed; one undo step | ## Styles and variables @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Library analytics count detaches: a file that detaches an instance of a library component counts it (kept with the file) and its app reports the counts to the library's server beside its usage; **Show usage** and `library.usage` list them per component and per file (`detached`, `detachesKnown`). Older servers and apps are unaffected: the reports are new messages and the old ones keep their encoding. Still nothing over time. Numbers unchanged | | 2026-10-30 | minor | M3, #1613: a variable, collection or mode named like another one's id no longer takes its place where an id is stored or typed (expressions, prototype mode switches, font family variables, the panels' labels); names still work where a person types them. Grade unchanged. | | 2026-10-30 | minor | M3, #1280: a colour variable can be calculated from others, per mode: `{"alpha": {"variable", "opacity"}}` is that variable's colour with its alpha replaced (replace, not multiply, so re-editing the source's alpha never compounds), `{"mix": {"from", "to", "t"}}` blends two operands (`{"variable"}` or `{"literal"}`) per channel over the stored values in the document's colour profile, alpha included (converting the profile keeps a mix's ends exact and moves its colours in between slightly). Calculations resolve lazily like aliases, so bound layers, mode switches, instances and undo follow their sources; they compose (a calculation of a calculation), a loop is refused when it is set, and one resolve consults at most 32 variables. Files keep them; REST, CSS, Penpot and `.fig` exports write the colour they show in each of their collection's modes (other collections in their default modes, so a CSS calculation doesn't follow another collection's `data-` mode); deleting a source leaves its readers on the colour they showed; library import and swap re-point their operands. The panel shows them read-only (swatch and formula) and can swap one for an alias or detach it to its colour. Tests: `engine/src/variables_colour_tests.rs`, `doc/src/tests.rs`. Left: editing calculations in the panel; files using them need this build to open. Numbers unchanged | | 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 8540d461..5aeee223 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -342,8 +342,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ### 10. Team libraries: no team-wide lists or accounts; analytics are counts - **Missing:** libraries listed for a whole team or organization (each file enables a library by - its link), accounts and per-person library permissions, analytics beyond instance counts per - component and file (insertions and detaches over time), update notices pushed by the server + its link), accounts and per-person library permissions, analytics beyond instance and detach + counts per component and file (insertions, and anything over time), update notices pushed by the server (apps ask every 30 seconds while the Assets panel shows). - **Evidence:** a file's library is published to `figcraft-server` under the file and enabled in other files by a share link, with per-asset versions, updates with overrides kept, usage counts @@ -573,6 +573,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Gap 10: library analytics count detached instances per component and file (reported by the file that detached them, kept by the server beside its usage, shown in Show usage and `library.usage`); insertions and counts over time remain. Estimate unchanged. Tests: `library_detach_tests` (server), `codec_tests::older_library_messages_keep_their_encoding`, `assets::library::tests::add_place_and_update_keep_overrides`, `detach_report_tests` | | 2026-10-30 | patch | Gap 15: `proto.linkNotice` (the Actions palette, the control channel, MCP) is saved with the preferences at once, as the notice's own "Don't show this again" is, instead of only when the app quits; that note leaves the missing list. Estimate unchanged. Test: `present_links_tests::skipping_the_notice_by_command_is_saved_at_once` | | 2026-10-30 | minor | Gap 6: plugin panels show pictures: an `image` control (`data` base64, at most 256 KB and 4,096 px a side; panel specs up to 1 MB) drawn at its size up to a field's width, named by its label, which shows instead when the picture can't be decoded. "Images" leaves the missing list; estimate unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::panel_images_show_named_or_fall_back_to_their_label` | | 2026-10-30 | minor | Gap 1: labels of connectors without a saved snapshot are kept, centred on their line (without their background), instead of skipped. Not checked on real files; estimate unchanged. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 04db2a88..a84e478d 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -105,7 +105,7 @@ Other languages (measured 2026-10-22): Russian (`ru`) 2,352 / 2,352 (100%), revi by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,314 / 2,352 (98.4%), its command labels and menu wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,291 / 2,352 (97.4%), whose header says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,292 / 2,352 (97.4%), a draft -with one/few/other plurals; Bosnian (`bs`, Bosanski, added 2026-10-30) 2,561 / 2,561 (100%: 1,901 UI +with one/few/other plurals; Bosnian (`bs`, Bosanski, added 2026-10-30) 2,566 / 2,566 (100%: 1,906 UI strings, 629 command labels, 31 menu wordings), a machine draft awaiting native review, with one/few/other plurals (21 takes one). Figma ships none beyond the eight above. @@ -125,7 +125,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see | Date | Change | Summary | |---|---|---| -| 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,901), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) | +| 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) | | 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed | | 2026-10-24 | minor | Thai (`th`) draft catalog: `th.toml` translates every UI string (1,791), 589 command labels and the 31 menu wordings, machine-drafted and marked as a draft awaiting native review (9 newer command labels read English, as in the other drafts). The desktop UI now adds an installed Thai face (Noto Sans Thai, Leelawadee UI, Tahoma, Sukhumvit Set, Thonburi, Ayuthaya, Sarabun, Loma, Garuda or Waree) when a layer or page name or the UI language needs it, so Thai names no longer show as tofu squares. No plural forms for Thai. Grade and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 5978f05f..ec46f9ef 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -924,9 +924,11 @@ most when it can't be reached). `library.acceptUpdates` takes it, overrides kept. The app's own background checks ask only about team libraries added (`library.add`) or checked (`library.check {library?}`) on this computer, so opening a file that names another one contacts nothing (#1097). -- `library.usage {library?}` → `{id, name, instances, files: [{name, updated, instances}], - components: [{id, name, instances, files}]}`: what the files using it reported (each fetch - reports the asking document's name and its instances per component). `library` is an enabled +- `library.usage {library?}` → `{id, name, instances, detached, detachesKnown, files: [{name, + updated, instances, detached}], components: [{id, name, instances, files, detached}]}`: what the + files using it reported (each fetch reports the asking document's name and its instances per + component; then its detached instances per component, which `detached` adds up; + `detachesKnown` is false from a server that doesn't keep them). `library` is an enabled team library; omit it for the active document's own library on its server. - `library.swap {from, to}` (any two enabled libraries, files or team) re-points every instance, style and variable copied from `from` to the published asset of the same name in `to` (variants diff --git a/docs/roadmap.md b/docs/roadmap.md index 588a8f48..75a87bf3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| | 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`#join=…`). A browser build served beside its server names it with ``, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged | +| 2026-10-30 | minor | M3, gap 10: library analytics count detached instances per component and file (reported to the server beside usage; Show usage and `library.usage`). Tests: `library_detach_tests`, `codec_tests` | | 2026-10-30 | minor | M12, #1596: the bar over a selected connector on a board had the line type and one arrow button. It now has, beside the board colours and the line type, a colour wheel opening the colour picker for any colour (when only connectors are selected), menus for the start and end points (every line end the Design panel offers, each shown), and a line style menu (solid or dashed, weights 1, 2, 4 and 6), each control naming itself on hover. The arrow toggle gave way to the end point menu. UI only: `board.setStyle` caps and the stroke's dashes and weight, one undo step each. Tests: `board_line_tests.rs` (headless). | | 2026-10-30 | minor | M12, gap 24: cells copied from a spreadsheet paste onto a board as a table. The clipboard carries them as text: a row per line, a tab between cells, and a cell holding a tab, a line break or a quote in quotes. `board_table::spreadsheet_cells` reads that into `board.table`'s `cells`. The table is centred in the view, or with Paste here its top-left goes at the point. Up to 50 × 20 cells are kept, with a note when there were more. Text without tabs, comma-separated text and any paste into a design file stay a text layer, as before. Tests: `board_table_paste_tests`, `table_paste_tests`. | | 2026-10-30 | minor | M6, #1650: a very long frame name made the Export section's **Export <name>** button wrap its label into a tall block centred on the button, painted over Position, Layout, Appearance, Fill and the rest of the Design panel. The button now shows one line ending with an ellipsis, with the full name on hover, and line breaks in a name show as spaces. Dev Mode's header, which names the layer, does the same instead of running off the panel's edge. Shortening text to fit now halves its way to the longest fit instead of trying one candidate per character: with a 4,000-character name that kept frames at about 0.4 ms instead of about 9.5 ms, and it finds exactly what the old search found. | diff --git a/docs/server.md b/docs/server.md index a79db19b..60e2d688 100644 --- a/docs/server.md +++ b/docs/server.md @@ -249,9 +249,11 @@ which other files use without anyone copying the library file around. checks it by itself from then on (#1097). Placing one of its components or applying one of its styles asks the server too, since you did that yourself. - **Usage.** Each time a file's app asks for the library it reports which of its components the - file uses and how often. **Show usage** in the library file (`library.usage`) lists the files and - the instances of each component. Files are told apart by their names; a file's report changes - only when its usage does. + file uses and how often, and then how many instances of each the file has detached (counted in + the file when you detach one). **Show usage** in the library file (`library.usage`) lists the + files and the instances and detaches of each component. Files are told apart by their names; a + file's report changes only when its usage does. A server from before detach counts refuses + those reports (nothing comes of it) and Show usage says it doesn't count them. - **Swapping.** `library.swap` (the swap button by an enabled library) moves a file's instances, styles and variables from one library to another's of the same names, keeping overrides.