# FigCraft Roadmap **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 connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the 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 the CLI, a JSON control channel and MCP. This page is the one-page summary; agents choosing work start with [`docs/gaps.md`](docs/gaps.md). ## Headline numbers | | Value | Kind | |---|---|---| | Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | | Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | | Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) | | Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~155–280 h** one agent | estimated | | Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| | Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) | | Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see [how the hours are calibrated](docs/target-app-parity.md#how-the-hours-are-calibrated)). Each audience's work is a subset of the next. About 70–80% parallelizes across 4–6 agents on disjoint crates (roughly ÷4 in wall-clock); the `.fig` reader, the multiplayer core and a plugin runtime each need one owner, and native-speaker review, real-file licensing and screen-reader QA need people. Hours are Opus 5.5 agent wall-clock hours, calibrated from this repo's history: the app was built in ~25 wall-clock hours by 4–6 parallel agents (~110–150 agent-hours, ~1 agent-hour per 1,000 added lines, a typical feature PR 1–2.5 h). Details and what needs a human: [`docs/target-app-parity.md`](docs/target-app-parity.md#how-the-hours-are-calibrated). ## Stage **Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs ~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h), basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h); native speakers' reviews of the nine language drafts need people. ## By dimension | Dimension | Ready | Hours | Doc | |---|---|---|---| | Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 60% (`.fig` read best effort with editable vectors and a total ZIP image byte budget; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins and publishes to a server, invite links open the app; a self-hosted server with edit and view links, team libraries and a team list of the files shared now; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) | | Stability | 55% (damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, #1125) | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | | Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | ## Features | Area | Breadth | Ready | Hours | Doc | |---|---|---|---|---| | Canvas & navigation | 98% | 74% | 1–4 | [ui-parity](docs/ui-parity.md) | | Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) | | Layers panel | 99% | 85% | 1–2 | [ui-parity](docs/ui-parity.md) | | Shapes & vector | 99% | 73% | 1–3 | [target-app-parity](docs/target-app-parity.md) | | Paint | 90% | 71% | 2–5 | [target-app-parity](docs/target-app-parity.md) | | Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) | | Effects | 100% | 75% | 2–5 | [target-app-parity](docs/target-app-parity.md) | | Frames, groups, sections, masks | 97% | 80% | 1–3 | [target-app-parity](docs/target-app-parity.md) | | Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) | | Text | 90% | 70% | 3–7 | [target-app-parity](docs/target-app-parity.md) | | Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) | | Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) | | Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) | | Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) | | Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 65% | 38% | 6–22 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | | Boards (whiteboarding; not in the weighted numbers) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) | ## Languages | Language | Code | Strings | Status | |---|---|---|---| | English | en | 100% | full | | Simplified Chinese | zh-Hans | 95% | draft (needs native review) | | Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) | | Hindi | hi | 0% | none | | Arabic | ar | 2,508 / 2,508 entries (100%, counted 2026-10-28) | draft; native review and panel mirroring pending | | French | fr | 95% | draft (needs native review) | | Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | | Indonesian | id | 99% | draft (needs native review) | | Japanese | ja | 99% | draft (needs native review) | | German | de | 96% | draft (needs native review) | | Korean | ko | 95% | draft (needs native review) | | Vietnamese | vi | 100% | draft (needs native review) | Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%), Czech (`cs`, 99%, draft) and Bosnian (`bs`, 100% on 2026-10-30, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, IME) already works (macOS IME composition fixed for #747, not yet checked on a Mac). Detail: [`docs/localization-parity.md`](docs/localization-parity.md). ## Upcoming Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. 4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi and Vietnamese; Arabic native review and panel mirroring ([localization-parity](docs/localization-parity.md)). 5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. ## Progress log - **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. - **2026-10-30** · Text style dedupe is O(spans) (M8, #1102): `shape_text` deduped spans into styles by a scan over the styles already collected — O(spans × styles), so a styled text's dedupe grew with the square of its runs (a synthetic 50k-run text with 100 alternating styles spent 7.7 ms in it). Now the last two styles again (spans run in blocks), then buckets keyed by a cheap fingerprint (family and style bytes, size bits with −0.0 folded, the enums, fill count and solid colours), each bucket resolved with the full equality: the result is the scan's, never worse than it, and O(spans) when names and sizes differ (the same synthetic text: 2.6 ms). The font-db write lock is untouched: `find()` loads faces, which needs the write. `layout_dedupe_tests`. Numbers unchanged. - **2026-10-29** · M10: correct nine German labels in `de.toml`: gradient stops use Verlaufsstopp, OpenType ss01–ss05 use Stilsatz, and the Home-screen preference names the Startbildschirm consistently in the UI and Actions palette. This is a partial terminology pass; German remains a draft awaiting a full native-speaker review and maintainer approval. Numbers unchanged. - **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged. - **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged. - **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged. - **2026-10-27** · M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143. Numbers unchanged. - **2026-10-27** · M6, #1341: FigJam sticky notes and shapes with text import their generated layers (#1397): the background (its saved vector network, or the outer fill or stroke outline when the generated geometry is damaged, before a rectangle), text with its typography and paints, saved bounds and sticky author lines, as editable frame children; missing or invalid generated text falls back to the outer text within the layer budget. Synthetic regressions and a local check of the report sample; native board metadata and direct `.jam` opening remain outside this fix. Numbers unchanged. - **2026-10-27** · Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return one outcome per step. References to failed steps fail with both step numbers; successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. Numbers unchanged. - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged. - **2026-10-25** · M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. - **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)). - **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged. - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged. - **2026-10-24** · Plugin widgets take text input and a property menu (M11, gap 6, plugin API 4; older plugins run unchanged): a double-click on a widget's editable field edits it like any text, and when editing ends the value goes into the widget's state and its plugin redraws it, one undo step (`widget.setText` from scripts and MCP); a widget can carry a few actions, toggles and colour choices, shown in a small bar over it while it's selected (`widget.menu`). The Counter example has a label field and a menu (reset, steps of ten, a colour). Plugins & widgets 65%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged. - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. - **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged. - **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged. - **2026-10-15** · Removing someone from a live session (M9, #338): the host or the file's owner removes a person from the avatar's menu or with `collab.removePerson`; they keep their copy and can't rejoin while the session runs. Numbers unchanged. - **2026-10-15** · Languages (M10, #257, #263, #469, #477, #288): Russian (one/few/many plurals) and Turkish catalogs reviewed by native speakers, Czech and Indonesian drafts, the Japanese draft completed; Preferences ▸ Language lists 12 languages and Latin American Spanish. Numbers unchanged. - **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged. - **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged. - **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged. - **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged. - **2026-10-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed filenames open normally; the global `--no-system-fonts` flag also respects the terminator. Binary regression coverage opens five such native files and checks their JSON info. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. - **2026-10-16** · Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged. - **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. - **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. - **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged. - **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged. - **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged. - **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. - **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. - **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. - **2026-10-13** · Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. - **2026-10-13** · Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. - **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. - **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. - **2026-10-13** · Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. - **2026-10-13** · `.fig` export: instance sizes, variables, styles (gap 1): instances write what they change from their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as derived data and a scale factor when scaled as a whole; variable collections, modes, variables and bindings, the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the import reads all of it back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged. text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged. - **2026-10-13** · Starter templates and first-run help: eight templates built in code (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter), a Templates tab on the home screen, `template.list` and `file.newFromTemplate` for scripts and agents, and a Getting started card that can be closed and comes back from the Help menu - **2026-10-12** · Your online files: a file shared or published online stays yours after its tab closes or the app quits. The app remembers it (with its owner token, kept with the preferences and never shown) and Home ▸ **Online** lists what you own: open a file again as its owner, start or stop sharing, copy its code, rename it, remove it from the server or forget it here. Servers are asked once, when the list opens; tabs mark files in a live session; a crash reopens such a file from its server and puts back the changes that hadn't reached it. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an upgrade only to tell which files are still there. The six draft catalogs translate every string again - **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and object bounds, Hide when publishing in the Assets panel, a cursor for every tool and modifier, panel widths that are kept, and dark pages (white text on them) for files made in the dark theme - **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or **Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that carries the host's address); others type it under Join with a code (the popover, the home screen), where a link works too. The server names links with codes, keeps them hashed and limits wrong ones; `collab.share`, `collab.join {code}`; servers must be upgraded to hand codes out (older ones keep working with links) - **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before in the design-system example). One font family; real files still unchecked (gap 3). No number moves - **2026-10-12** · Live sessions are per document: every open file has its own session or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, the Share popover, people, cursors, chat, spotlight and follow are the file in front's, `collab.peers` lists them all (`sessions`), and closing a file ends its session only (before, one session followed every tab and starting another replaced it) - **2026-10-12** · A public `figcraft-server`: `open_publish = true` lets anyone publish without the server key (Share ▸ Publish to server asks the server first and shows who hosts it, its terms and how long unopened files are kept), with an owner token shown once that manages the file later (`collab.manageFile`); limits per address, idle expiry, reports (Share ▸ Report this file…), `block-ip` and `files list --sort`, `GET /healthz`, and `packaging/server/install.sh` that sets up a Debian/Ubuntu machine with Caddy, ufw and backups ([server.md](docs/server.md#running-a-public-server)). Numbers unchanged (hosting, not a Figma feature). - **2026-10-12** · `.fig` export opens elsewhere (gap 1): written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. The export now says that the document and its pages are visible and opaque, numbers its schema as the format does, and writes outlines, text settings, standard font style names and line height units; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers unchanged. - **2026-10-12** · Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document: layers, geometry, paints with their images, strokes, effects, styled text, vector networks, auto layout, components and instances with their overrides, and prototype interactions; what's left out (style links, variables, comments, Dev Mode data…) comes back as warnings. Checked only by reading the files back with our own importer. File formats 60%. - **2026-10-12** · Keyboard shortcut parity (gap 4): a table test with a row for each of the 129 shortcuts in the public documentation, macOS and Windows/Linux keys each, and the 48 bindings it is silent on pinned; 126 bound (110 before), Run last plugin moved off a documented conflict on Windows, 2 missing. New: others' cursors toggle, list levels on Cmd/Ctrl+] and [ while editing, keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, Control suspending snapping on macOS, Space/N/C/F while presenting, the Frame tool on A, pixel grid on Shift+', link on Shift+Cmd+U, ungroup on Cmd/Ctrl+Backspace, suggest auto layout and go to main component keys; shifted symbols on other keyboard layouts. - **2026-10-12** · Plugin directory on `figcraft-server` (gap 6): publish a plugin folder with the server key (`figcraft-cli plugin publish`), browse a server's plugins in Plugins ▸ Manage plugins ▸ Browse, install after a confirmation that shows the permissions it asks for, update when a newer version appears, remove; bundles are checked in full on the server and again in the app, downloads must match their SHA-256, installed plugins live per user in the same sandbox; `plugin.directory.*` for scripts and MCP; the operator moderates with `figcraft-server plugins`. Plugins & widgets 60%. - **2026-10-12** · Plugin widgets and the panel tail (gap 6, plugin API 3; older plugins run unchanged): a widget plugin puts a layer of its own on the canvas (Plugins ▸ Widgets, `widget.insert`), described by the plugin, validated and built with locked layers inside; clicking a part it named calls it back with the widget's state (kept in the layer, so it saves, copies, undoes, exports and syncs) as one undo step, and it redraws; `widget.click` and `widget.list` for agents; a Counter example. Panels of several plugins open side by side and plugin colour fields open the colour picker. Plugins & widgets 55%. - **2026-10-12** · Layers and Assets panels behaviour suite (gap 4): several layers dropped together land together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers at once, a Rename layers dialog for several layers (number codes as documented, regular expressions, a start value), pages dragged to reorder, dividers, "Copy of" duplicates, Assets folders by page, frame and slash names, and hidden-from-publishing names that decide both ways. - **2026-10-12** · Vector multi-edit and the vector edit tail (gap 9): several vector layers in edit mode at once (select, marquee, drag, nudge, delete, mirroring and radius across them), several Bezier handles dragged together, rounded points beside curved segments, a stroke cap and join per point, and the Pen connecting to its own points or continuing a selected open path. - **2026-10-11** · Canvas navigation, guides and grids behaviour suite (gap 4): zoom, panning, the pixel grid (now from 400%), rulers, guides (top-level frames only; select, Delete, nudge, Remove guide, Alt-drag copies, Alt measures while dragging), layout grids (Auto counts; hidden on rotated frames), outline mode (clicks reach layers behind) and measuring, each rule from the documentation. - **2026-10-11** · Export and inspect behaviour suite (gap 4): exports cover what a layer paints (shadows only where they fall, true stroke widths, no room for hidden layers, a mask group's mask), slices export what's under them, layer names with slashes export into folders; export settings gained contents only, include bounding box and the SVG options (outline text, ids, simplified strokes); Inspect shows colours in hex, RGB, CSS, HSL or HSB, CSS in rem, blend modes and positions from the frame, and lists icons and images as assets. Dev Mode 70%. - **2026-10-11** · `.fig` import's tail (gap 1): vectors open as editable vector networks with their corner radii, handle mirroring and end caps (outlines when the network is missing or damaged); sticky notes, shapes with text, connectors, tables and similar layers import as frames, text and vectors instead of being dropped; set-variable and conditional prototype actions come across. File formats 58%, ready ~49%. - **2026-10-11** · Paint and effects behaviour suite: every documented rule for fills, blend modes, gradients, images, colour models, shadows, blurs and effect exports is a table-driven case. Fixes: Normal groups isolate and clipping frames don't, plus darker is drawn, drop shadows hide behind translucent layers in every export, Fill images reach the box's edges, "Normal" no longer means pass-through. Paint 70%, effects 75%. - **2026-10-11** · `.fig` import, best effort (gap 1): `file.open`, `file.import`, drag and drop, the CLI and MCP open `.fig` files. A generic kiwi decoder reads each file's own schema, and the decoded nodes map by field name onto the REST importer: layers, paints and styles, text runs, vectors as outlines, auto layout, components and instances (expanded with their overrides and property values), variables, prototyping and images from the archive; the rest is a warning. File formats 55%, ready ~49%. - **2026-10-11** · Gamut warnings (gap 18): the colour picker of a Display P3 file flags colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas. Showing P3 itself waits on eframe/egui-wgpu exposing the surface colour space wgpu 30 already supports. - **2026-10-11** · Several windows (gap 18): File ▸ New window, Open in new window on a tab or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes (the main window too); the control channel and MCP open, list, focus and capture windows. Hardware 60%. - **2026-10-11** · Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls, certificates always verified; `FIGCRAFT_CA_FILE` for a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` walks through deploying it on a rented Linux server (systemd unit, Caddy or nginx, firewall, Docker, upgrades, backups). - **2026-10-11** · Plugin panels and settings (gap 6, ABI v2; v1 plugins run unchanged): a plugin shows a panel of fields and buttons drawn with FigCraft's widgets and is called back for each button or changed value (one undo step per callback, same sandbox limits), `plugin.ui` / `plugin.event` / `plugin.close` drive panels from scripts and MCP, and per-plugin settings are kept with the preferences; Swatch grid gained a palette panel. Plugins & widgets 45%. - **2026-10-11** · Canvas fixes: clicks hit boolean groups where their result is (a click in a subtracted hole reaches what's behind), a layer dragged inside a group stays in it, View ▸ Frame outlines and View ▸ Slices draw, and a flaky idle test now waits for the render thread (gap 13). - **2026-10-11** · Team libraries on `figcraft-server` (gap 10): Assets ▸ Libraries ▸ Publish to server keeps the file's library on the server under the file (per-asset versions; the server's version is followed); other files enable it by pasting a share link (a view link is enough), get "Library updates available" from a background check and accept with overrides kept; the server counts which files use which components (Show usage, `library.usage`); `library.swap` re-points instances, styles and variables to another library by names. Team libraries 70%. - **2026-10-11** · Animated GIF fills (first frame on the canvas and in exports, played in the presenter and the preview, waking the window only for the next frame) and TIFF images (gap 12). - **2026-10-11** · The last three menu items got commands: File ▸ Open recent (kept in the preferences, missing files greyed, Clear recent), outlines for the selection and mask outlines (gap 21). Menu breadth 203/203. - **2026-10-11** · Extended variable collections (a library collection's modes and variables in a collection of the file, overridden per mode with reset, following library updates), variables import/export (the REST local variables JSON, merged by name; CSS custom properties), gradient stops and variant properties bound to variables (gap 14). - **2026-10-11** · Live boolean editing: operands stay editable layers (double-click or Cmd/Ctrl-click selects one, moving, resizing or editing its points redraws the result during the drag with every operand outlined, per-operand operations from the boolean controls, prefix-cached recompute); Design panel arc fields; the shape builder on strokes and open paths; one-sided variable widths (gap 13). Feature breadth ~79%. - **2026-10-10** · WASM plugins (#101: sandboxed wasmi runtime, manifest permissions, Plugins menu, Run last, Manage, Rust SDK and two examples), docs refresh (#102), the multiplayer core without networking (#103: client-scoped ids, change sets, server ordering and validation, client reconciliation, convergence tests). Menu breadth 200/203; ready for real work ~47%. - **2026-10-10** · Full re-measure against Figma desktop 126.10.7 under the craftrules progress-docs standard: ready for real work ~46%, feature breadth ~76%, stage alpha; progress docs split out (`docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, the per-area parity docs); `docs/parity.md` renamed `docs/parity-checklist.md`. - **2026-10-10** · Day 2 (PRs #1–#99): vector edit mode depth (transform box, point snapping, arc handles, shape builder, variable width), text depth (variable fonts, bidi, RTL names, style browser), auto layout nesting and keyboard reordering, slots and library variables, components at scale (2,000 instances), effects fidelity (glass, noise, texture, progressive blur), prototyping (conditionals, expressions, device frames, responsive presentation), REST import of prototypes and variables, SVG import fidelity, PDF print options, Dev Mode (code, annotations, ready for dev, compare changes), comments polish, version history, keyboard shortcut parity, accessibility, retained canvas and 50k-layer performance, no redraws while idle; v0.2.0 released for every platform; multiplayer protocol designed. - **2026-10-09** · Day 1: foundation (13 crates, layering, command registry, renderer, engine, UI3 frontend, control channel), CLI and MCP, web build, PDF export, packaging, first depth passes on text, components, prototyping and comments. ## Revision history | Date | Change | Summary | |---|---|---| | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | | 2026-10-31 | minor | M5: a key/gamepad trigger's Controller menu shows, beside each button, what PlayStation and Nintendo Switch controllers call it ("Gamepad A" is ✕ and B). Triggers are stored by the button's place, as before. | | 2026-10-31 | minor | M11, gap 6: widgets draw lines and SVG pictures, as a Figma widget's `Line` and `SVG` do (plugin API 6; older plugins unchanged). A line is 100 long, black and 1 thick unless told otherwise, and turns around its top-left corner. An SVG comes in as locked layers, scaled to fit a size, within the widget's 256 layers. The Counter example uses both. Numbers unchanged. Tests: `widget_shapes_tests`, `widget_tests`. | | 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. | | 2026-10-31 | minor | M8, #1351 and #1124: both reports checked by running them and already fixed on main, so only tests are added: a rounded rectangle or frame strokes only the side asked for, in every alignment (`a_rounded_shape_strokes_only_the_side_asked_for`); a font family and a layer name written as an id drop characters XML forbids (`ids_and_font_families_drop_xml_forbidden_control_characters`). No behaviour changes. Numbers unchanged | | 2026-10-31 | patch | M8, #1474: an image whose header reads but whose pixels don't is refused with a reason when placed, used as a fill, pasted or opened, instead of going in as an empty box with nothing said. A file that won't open at launch now says so in the window. | | 2026-10-31 | minor | M1, #492: an image copied in another app pastes again. The desktop shell swallows a plain Cmd/Ctrl+V, press and release, and now sends an image paste event; FigCraft still waited for the release, so nothing happened. The browser paste event carries only text, so the page now reads the image from it, under the same limits. Dragging straight out of a browser into the desktop app stays open: the browser hands over a promised file, not a real one. Numbers unchanged. Tests: `paste_image_tests`. | | 2026-10-31 | minor | M6: as the help center describes code syntax, Dev Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged | | 2026-10-31 | minor | M1, #1682: as the help center describes the colour picker, choosing Image no longer opens a file dialog at once: the tab shows a checkered placeholder and Upload from computer until there is a file. An image paint shows its asset preview with the same button. A file dropped on the open picker replaces the image and keeps the fill mode, crop, position and adjustments; on the placeholder it becomes the paint's image, filling the layer. While a file is dragged the picker says it takes it instead of the window saying Drop to import. New command `image.replace {ids?, target?, index?, data | path}`, through the same size cap and decoder as `image.place`; one undo step. Dropped files in the browser arrive as `data`. A native window or a browser doesn't say where a dragged file is, so a drop with no known position goes to the open picker, and one known to be over the canvas imports as before. Left: the Fill and Stroke swatches in the Design panel don't take a drop, since their place can't be told during a drag; pasting into the picker isn't documented and isn't added. Tests: `colorpicker_image_tests`, `image_crop_tests::replacing_an_image_keeps_its_fill_mode_crop_and_adjustments`. Numbers unchanged | | 2026-10-31 | minor | M1, #1691: the Frame tool's Phone presets stopped at the iPhone 16 family, while the prototype device list already had the iPhone 17, 17 Pro, 17 Pro Max and Air. All four are presets now, at the same sizes (402×874, 402×874, 440×956, 420×912), and `frame.create {preset}` takes them. A test keeps every preset named like a prototype device at that device's size. Test: `frame_tests::presets_keep_the_sizes_of_the_prototype_devices`. Numbers unchanged | | 2026-10-31 | patch | M8, #1701: Show in folder on Linux asks the desktop portal to show the file (`OpenDirectory`), selected, and opens its folder with `xdg-open` only without a portal. In the Flatpak build, a file picked through the portal lives in a document folder that the portal reads as the document itself, so the old call offered to open the file ("No Apps Available"). | | 2026-10-31 | patch | M4: an open dashed line starts and ends with a half-length dash, with a whole number of patterns fitted to it (dashes and gaps scaled together), for Dash and Custom patterns, on the canvas, in PNG, PDF and SVG (`stroke-dashoffset`) and in Outline stroke. Closed paths are unchanged. An imported SVG's open dashed line follows the same rule. | | 2026-10-31 | patch | M8, #1703: the search fields in the Pages, Layers and Assets panels keep their text in the middle of the field. It hung 5 points low in Pages and Layers and 2 in Assets, in every file: the editor was laid out in a box one line tall, shorter than the theme's least height for an editor. All three now share `widgets::search_box`, on the editor the other text fields use. | | 2026-10-31 | minor | M12, #1710: a selected elbow or curved connector shows path handles. Dragging a run moves it and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins, is saved with the file, and goes back to routing itself when the style changes or an end moves to something else. | | 2026-10-31 | minor | M12: boards have 23 shapes with text instead of 7: triangle down, parallelogram right and left, trapezoid, pentagon, hexagon, octagon, star, plus, arrow left and right, and the flowchart symbols database, document, predefined process, manual input and internal storage. The toolbar flyout wraps into rows of eight and the selection bar's shape menu is a grid of outlines named on hover. The stale "Quick-create handles: missing" row now says what exists. | | 2026-10-31 | minor | M3, gap 10: library analytics over time. A file counts the instances of library components it inserts (placed, created, pasted or duplicated, Alt-dragged copies too; not those nested in another instance) and reports them like its detaches (`ReportInserted`). The server counts what a file's insertion and detach totals grow past their highest in the current week, a Monday at 00:00 UTC, so an undo and a redo count once, and keeps a year of weeks in `actions.json` (`Actions`). `library.usage {days?}` adds `insertions`, `detachments` and `weeks`, and per component `insertions` and `detachments`; Show usage has a 30 days, 90 days or 1 year choice, the period's totals, and each component's counts on a line of their own. The new variants are appended, so older messages keep their bytes; an older server refuses them and Show usage says it doesn't count insertions. A file's first report after this counts its total in that week. Tests: `library_actions_tests`, `inserting_library_instances_counts_them`. Numbers unchanged | | 2026-10-31 | minor | M4, #1681: Stroke style has Custom beside Solid and Dash, with a Dashes field that takes the whole pattern as `dash, gap, dash, gap…`. A pattern longer than a pair shows there instead of in Dash and Gap, so an imported one can be read and edited whole. No phase or centre on corners, which the reference has no setting for. | | 2026-10-31 | minor | M8, #1696: the control channel waited a fixed 60 s for every request, so a client that knowingly sent a big `batch` could only split it, while the same script ran headless without a limit. A request can now carry `timeoutMs` beside `id` and `method` (whole milliseconds, at most 600,000); without it the wait stays 60 s, and an app from before this ignores the field. `figcraft-cli mcp --attach PORT --timeout SECONDS` (1–600) sends it with every request and reads the app's answers that much longer. A timeout's message says how long the request waited. Tests: `control_server_tests`, `backend_tests::a_raised_timeout_is_asked_for_on_each_request`, `args`. Numbers unchanged | | 2026-10-30 | minor | M11, gap 6: a widget can open its plugin's panel, as a Figma widget's click handler shows a plugin window (plugin API 5). A widget callback's `ui_show` (click, field edit or menu item) opens the plugin's one panel and keeps the module loaded; each panel callback gets the widget's id and current state as well as the panel's event and values, and its `widget_render` redraws the widget in the same undo step. A widget removed meanwhile isn't redrawn (the callback rolls back, the panel stays); an API 3 or 4 plugin's `ui_show` from a widget still opens nothing; a presented prototype opens no panel. The Counter example (API 5) gets Set count… in its menu: a panel whose Set button redraws it with the count entered. Tests: `widget_panel_tests` (engine), the Counter's unit test. Numbers unchanged | | 2026-10-30 | minor | Gap 4: the keyboard's Menu key opens the context menu, as Shift+F10 does (at the focused layer row or below the canvas selection, with the keyboard in the menu). egui already reported the key; FigCraft's keymap had no name or binding for it. `view.contextMenu` now takes `Menu` too, and the Context menus row is done (`layers_keyboard_tests`). Stale rows corrected: smart-selection spacing has had reorder rings since the "M1: smart-selection drag reordering" landing (the row is still partial: irregular grids and several marked layers aren't covered), and gap 4 and the parity summary quoted shortcut counts from before the last bindings (now 128 bound, none missing, 57 undocumented pinned, as `shortcut_parity_tests` counts). | | 2026-10-30 | minor | Prototyping: Open link has "Open in new tab", on by default; off, the web build's own tab goes to the link (the desktop app opens the browser either way). Kept in files only when off (`openInNewTab`), read from REST files, and in `proto.interaction.add`. | | 2026-10-30 | minor | M3, gap 14: a collection of this file can be extended, as the help center shows (right-click a collection, Extend collection); before, only a library's could. `variable.extendCollection {collection, name?}` makes " (extended)" with its modes and one inheriting variable per variable, and the Variables editor's collection menu offers it beside Rename and Delete collection. Edits of the collection it extends reach it at once (values in modes it doesn't override, new variables and modes, renames, mode order), so normalising keeps extensions in step whenever variables change, not only on library updates; undo takes both back. An extended collection extends again (parents settle first; an extension doesn't take its parent's overrides as its own), and a loop of extensions from a hostile file comes apart into plain collections keeping their values. Deleting the parent leaves a plain collection with the values it showed, as for libraries. Tests: `extended_chain_tests` (engine), `variables_rename_tests::a_collection_of_this_file_extends_from_its_context_menu` (headless UI). Numbers unchanged | | 2026-10-30 | minor | M4, gap 4: the eyedropper with Cmd/Ctrl+Shift held makes a colour style or variable of the colour it picks, as the reference app's eyedropper does, instead of filling the selection. It asks the UI for a Create from color window (`dialog.createFromColor`; the control channel never opens it). The window shows the colour and offers Style or Variable, a name (the next free "Color n") and, for a variable, a collection: the file's first, or a new one made with it in the same undo step. Create runs `style.create` or `variable.create`. Tests: `eyedropper_create_tests`, `sampled_color_tests`. | | 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. | | 2026-10-31 | minor | M8, #1673: an attached client whose request ran past the control channel's 60 s timeout was told "timed out waiting for the UI: screenshots and input need the FigCraft window on screen" while the request went on and was applied. Now the UI marks a request started (a new `Running` state of its request gate) just before it runs it and never runs one the transport already gave up on. At the timeout the reply says which happened: not started, so dropped and not applied; or still running, so it will take effect and the document should be checked before retrying. A reply that arrives just as the timeout fires is still delivered. Only `ui.screenshot` and `ui.input` keep the window message. Tests: `control_server_tests` (a started request times out as still running, a cancelled one never starts) and `idle_tests::a_request_the_transport_gave_up_on_is_never_applied` (from a frame and from a hidden window). Numbers unchanged | | 2026-10-30 | minor | M12, gap 24: a board keeps each ended vote's results in the file (the last 20), as the reference app keeps each voting session's results until they're deleted. A record holds the vote, its ballots, the names the voters went by and when it ended, so someone who has left a live session still shows by name. The Results popover lists them newest first, with arrows to earlier votes and Delete results (`board.vote {action: delete, index}`). Clear results still only takes the results on the board off it. `board.voteResults` lists `sessions`. The results are saved (`vote_history`; older files open without it, and a vote ended in one still shows), synced in live sessions (`DocProp::VoteHistory`), and like the vote they're not undo steps: undo and redo carry them over. Tests: `vote_history_tests`, `facilitate_tests` and `vote_history_ui_tests`. | | 2026-10-30 | minor | M12, gap 24: an ended vote's results list who voted for each object and how many times ("Ada ×2, Bo"), as the reference app shows how each participant voted once a vote ends; while it runs, people still see only their own votes. `board.voteResults` gives them per result as `voters: [{voter, name, votes}]`. In a live session a voter is named as the session knows them, and someone who has left shows as such. The Voting row no longer asks for facilitator-only ending: the reference app's help says anyone in the file can end a vote, as here. Tests: `facilitate_tests` (offline and live, including a person who left) and `vote_voters_tests` (the Results popover). | | 2026-10-30 | minor | M12, gap 24: while the stamp tool is over an object, or a stamp is dragged over one, that object is outlined (the canvas's hover outline): the one the stamp will stick to, found by the same rule as `board.stamp` and `settle_stamps`. Found while checking it: a drag dropped what it moved into the auto layout of a sticky or shape with text under the pointer, so a stamp dragged onto a sticky became part of its layout; board objects (stickies, shapes with text, tables, stamps, labels) now take no drops, which go to what is under them, and a stamp already inside its sticky from such a drop moves with it once. Checked in the app. Numbers unchanged. Tests: `board_connect_tests::the_object_a_stamp_will_stick_to_is_highlighted`, `board_tests::stamps_stick_to_what_they_land_on`. | | 2026-10-30 | minor | M12, gap 24: a stamp sticks to the object it lands on (the topmost layer under its centre other than stamps, connectors, their labels and ink) and moves with it, as the incumbent's stamps do: `BoardItem::Stamp` keeps it in `on` (left out when empty, so older files open with their stamps on nothing), and the board's normalize pass carries a stamp along when its object moves or turns, once when both move together. A stamp dragged onto another object sticks to that one and off everything to nothing; one whose object is deleted stays where it is, on nothing. Undo and live sessions go with the edit that moved the object. Checked in the app (two stickies moved with their stamps). No highlight while a stamp hovers yet. Numbers unchanged. Test: `board_tests::stamps_stick_to_what_they_land_on`. | | 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`. | | 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. | | 2026-10-30 | minor | M8, #1571 (part 5, fixes it): with 648 layers selected, the right panel cost 0.55 ms on every frame against 0.19 ms with one layer, and the canvas overlays 0.27 ms against 0.16. Each Design panel section went over the whole selection several times a frame for mixed values and which kinds are selected (about 20 passes, each looking up 648 layers in the document). The overlays did the same for boolean operand outlines and constraint guides. Now the panel looks the selection up once per document snapshot and selection; while a drag only moves the layers, that is the document the drag started from. `common` and a new `every` read those layers. The overlays keep which layers have constraint guides and which boolean operands to outline, worked out once per document and selection; during such a drag, from the start document, since a move changes neither. Measured on the same synthetic file of about 1,900 layers: the drag-all UI mean went from 1.48 to 1.04–1.09 ms per frame, and hovering with all selected from 0.95–1.04 to 0.64–0.69 ms. The right panel went to 0.25 ms and the overlays to 0.18 ms. Screenshots of the panel and the overlays are byte-identical before and after. Tests cover the kept layers after edits, another selection, a deleted layer and another document, and the overlays after structure edits. | | 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,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). | | 2026-10-30 | minor | Docs (found while landing #1649): merges that kept both sides of a conflict had left rows and status items twice. One row each now for `.fig` in `docs/file-format-parity.md` (four copies: #1234, #1236 and the #1076 ZIP budget kept, with the newer four-values-per-byte budget wording and #1649's connector routing), Real-time co-editing (three: #1069, #1065, #1063) and Live cursors (two: #1380, #1416) in `docs/collaboration-parity.md`, Windows x64 / arm64 in `docs/hardware-parity.md` (#1320, #965) and M0.9 in `docs/roadmap.md` (#1396, #996). The status lines of this file, `docs/roadmap.md`, `docs/gaps.md`, `docs/target-app-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md` and `docs/file-format-parity.md` drop items listed more than once (302 in all, the first kept), and an unclosed parenthesis in target-app-parity's status line is closed. No wording changed otherwise. New `cargo xtask docs`, run by `ci`: a table row that repeats an earlier one (header rows apart) or a status item listed twice fails it. Numbers unchanged. Test: `docs_check::tests`. | | 2026-10-30 | minor | M3, #1613 (after #1618): the rest of the stored-id work. `Variables::get` and `Variables::collection` take the variable or collection with that exact id before one named like it (as `find_mode` does), so typed ids in `variable.bind`, `variable.update`, aliases and the prototype action and expression input can't be taken by a renamed one. Stored mode ids go through the new `Collection::mode_by_id` (expression evaluation, display and type checks, the prototype's mode switch), font family variables read their stored ids exactly, and the colour picker, prototype editor and panels' labels (variables, prototype, selection colours, typography, style picker, assets, variable fields) read exact ids. Tests: `stored_ids_tests.rs` (exported aliases, deletion, Dev Inspect, expressions, mode switch, library import, interaction edits) and the doc crate's lookup test; #1618's expression test now expects the exact id to win. | | 2026-10-30 | minor | M6, gap 6 (first slice): plugin widgets have hover styles, as the reference tool's widgets do. Any layer of a widget description, the widget itself too, may have `hover: {fill?, stroke?, opacity?, color? (text)}`, checked like the rest; on the canvas, while the pointer is over such a layer, it shows that style as a live preview (the text-preview mechanism: no undo step, never saved or sent to a live session), and moving away, leaving the canvas or any command (a click on the part acts on the document itself) puts it back; another live preview showing isn't replaced. `widget.hover {ids}` does the same for scripts and agents. No new plugin API: older FigCraft draws the layer as it is. The Counter example's buttons darken under the pointer. Timers and panels from widgets are still missing. Numbers unchanged. Tests: `widget_hover_tests`, `widget_tests::hover_styles_are_checked_and_normalised`. | | 2026-10-30 | minor | M6, gap 1: `.fig` connectors saved without their path that are elbowed or curved were drawn as straight lines. They now run as FigCraft's board connectors do: in right angles (around the layers they join when the short way would cross them) or along one curve, leaving each layer from the side the file's `magnet` names, else the edge its point lies on, else the side facing the other end. The router (`route_points`) moved from the engine's boards into `figcraft-doc`, unchanged, so the importer and the board connectors share it. Straight connectors are unchanged. Checked by rendering the synthetic fixture with figcraft-cli; not on real files. Numbers unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style`, the elbowed connector in `other_layer_kinds_import_as_the_closest_layers`, the engine's board connector tests. | | 2026-10-30 | minor | M6, gap 6: plugin panels (and plugin command forms) take a `slider` control: a number between a required `min` and `max`, drawn as a slider with the value field beside it, named for screen readers as a slider with its range. Dragging shows the value live and calls the plugin back once, when let go (one undo step); typing in the field or double-clicking the slider (back to the panel's value) do the same. The slider is the one the Type settings' variable axes used, moved into the shared widgets, which also gives those axis sliders a screen-reader name. Numbers unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go`. | | 2026-10-30 | minor | M6, gap 16: `figcraft-cli connect publish [--mappings FILE] [--dry-run]` publishes a code repository's code connect mappings (`figcraft.connect.json`, as Export code connections writes it) to a design file: each link connects the components or sets of that name to their code for its language; links that don't fit are warnings and are skipped; the file is saved in place through the engine's `file.save` (whole or not at all, version history kept); a dry run saves nothing; nothing linked is exit code 1. Numbers unchanged. Tests: `files::tests::connect_publish_links_components_and_saves_the_file`, `args` tests. | | 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M12, #1597 (the third point): Shift+Cmd/Ctrl+Enter (`board.nextSticky`) now works on a selected or edited shape too: it adds the next shape of the same kind, size and colour on its right (or the `side` given), joined to it by a connector and with typing going on in it, as the "+" beside it does (`board.addConnected`, #1631). On a sticky it adds a sticky beside it without a connector, as before. Numbers unchanged. Tests: `board_connected_tests::the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were`, `board_ui_tests::the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing`. | | 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). | | 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). | | 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged | | 2026-10-30 | minor | M8, #1076: bound total `.fig` ZIP image reads to 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, keeping the 256 MiB member cap. Members that do not fit are skipped before allocation and counted in the import warnings; failed reads still consume their bytes, and repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | | 2026-10-30 | minor | M8, #1114 (the rest): `file.open` and `file.import` (and recovery copies), `image.place {path}`, the image fill's file picker and `figcraft-cli run`'s script read the path they were given whole, so a device (`/dev/zero`), a pipe or an absurd file could fill memory or wait forever. Every whole-file read of a path now goes through `file_access::read_capped`, which looks at the path before opening it and refuses a folder, a device or pipe, or a file over its cap, with a message saying which, and reads at most the cap even if the file grows; the uncapped `file_access::read` is gone. Caps: documents 16 GiB (a `.figcraft` keeps its images as they are: #1313's file is 7.4 GB on disk), placed images 256 MiB (what a document takes from outside, `ImageData::MAX_BYTES`), scripts 1 GiB; fonts (256 MiB), plugin files and variables files already had theirs. Numbers unchanged. Tests: `path_read_caps_tests`, `file_access` tests, the CLI's `a_script_that_isnt_a_regular_file_is_refused`. | | 2026-10-30 | minor | M8 (found while retaking the README screenshots): at the default (and narrowest) 240 px right panel, a component set's variant property rows ran off the panel: values past the second ("Outline", "Disabled") were cut off, and the + after them and the − of each property sat past the edge; a shared boolean property's − was pushed out too. Values now wrap as many to a line as fit (two at 240 px, three from about 320 px as before), the property name field gives way first so the default and − stay in view, and a boolean's checkbox no longer takes the whole row. Numbers unchanged. Tests: `component_set_narrow_tests`. | | 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged | | 2026-10-30 | minor | M8, #1097: while the Assets panel showed a file, the app asked the server of every team library the file named for updates, with the link's token and a usage report, so a file someone sent could make the app contact any server. Now only team libraries you added (`library.add`) or checked on this computer are checked by themselves (kept with the preferences as hashes of their links); another one waits in **Assets ▸ Libraries** as "Not checked yet" until you click **Check for updates** (`library.check`, which plugins can't call). Placing or applying something from it is your own action and asks its server as before. Numbers unchanged. Tests: `library_beacon_tests` (engine and UI). | | 2026-10-30 | minor | M8, #1121: client-side maps were unbounded. A hostile host or server could mint people without end: the presence waiting for the UI, the people a connection had seen and the session's list of who is here each grew with every new id; and a session server's set of deleted layer ids (kept so undo may create them again) only grew. Now a client keeps track of at most `MAX_PEERS` (4,096) others: past it, someone new is left out with a logged warning (in a welcome too), someone already here can still be renamed, and someone leaving makes room; a server's `max_people` above 4,096 is lowered to it with a startup warning (the server still starts). A session remembers at most 262,144 deleted ids, forgetting the lowest first (on restore too). Threat model in `collab-protocol.md`. Tests: `client_cap_tests.rs` (collab-net), `live_peer_cap_tests.rs` (engine), `deleted_cap_tests.rs` (collab), the server's `max_people` clamp. | | 2026-10-30 | minor | M8, #1117: `doc.list` returned the path on disk of every open file to any caller, plugins and attached AI agents included. It now gives an agent's calls (MCP, or a control call naming an `agent`) and plugins each file's `id`, name and state without `path`; the user's own scripts (the CLI, control calls without an agent) still get paths, and the app's UI reads its tabs directly. Numbers unchanged. Tests: `doc_list_paths_tests`. | | 2026-10-30 | minor | M8, #1571 (part): a large selection redid the same work on every frame. A pointer move draws two to five egui frames, and each one worked out every selected layer's outline, the selection box and size, the slots shown, and the Design panel's instance checks and position values (X, Y, rotation, constraints, alignment) again, though only the first frame after a move or an edit can differ. Now they are kept with the document snapshot, the selection and, for the overlay, the view's mapping to the screen and the overlay green, and made again when any of those changes (`selection_cache`); the hover and drop-target highlights are drawn every frame as before. Nothing looks different. Dragging 648 layers of a synthetic file of about 1,900 (release build, control-driven, two frames per move): the canvas overlays 1.99 to 0.86 ms a frame, the Design panel 2.54 to 1.40 ms. Not yet: the frame of each move still works everything out (the selection spacing, the export section, the outlines), the canvas texture is uploaded whole after each render. Tests: `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M8, #1089: libraries and plugins larger than one address's download burst (16 MiB) could never be downloaded: the per-address token bucket never held more than its burst, so a private server's 40 or 64 MiB team library was refused for good. Now a download larger than the burst is served once the address's budget is full, leaving it in debt that refills at 1 MiB a second before its next download (`Bucket::take_spread`, also for everyone's library budget); within the burst nothing changes. Over any stretch of time an address still gets at most the largest object plus the rate times that time. `server.md` (rates, threat model). Tests: `download_burst_tests.rs` (the bucket's loan and payback, ten minutes of 40 MiB requests kept to the rate, a 40 MiB library served and then the address waits). | | 2026-10-30 | minor | M8, #1099: `command.batch` (and every grouped run: plugins, scripts, MCP `batch`) kept a full undo snapshot per step until the batch ended, so a long batch held one document copy per step. Inside a group, edits now add to the one undo step the group pushed, so a batch holds one snapshot however long it is; it is still one undo step that restores exactly the state before it, and a failure still rolls everything back. An `edit.undo` step inside a batch now undoes the batch's edits so far together. Measured on a 2,000-layer file with 160 KB of document colours (debug build): a 1,000-step batch went from +165 MiB peak and 80 to 100 ms to +10 MiB and about 50 ms (4,000 steps: +35 MiB, the step list and command journal). Numbers unchanged. Tests: `batch_undo_snapshot_tests`. | | 2026-10-30 | minor | M8, #1101: the importers' 256-level depth cap could overflow the browser's 1 MB stack before it was reached. Measured per level: the REST import's layer walk (also the second stage of every `.fig` import) took about 4 KB, a whole megabyte at 256, and overflowed a 1 MB thread; it now runs on a work list in the same order (groups fitted once their children are in), a few KB in all. The `.fig` mapping, which builds each layer from its children's results, stays recursive at about 1.8 KB a level, 455 KB at the cap. Desktop and browser keep the 256-level cap, so nothing that loads today is cut off. Numbers unchanged. Tests: `deep_import_tests` (256 and 400 levels on a 1 MB thread). | | 2026-10-30 | minor | M8, #1082 (the rest): the SVG and PDF exporters drew a pattern fill's source from depth 0 again, and the renders they embed (a layer with effects drawn alone, a pattern cell with effects) started at 0 too, so an export could nest its own levels, the renderer's and a pattern's on top of each other. A pattern source now exports one level below the layer it fills, and what the renderer draws for a layer goes on from that layer's depth (`figcraft_render::at_layer_depth`), so one cap bounds the whole export; the render of what lies beneath a layer (background blur, glass) is of the page's own layers and still counts from the page. Canvas masks, mask content and boolean and outline walks already carried the depth or have their own small caps. Numbers unchanged. Tests: `render_depth_tests`, `figcraft-svg` and `figcraft-pdf` `pattern_depth_tests`. | | 2026-10-30 | minor | M8, #1082: a pattern fill drew its source as a fresh render from depth 0, so patterns in patterns could stack three renders of 256 levels; its source now goes on from the depth of the layer it fills, and the one cap bounds the whole render. Measured, a level takes about 6.5 KB of stack in a render (6.8 KB in a debug build), so the deepest allowed render needs about 1.7 MB: the canvas render threads, the save thread (it renders the thumbnail) and a band pool of their own now get 8 MiB stacks, like the desktop app's main thread (rayon's and other threads' default is 2 MiB). Numbers unchanged. Tests: `render_depth_tests` (the cap reached through a pattern; three 250-level chains of patterns in patterns rendered on a render thread's stack). | | 2026-10-30 | minor | M8, #1114 (explicit variables files only): sibling variables files were read with a 256 MiB cap and a regular-file check, but an explicit path given to `variable.import`, or as the `variables` of `file.open` and `file.import`, was read whole with no limit. That path now goes through the same capped read: an oversized file, a folder or a device fails with "not a file of at most 268435456 bytes" and the document, tabs and recent files stay as they were. The check reads the file's size before reading it, so a file that grows or is replaced in between is not bounded; primary document reads and `image.place` are left for the rest of #1114. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads`. Numbers unchanged | | 2026-10-30 | minor | M8, #1074: on `figcraft-server` every join of an idle file read its snapshot and log back and every join and leave rewrote its `meta.json`, all on the hub thread, so join/leave loops amplified into disk I/O. A file everyone left (its log synced) now stays loaded for a minute (at most 16 such files, 256 MiB together), and a join in that time takes it as it was with nothing read; its open time, which expiry counts in days, is written at most hourly and when the server stops. Edits are saved as before (logged, compacted when everyone leaves). Measured: 20 join/leave cycles of an unchanged file did 0 reads and 0 writes (40 reads before, plus a `meta.json` write per join and leave). Numbers unchanged. Tests: `idle_io_tests`. | | 2026-10-30 | minor | M8, #1079: one budget for everything a `.figcraft` file unpacks when it opens. Each area had its own cap (document 4 GiB, images 4 GiB, library 1.5 GiB, versions 1 GiB, branches 1 GiB), so one file could make the app unpack about 10 GB. Now they also share one total, 6 GiB (2 GiB on 32-bit targets such as the browser), counted member by member in reading order: document, images, published library, version history, branches; the per-area caps stay. The document (a 900 000-layer one unpacks to 1.5 GB) and the images up to their own cap fit within it, so a file whose images pass 4 GiB (#1313) keeps as many as before, and images left out by the total are treated like those past the images' cap: they stay in the file and saving over it is refused rather than dropping them. History and branches past the total are skipped, and a document past it refused, each with the note "the file's contents together pass … MB". Numbers unchanged. Tests: `unpacked_total_tests`. | | 2026-10-30 | minor | M8, #1571 (part): laggy drags and high CPU while the pointer only moves. Measured on a synthetic file of about 1,900 layers (24 auto layout frames of 48 rows, 600 shapes, 24 images; release build, 300 to 600 pointer moves at 60 per second): every pointer move draws about three egui frames (the move, egui's follow-up, a finished render), and the canvas hit-tested the hover on each of them; a drag of an auto layout frame laid its contents out again on every move, though a move can't change them. Now a hover where nothing changed since the last (same place, file, page, revision, selection, tool and modes) isn't sent to the tools again, and a frame that only moved keeps its own layout (its parent's still runs). Hover: the UI pass 0.91 to 0.49 ms a frame, CPU 33% to 22% of a core; dragging 648 layers: the UI pass 3.8 to 3.2 ms, the engine's normalise 3.8 to 2.1 ms a move, CPU about the same (117% to 116%). `ui.inspect` reports `frameTimes` ({frames, meanMs, maxMs}). Not yet: the selection overlay and the Design panel recompute every frame for a large selection (about 2 ms each at 648 layers), the canvas texture is uploaded whole after each render, and egui draws its follow-up frames. Tests: `canvas_hover_tests.rs`, `auto_layout_move_tests.rs`. | | 2026-10-30 | minor | M8, #1077: two quadratic costs in the `.fig` import's instance expansion. Variable and collection nodes sat in the layer tree, so every instance of a main walked the ones under it again, and as they never become layers the layer cap never stopped them; they are now left out of the tree (variables are read from the node list as before), so everything an expansion visits counts against the cap. A nested instance copied all of its outer instance's override rows, and its property values and slot contents; it now takes only the rows addressed below it (they sort together, so the rest aren't visited) and shares the outer values, keeping only its own. Normal files map as before. Crafted files: 20 000 variables under a main with 2 000 instances 13.8 s → 21 ms; 50 000 override rows over 2 000 nested instances 40.5 s → 98 ms (native release, the same output). Numbers unchanged. Tests: `expansion_cost_tests`. | | 2026-10-30 | minor | M9, #1415 (the rest): `comment.people` lists every name a message @mentions besides those who wrote or reacted (once each in any case, a known name spelt as the file spells it, never an email address), so a name that has only been tagged shows up. `comment.next` takes the `mentions` filter and, like `comment.open`, returns the thread as `comment.list` lists it, with each message's `mentions`; `comment.prompt`'s bounded `comments` carry each thread's mentions (at most 32, names cut to 80 characters). One mention helper serves them all. Numbers unchanged. Tests: `comment_mentions_tests`. | | 2026-10-30 | minor | M8, #1083: grid auto-placement tested every cell of a child's span at every position it tried (children × rows × columns × span area), so a crafted grid of wide children kept every layout pass busy. The grid's taken cells are now a bitmap: a span is checked a 64-bit word at a time, the search jumps past the cell that blocks it, a row's search ORs the span's rows once, and a column-anchored child looks on from where the last one of the same column and spans found room (cells only ever get taken). Placements are the same, checked against the old search on 400 random grids. A crafted 1000 × 1000 grid with 1000 full-width children: 192 ms → 0.3 ms; 3000 column-anchored wide children: 4.2 ms → 0.7 ms; ordinary grids unchanged (20 000 1 × 1 children 5.2 ms either way). Numbers unchanged. Tests: `grid_place_tests`. | | 2026-10-30 | minor | M1, #1430: from the pen into vector edit mode. A double-click with the pen drew on: its second press added a point on top of the last one (a zero-length segment), a double-click meant to edit a layer drew a stray two-point vector on it, and Enter ran what Escape does. Now a press on the pen's last anchor adds nothing; a double-click while drawing ends the path at that point, and one that only started a path drops it and edits the layer under the pointer; Enter ends the path. Both go on editing the vector with the Move tool (points and handles); the next Enter leaves edit mode. In edit mode the pen's double-click and Enter take the Move tool too, a double-click taking back the point or new path its first press began. Escape still keeps the pen in edit mode for the next path (#760). Numbers unchanged. Tests: `pen_vector_edit_tests`. | | 2026-10-30 | minor | M1: desktop recovery copies made while a version is previewed held the version shown instead of the file, so a crash during a preview brought the old version back as the unsaved file. What a recovery copy holds is now one shared helper, `figcraft_engine::branches::recovery_copy`: the document itself (`DocState::document_itself`, the same document the unsaved-changes check compares, not the version or live text preview shown in its place), its version history and its branches. The desktop copies and the browser's (#1580, which already took the document itself) both use it. Tests: `recovery_tests.rs` in the desktop and web apps (a copy made during a version preview recovers the file and its history). | | 2026-10-30 | minor | M12, #1342: a FigJam `.jam` file opens as a board, our whiteboard file kind, instead of a design file: the `.fig` reader marks a canvas with FigJam's `fig-jam.` header as `FileKind::Board`, so the file opens with the board toolbar, the dot grid and the board panels, and saves as a board. A `.fig` stays a design file, and `file.import` of a `.jam` adds its pages to the open file without changing its kind. Its stickies and shapes with text are still frames with their generated layers and its connectors vectors, not native board objects. Tests: `board_import_tests.rs` (the kind, `.jam` against `.fig`), `jam_open_tests.rs` (open as a board, import into a design file). | | 2026-10-30 | minor | M6, #1342: FigJam `.jam` files open. A `.jam` is the same archive as a `.fig` (its canvas header reads `fig-jam.`), and the `.fig` reader already took it when a copy was renamed; now File ▸ Open, its file filter, drag and drop (home screen and canvas), `file.import` and the command line take `.jam` too, and the home screen's recent files show their thumbnails. What comes in is what the `.fig` reader makes of FigJam layers: stickies and shapes with text as frames with their generated layers, tables, stamps and the like as frames with an import note, connectors as vectors, and a layer type it doesn't know left out with a note naming it. The file opens as a design file, not a board, and its objects carry no board metadata yet. Tests: a synthetic `fig-jam.` canvas, bare and zipped (`board_import_tests.rs`), a `.jam` made from our own `.fig` export opened and imported (`jam_open_tests.rs`), the extension checks (`home_tests.rs`). | | 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. | | 2026-10-30 | minor | M9, #1525: share links can comment. A third access level, `comment`, sits between `edit` and `view`: someone who joins with a comment link (or its join code) sees the file live, as with a view link, and adds comment threads, replies, reactions and resolves them; any other edit is refused, by their app and by the server, which accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones, and the Share popover offers Can edit, Can comment and Can view; the toolbar shows a "Can comment" badge with Move, Hand and Comment. `collab.share {access: "comment"}`, `collab.manageFile {action: createLink, access: "comment"}` and `figcraft-server links create FILE --access comment` make and pick it. Older apps are unaffected: one that joins with a comment link is welcomed as a viewer, links listed to it read as view links, and a level an app doesn't know reads as view; an older server makes no comment link and sharing goes on without one. Comment authorship is the name in Preferences, as before (no accounts), so a commenter can edit or remove other people's threads. Tests: `comment_access_tests.rs` (collab), `comment_link_tests.rs` (engine, server end to end), the Share popover and toolbar in `live_share_tests.rs` and `live_server_tests.rs`. | | 2026-10-30 | minor | M1, #1390: Preferences ▸ Scroll wheel zoom (`pref.scrollWheelZoom`, off by default, kept with the preferences). On, a mouse wheel zooms the canvas about the pointer at the rate Cmd/Ctrl+wheel zooms otherwise, and Cmd/Ctrl+wheel scrolls; Shift+wheel still scrolls sideways, and trackpad scrolls, pinches and touch screens are unchanged. A wheel is told from a trackpad by what the platform reports: macOS names the device; elsewhere a wheel scrolls by lines and a trackpad by pixels, so a mouse whose wheel reports pixels (Chromium-based browsers) keeps scrolling, and a trackpad that reports lines (some X11 setups) zooms. Numbers unchanged. Tests: `wheel_zoom_tests`. | | 2026-10-30 | minor | M9, part of #1415: `comment.list` takes `mentions` (a name, any case, a leading `@` allowed) and lists only the threads where a message, the first or a reply, @mentions it, combined with the other filters (`{"mentions": "Claude", "filter": "unread"}`). It matches the names each message's `mentions` reports, by the same parser the thread view highlights with (`figcraft_doc::mentions`), so `@Ada Lovelace` is Ada Lovelace, not Ada, and an email address mentions nobody; the name counts as one of the file's people, so an unknown name can be several words. Numbers unchanged. Tests: `comment_mentions_tests`. | | 2026-10-30 | minor | M8, #1123: a safety net for internal errors in the browser. wasm32 can't catch a panic (stable Rust has no unwinding std for it), so there the command guard and the frame guard do nothing and a panic stopped the page, losing every unsaved change. The page now follows its files with unsaved changes after every frame (shared copies, taken again only when a file changed); its panic hook writes them to the page's local storage as `.figcraft` copies, as many as fit (browsers give a page about 5 MB), and says on the page that it stopped and what reloading brings back; the next page opens them again as unsaved files (`file.recover` also takes the copy as base64 `data`). Numbers unchanged. Tests: `recovery_tests` (figcraft-web). | | 2026-10-30 | minor | M8, #1104: boolean operations had no input cap, so a crafted file could keep the sweep busy for unbounded time and memory (any number of segments, crossing as often as they like, and a fold re-sweeping its growing result for every operand). Each sweep now takes at most 250 000 segments and 2 million possible crossings (pairs of segments whose bounds overlap, counted exactly in O(n log n)), and a fold at most 4 million segments swept over all its steps; past them the operation is refused with `TooComplex` ("the shapes are too complex to combine") before it runs. The example files' boolean groups sweep at most 55 segments and 155 possible crossings per step. A boolean group past the caps draws without its result, with a warning, once, like layers past the render's memory budget; the refusal is remembered, so redrawing it costs nothing; Flatten refuses it instead of dropping it. Numbers unchanged. Tests: `budget_tests` (pathops), `boolean_budget_tests` (render), `boolean_cap_tests` (engine). | | 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged | | 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged | | 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged | | 2026-10-30 | minor | M9, #917: the browser build publishes to a `figcraft-server`. Share ▸ Online (the server in Preferences) and Share ▸ Publish to server… upload the open file over the page's own WebSocket, make the links and join codes others join with, and keep the tab in sync with the new file, as the desktop app does; asking a server about its hosting, stopping a share and sharing again go out the same way. The Share popover offers only online sharing there (a page can't listen, so the local network stays the desktop app's); without a server in Preferences it says so and offers joining. A failed publish says why and leaves the file as it was. The browser keeps only your name across reloads, so the owner token of a file it put on an open server lasts as long as the page (`crates/collab-net/src/exchange.rs`, run by the browser client's driver; the requests and what their answers mean are shared with the desktop transport in `requests.rs`). Tests: `exchange_tests` (scripted server: with and without the key, sharing or not, refusals, an old server without join codes, time-outs, leaving while publishing, the background requests), `live_join_tests`; checked in a browser against a local server. Percentages and hours unchanged | | 2026-10-30 | minor | M4, #1051: fonts installed on this computer in the browser build. Where the browser lists them (Chromium's Local Font Access, `window.queryLocalFonts`), the Design panel's font menu offers "Use fonts installed on this computer…"; the browser asks for permission, and the fonts join the font list by name, with their styles. A font's bytes are read only when text uses it, one style at a time (its preview in the menu waits until then), and auto-sizing text fits it once it is in; one that can't be read is dropped and its text keeps the fallback. A family the build embeds keeps its faces; the computer's styles it lacks join it. Other browsers show no row (`figcraft_text::add_local_fonts`, `apps/figcraft-web/src/local_fonts.rs`). Tests: `fontdb::local_tests`, `local_fonts_tests`; checked in a browser. Percentages and hours unchanged | | 2026-10-30 | minor | M8, #1550: SVG import reads CSS colour functions its SVG parser doesn't (it painted them black, or left strokes out, without a word): `color(srgb …)` and `color(display-p3 …)`, with alpha, converted to the document's profile, and `color-mix(in srgb|oklab|oklch, …)`, evaluated at import (percentages, alpha and nested mixes as CSS Color 5 has them), in colour attributes, `style` attributes and `