Commit Graph
84 Commits
Author SHA1 Message Date
bflatastic 120aa8a7a0 Merge #336: M1: keep a recovery copy until this run has its own (#187) (@Barzabel) 2026-10-15 19:32:23 +07:00
bflatastic 09ad21489b Merge #331: fix(recovery): keep the version history and branches in recovery copies (#185) (@vxMacDev) 2026-10-15 19:32:22 +07:00
bflatastic 0a2ed2fb65 Merge #303: Recovery: wait for the write in flight before an emergency save and before removing copies (#202) (@MarekSpodBiedry) 2026-10-15 19:32:20 +07:00
bflatastic e15242f506 Merge main, the community batch, #303 and #331 into #336
# Conflicts:
#	apps/figcraft/src/recovery.rs
#	apps/figcraft/src/recovery_tests.rs
2026-10-15 18:27:43 +07:00
bflatastic 0757800b3d Merge main, the community batch and #303 into #331
# Conflicts:
#	apps/figcraft/src/recovery.rs
#	apps/figcraft/src/recovery_tests.rs
2026-10-15 18:24:31 +07:00
bflatastic 379e4b5c55 Merge main and the community batch into #303
# Conflicts:
#	apps/figcraft/src/recovery.rs
#	apps/figcraft/src/recovery_tests.rs
2026-10-15 18:21:15 +07:00
bflatastic 69de1ee36f #253: a dialog's result goes to the file and window it was opened for; dialogs are parented to the main window 2026-10-15 18:12:35 +07:00
bflatastic 8b1613c7ee Merge main and the community batch into #253 2026-10-15 18:00:57 +07:00
bflatastic 4f12bf1e00 Merge #297 fix: recovery retry tests run on Windows 2026-10-15 18:00:10 +07:00
bflatastic 471694dc48 #297: recovery retry tests fail the copy with a folder in its place, so they also run on Windows 2026-10-15 18:00:02 +07:00
bflatastic fc7556ae7f Merge main and the community batch into #253
# Conflicts:
#	contributors/people.toml
2026-10-15 17:58:35 +07:00
bflatastic 55eaeb8709 Merge #290: M1: open .figcraft files from Finder, Open With and the Dock on macOS (@marcoarnulfo) 2026-10-15 17:54:25 +07:00
bflatastic 8ae567c06b Merge #254: fix(engine): read the documented gradient shorthand on every route (#252) (@pcastro93) 2026-10-15 17:53:00 +07:00
bflatastic 097110b359 Merge #297: Count a recovery revision as copied only after its write succeeds (@Silvertree2010)
# Conflicts:
#	contributors/people.toml
2026-10-15 17:52:15 +07:00
bflatastic d337b34380 Merge #340: M9: a compaction cut short closes the file instead of confirming lost edits (#183) (@Barzabel) 2026-10-15 15:10:38 +07:00
bflatastic 731b7e356a Merge #294: fix(desktop): Windows' first preferred language picks the UI language (#291) (@fede9234)
# Conflicts:
#	ROADMAP.md
#	docs/roadmap.md
2026-10-15 15:10:19 +07:00
bflatastic a8604adbca Merge #302: fix(server): keep each team library version in its own file (#206) (@MarekSpodBiedry) 2026-10-15 15:10:14 +07:00
Mateus Villain 3edb15c417 M1: copy and paste through the system clipboard in the browser
The web build's Services never wrote to the clipboard, so Cmd+V after
Cmd+C got the paste event with whatever text the clipboard held before
and made a text layer of it instead of pasting the copied layers.
WebServices now writes copies (text and PNG) through egui's output,
which eframe hands to the browser clipboard during the copy event.
2026-10-14 20:26:18 -03:00
Barzabel 90a4606c65 M9: a compaction cut short closes the file instead of confirming lost edits (#183) 2026-10-14 22:53:02 +03:00
Barzabel ac8a0a112c M1: keep a recovery copy until this run has its own (#187) 2026-10-14 22:10:49 +03:00
vxMacDev b821b6f8f4 fix(recovery): keep the version history and branches in recovery copies (#185)
Recovery copies were written with plain `save_bytes`, so a file
recovered after a crash came back without its version history and
branches, even though `file.recover` reads both. Copies now go through
`save_bytes_with_history` like a normal save, with each open branch
tab's latest edits (`branches::current`), since `sync` only puts those
in before the next command.

A copy was also only redone when the main file's revision changed, and
a file with edits only in a branch tab didn't count as changed. Both
checks now include the file's branch tabs.
2026-10-14 13:41:17 -05:00
MarekSpodBiedry a8a689d682 fix(server): keep each team library version in its own file (#206) 2026-10-14 16:19:01 +02:00
MarekSpodBiedry 8b961b980a Recovery: wait for the write in flight before an emergency save and before removing copies (#202) 2026-10-14 16:12:40 +02:00
NetterSilverandClaude Haiku 5.5 4193a82dfb Count a recovery revision as copied only after its write succeeds
Fixes #186

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:08:47 +02:00
fede9234 3709e7320c fix(desktop): Windows' first preferred language picks the UI language (#291)
Windows' preferred-language list is also the input-language list: a language
added for a keyboard sits in it, and taking the first entry with a catalog
turned the interface German on a Polish system (a German keyboard) while the
system and the other apps stayed Polish. Only the first preferred language now
decides; when it has no catalog the interface reads English. macOS'
AppleLanguages and gettext's `LANGUAGE` still take the first language with a
catalog. Fixes #291.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-14 14:54:01 +02:00
Marco Arnulfo 310cb40af9 M1: open .figcraft files from Finder, Open With and the Dock on macOS (fixes #289) 2026-10-14 13:58:37 +02:00
Piettro CastroandClaude Opus 5.5 f3cd0509cb fix(engine): read the documented gradient shorthand on every route (#252)
`{"type":"linearGradient","stops":["#hex",…]}` was only expanded by the MCP
tools, so `figcraft-cli run` and the control channel's `engine.execute`
refused it ("`fills` must be paints or colours"). The expansion now lives in
the engine (`params::normalize_paint`, used by `paint_of`), so node.set,
node.create props, fill.add/set, strokes and paint styles all take it; MCP
calls the engine's copy instead of its own.

Tests: engine (every paint command, MCP-equal defaults, junk still refused),
control channel `engine.execute`, and `figcraft-cli run` end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:02:45 -05:00
Justin John 94ce2a3611 M13: make native file dialogs asynchronous 2026-10-14 11:04:30 +05:30
bcedfeba10 M7: recent files as thumbnail cards on the home screen (#249)
The Recents section shows recent files as cards: the picture each file
carries (thumbnail.png in .figcraft and .fig, an image file itself) fitted
on its first page's colour, a divider, and a footer with the file's icon,
name and "Edited ... ago" (or "File not found"); the full path on hover.
Pictures are read off the frame's thread, only for cards in view, cached
by path and modification time, capped in bytes and pixels; missing,
damaged or oversized ones give a placeholder. They fade in, and cards
show a skeleton until their file has been looked at.

A sort menu (Alphabetical, Date created, Last modified; either order) and
a grid/list toggle sit above them. The list is a Name, Last modified and
Created table whose headers sort. View (per section) and sort are kept in
the preferences and set with ui.set {homeRecentsView, homeRecentsSort,
homeRecentsOrder}. A click selects, a double-click or Enter opens, arrow
keys move the selection; a right-click offers Open, Show in folder and
Remove from recents (file.openRecent {remove}).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
2026-10-14 12:00:20 +07:00
8e12e5eeda fix(server): never persist a snapshot the reader refuses (#188) (#236)
Snapshots hold more than the document (recent batches, deletions), so near
the cap compaction could write one over max_file_bytes, which both readers
then refused: the file vanished on restart. Reading now allows twice the
cap, and compaction refuses a snapshot beyond that, keeping the old
snapshot and log.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:31:30 +07:00
118ca7c60b fix(desktop): write preferences owner-only on Unix (#200) (#235)
state.json holds online owner tokens. The temporary file is now created
0600 (and narrowed if a crash left one behind) before anything is written,
and the rename carries that mode to state.json, so an older
world-readable file is replaced on the next save.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:31:17 +07:00
Jeremy YaoandClaude Opus 5.5 7d589c4456 fix(desktop): use Direct3D 12 on Windows instead of Vulkan (#226)
On Windows, eframe offered wgpu every backend and wgpu ranked Vulkan
first. On a laptop with two AMD GPUs whose drivers are years apart, the
Vulkan driver faults (0xc0000005 inside amdvlk64.dll) right after the
first frame, so the app died as its window opened. An access violation
in a driver thread is not something the app can catch.

Offer wgpu Direct3D 12 on Windows, with OpenGL behind it for hardware
without a Direct3D 12 driver. WGPU_BACKEND still names the backend on
every system, and other systems keep eframe's defaults.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:29:21 +07:00
YTwsyandbflatastic 33dcb1aef6 Refuse incomplete SVG/PDF exports beyond the depth limit (#218)
SVG and PDF exports check the visible subtree's depth before bounds or drawing and refuse, with an actionable message, a document nested past 256 levels instead of silently leaving the deep layers out; nothing partial is written, and an inner layer can still be exported. The CLI and MCP report the same error. Fixes #208.

Also: on Windows, figcraft and figcraft-cli link with an 8 MB main-thread stack (Windows gives 1 MB where Linux and macOS give 8), so an export at the depth limit no longer overflows it.

Co-authored-by: bflatastic <bflatastic@users.noreply.github.com>
2026-10-14 05:23:28 +07:00
Euisuh Jeong e8d7dc24f2 fix(control): bound frames and expire queued requests (#213)
MCP and control-channel lines are bounded while they are read (32 MiB; an oversized frame gets an error and the stream closes without being drained). The loopback control listener caps connections at 8, applies 60 s socket read and write timeouts, caps both request queues at 8 and discards requests whose client stopped waiting. Fixes #201.
2026-10-14 05:18:40 +07:00
bflatastic 573dd521bb M11: starter templates: Templates tab, first-run templates and Getting started card on the home screen 2026-10-13 03:01:19 +07:00
bflatastic 31815f1d13 M9: online files: crash recovery never drops an unsent edit
The recovery copy of one of your online files is no longer discarded when
the file reopens from its server. It is kept until the outcome is known:

- the server has everything in it: the copy goes;
- the copy has changes that never reached the server and nothing else
  changed there: they are applied on top as one edit and sent to everyone;
- anything else (others changed the file, the server forgot the client):
  the copy opens beside the file as "<name> (recovered copy)", with a toast;
- the server can't be reached: the copy opens as an unsaved document.

The note beside a copy is written after it and records who the app was in
the file and the server's last change it had. The app joins again as that
client (the first hello resumes; a new sync client numbers its batches
after the ones the server processed), so the layers it made are its own to
send. The old copy is removed only once this run has copied what it became.
collab.recoverFile; a tooltip on the tab mark; docs.
2026-10-13 01:41:14 +07:00
bflatastic 501e4a6737 M9: online files: the app remembers the files it put online
A file put on a server without the server's key stays its owner's after its
tab closes or the app quits. The app records it with the preferences (server,
id, name, owner token, the owner's links, what others join with; at most 200;
validated when read back) and never shows the token: not in command results,
ui.inspect, logs or debug output.

- Home > Online lists your online files: open one as its owner (a new tab, or
  its tab comes to the front), start and stop sharing, copy the code or link,
  rename on the server, remove from the server (confirmed), forget here. One
  request per server when the section opens; offline and gone states.
- collab.files, collab.openFile, collab.forgetFile; collab.manageFile takes
  its server and token from the record and gains share / unshare.
- Server: Admin owned (a compatible addition within protocol version 2) says
  what became of the files a list of owner tokens owns. Older servers leave
  the list as recorded.
- Tabs mark files in a live session (shared, or open only to you); toasts
  about a file that isn't in front name it; after a crash a recorded file
  reopens from its server as its owner.
- Draft catalogs (de, fr, ja, ko, pt-BR, zh-Hans) translate the 71 strings
  and 5 command labels they lacked and the new ones; es and es-419 follow.
- Docs: collab-protocol, server, mcp, control-protocol, parity pages, gaps,
  ROADMAP.
2026-10-13 01:41:14 +07:00
bflatastic 8f6a123f29 M9: share with a join code: stop sharing keeps the file
Online, the file is its owner's: others get links and codes of their own, and
Stop sharing (collab.stop) revokes those on the server. Whoever joined with
them is disconnected at once and told sharing was stopped (the session's end,
with their copy); the owner stays in the file, which stays on the server, and
Start sharing gives the same file new links and codes. Removing the file is a
separate, confirmed step under Advanced.

The join field is the shared text input: as tall as the Join button beside it.
2026-10-12 19:42:28 +07:00
bflatastic 6720ea4a21 M9: share with a join code: online through the built-in server or on the local network
The Share popover is one flow: Online (the file is published to the server in
Preferences and the server names its links with short codes) or Local network
(the app hosts; the code carries its address and the session's secret), one
Start sharing button, the code large with Copy code and Copy link, who is here,
can edit / can view, and Stop sharing. Join with a code is one field in the
popover, on the home screen and in the Join dialog, for a code of either kind
or an invite link.

Protocol (within version 2, new variants last): Resolve / Resolved and Admin
code. The server keeps a stretched hash and the wrapped token, never the code,
and limits wrong codes per address (with a lockout) and for everyone. Older
servers leave the links in place of a code; older apps join by link.

collab.share, collab.join {code}, code and share in collab.peers; the server
preference defaults to the built-in server and can be reset or turned off.
2026-10-12 19:42:28 +07:00
bflatastic 94349d86b0 M9: live sessions per document
Live sessions are per document instead of one for the whole app.

- **Engine** (`crates/engine/src/live.rs`): `Session.live` is now `Lives`: the app-wide parts (the
  installed link, the invite-link registration) plus one `Live` per session. One place resolves
  "the session of this document": `Session::live()` (the active document's; status off when it
  has none), `live_mut()`, `live_of(uid)`. Sync, undo, read-only checks, follow, cursor chat,
  spotlight and presence all go through the session of the document they are about, so edits and
  presence never cross. Sessions behind the active tab keep syncing; your presence there is empty.
- **Link** (`LiveLink`): every call takes the `SessionId` the engine picked and `poll` returns
  `(SessionId, Event)`, so one link carries several sessions. The desktop link and the browser
  link keep them in a shared table with one mailbox per session
  (`crates/collab-net/src/sessions.rs`); each hosted document listens on its own port.
- **Commands**: `collab.*` act on the active document's session; no parameters changed.
  `collab.peers` keeps its shape and gains `session` (its id, null when the document isn't in
  one) and `sessions` (every session: `{session, document, status, reason, role, fileName, link,
  peers}`). `collab.join` returns the new session and opens its file in a tab of its own without
  touching other sessions; until then (or if it fails) it shows on the tab it was started from.
  Starting a session for a document replaces only that document's own session. Closing a document
  ends its session only.
- **UI** (`crates/ui-egui/src/live.rs`): the Share popover, avatars, cursors, chat, spotlight
  banner, follow frame and read-only reason read the session of the tab in front, per window. What
  belongs to one session in the window (cursors shown, your chat box, a report being written) is
  dropped when another tab comes to the front. `ui.inspect`'s `live` gains `session` and
  `sessions`. No new strings or command ids.
- **Docs**: `docs/collab-protocol.md`, `docs/mcp.md`, `docs/control-protocol.md`,
  `docs/collaboration-parity.md`, `docs/gaps.md` (gap 2), `ROADMAP.md`.
2026-10-12 17:54:18 +07:00
BFlat 76929f63ce App icon: full rounded tile on Windows and Linux (#159)
The hicolor theme, the .ico and the window icon now show the whole tile with its rounded
corners, like the other Crafting Apps, instead of a crop that cut most of the corners off.
2026-10-12 15:26:54 +07:00
BFlat a0d1e72c8d App icon: the chameleon, vectorised like the other Crafting Apps (#156)
The owner's engraving-style chameleon drawing, traced to three colours (ink, paper,
FigCraft green #02da96) on the shared 512-unit tile. assets/app-icon/figcraft.svg is the
master; cargo xtask icon now renders the PNGs (macOS grid), the hicolor theme and the .ico
(tight crop, 16-256 px) from it with resvg instead of drawing the old geometric mark.
Off macOS the window icon uses the tight 256 px render.
2026-10-12 14:25:29 +07:00
bflatastic 9fde7fed3a M9: oversized-message test sends only the frame header (no broken pipe on Linux) 2026-10-12 11:40:03 +07:00
bflatastic 98c4753118 M9: open hosting: fair limits for shared addresses
Third review:
- a broadcast counts once per recipient block, welcomes don't count
  against block caps, and presence, cursor chat and pings have their own
  per-address budget, so an office behind one address works together
- when everyone's reading budget runs out, the biggest readers are cut
  off until it fits (their bytes freed at once), no cliff
- block counters are forgotten once only the table holds them
- install.sh: root's downloads, clone, swap file and built binary in a
  root-only directory; the build user builds its own copy; the binary is
  copied out with the build user's rights
- recommended open-mode settings in install.sh's config and the docs
2026-10-12 11:40:03 +07:00
bflatastic e0435d4eb4 M9: close the remaining open-hosting DoS paths
Second security review:
- a broadcast counts once against the queued-bytes budget; each block of
  addresses gets at most an eighth; when the budget runs out the biggest
  consumer is shed, not the receiver
- forced compactions at most once a minute per file
- plugin downloads check the per-address share before the global budget
  and before reading
- install.sh never replaces a Caddyfile with other sites (one import line,
  once; install-test.sh in packaging lint); the built binary runs only as
  the server's account
- bytes being read count against per-block and global budgets, the
  biggest readers dropped first
- block multipliers x2 (/56) and x4 (/48), connection caps at most a
  sixteenth/eighth of all; global publish and download budgets eight times
  a /48's share
- problems capped at 200; writes refused below max(1 GiB, two files) free;
  the private-server file cap documented
2026-10-12 11:40:03 +07:00
bflatastic 7edc61b8d2 M9: harden open hosting against resource exhaustion
Fixes from the security review of the public server:
- team libraries only in key-published files on open servers; unpacking
  bounded by the file cap (zip bombs refused)
- byte-bounded send queues per connection and in all, a bound on bytes
  waiting for the hub, per-address and global download budgets
- batches checked against the file, per-address and total caps as files
  grow (refused with a Reject the app rolls back); the core's recent-batch
  memory bounded in bytes; nothing confirmed when a log can't be written
- usage reports kept in memory; per-kind message caps checked while read
- layered IPv6 limits (/64, /56, /48), a global publish rate, connections
  reserved for sessions, short idle timeout for one-shot requests
- revoked links and removed files cut library connections
- data directory 0700, server key 0600 from creation
- install.sh: SSH ports from sshd, ufw left off when other services
  listen, unprivileged builds of the latest tag, settings checked before
  use, own Caddy snippet; systemd memory and network caps; backups check
  free space and say they hold the key
- linger reads raw bytes after an oversized frame (tungstenite asserted)
2026-10-12 11:40:03 +07:00
bflatastic b7af7c7ef7 M9: open hosting mode for public figcraft-server
Config-gated (open_publish): anyone publishes without the server key and
gets an owner token (128-bit, shown once, stored hashed) that manages only
that file. Per-address limits (X-Forwarded-For only from trusted_proxy,
bounded tables), global caps, idle expiry, reports, persisted blocks,
files list --sort, check, GET /healthz on the same listener, startup
warnings. The app asks the server first, publishes without a key when it
is open, shows the owner token once, the host/terms line, the expiry date
and a report button; collab.serverInfo, collab.manageFile, collab.report
and pref.publishServer for MCP/CLI. Protocol additions stay within
version 2, gated like signals.
2026-10-12 11:40:03 +07:00
bflatastic f2802cca02 M7: headless renders use system fonts
Headless runs (`figcraft-cli export`, `render`, `convert`, `run`, and the headless MCP server) never indexed the installed fonts, so a text layer in an installed family (Segoe UI, Inter installed per user, ...) was measured and drawn with a fallback face, while the desktop app used the real one. Auto layout sizes from MCP agents therefore differed from what users see in the app.

- `figcraft_text::scan_system_fonts_on_demand()`: the font database indexes the system fonts the first time it is used (first text layout, font lookup or font listing). `figcraft-cli` turns it on at startup for every subcommand, so a run that touches no text never pays for the scan. The desktop app keeps its background scan.
- `scan_system_fonts()` now runs once per process (a `OnceLock`): racing callers wait for the one directory walk instead of each walking the folders.
- Opt-out for reproducible renders: `--no-system-fonts` on any subcommand, or `FIGCRAFT_NO_SYSTEM_FONTS=1` (honoured by the desktop app too). Text then uses only the built-in fonts, as before.
- The scan is cheaper: it reads each file's headers, table directories and `name` tables instead of whole files (about 460 MB on this Windows machine), and takes file metadata from the directory listing instead of a lookup per file. This also speeds up the desktop app's background scan.
- Browser/wasm build unchanged (the scan is a no-op there).
- Docs: `docs/cli.md` (new Fonts section) and `docs/mcp.md` (Fonts under the backends table); CLI usage text mentions the flag.
2026-10-12 09:32:42 +07:00
bflatastic c420721597 M6: experimental .fig export
An experimental, best-effort `.fig` export, so work done in FigCraft can be handed back as a `.fig` file. It is written from the same public format write-ups the importer uses and is the inverse of the importer's mapping. Only round trips through FigCraft's own importer are verified; other apps may not open every file.

- **Encoder** (`crates/format/src/fig/kiwi.rs`): the kiwi encoder that test fixtures used is now shipped code. It never panics and checks every value against the schema: unknown fields or enum names, missing struct fields, wrong types, out-of-range numbers and non-finite floats are errors, and NULs inside strings are dropped. Varints and floats are written exactly the way the decoder reads them. `Schema::parse` reads the compact text schema form, and the importer's tests use it too.
- **Writer** (`crates/format/src/fig/export.rs`): writes the zip (`canvas.fig`, `meta.json`, `thumbnail.png`, `images/<hash>`). `canvas.fig` holds the fig-kiwi header, then a raw-deflate binary schema and a raw-deflate message (flate2's pure-Rust backend, already in the tree; no new dependencies). The schema covers only the fields we write. The canvas, images, blobs, node count and nesting depth are capped, and floats are made finite before they are written.
- **Mapping:** pages; frames, groups, sections, rectangles, ellipses (arcs), polygons, stars, lines and vectors (vector network blobs with per-point radius, mirroring and caps, plus fill outlines); text with styled runs (UTF-16 indexed) and list types; slices; boolean groups; solid, gradient and image paints (image bytes included); strokes; effects; corners; constraints; masks; auto layout, including grids with tracks and cells; child layout; layout grids; export settings; components, component sets and their properties. Instances are written as their main plus the fields their layers change, by id path. This covers nested instances and swaps, and fields the main sets but the instance clears. Prototype interactions (triggers, navigation and overlay actions, transitions, easing), scrolling and flow starts are included. Everything skipped is reported in a warnings list: style links, variables, comments, Dev Mode data, plugin data, guides, slots, brush and width-profile strokes, pattern fills, image adjustments, variable and conditional actions, and more.
- **Import fixes found by the round trips** (`map.rs`): ellipse arcs, boolean operation types and component set descriptions are now read. Durations are read as whole milliseconds, because seconds stored as 32-bit floats used to truncate by 1 ms.
- **File writes:** a failed write no longer leaves its `.tmp-figcraft` file behind. The junk-parameter sweep found this.
- **Commands and UI:**
  - `file.export {format: "fig"}`, or any path ending in `.fig`, writes the whole file and returns `warnings`. Without a path it returns base64 `data`.
  - File > Export... has a new "The whole file" section with a ".fig (experimental)" row. A note under it says other apps may not open every exported file. After export, a toast reports how many kinds of content were left out.
  - `figcraft-cli export --format fig` (or `-o x.fig`) and `convert` to `.fig` print the warnings as `note:` lines.
  - MCP's `export` accepts `format: "fig"` and passes the warnings through.
  - New UI strings have English and Spanish entries.
- **Docs:** file-format-parity.md has a new export section covering what is written, what is skipped, and that only round trips through our own importer are verified. Also updated: mcp.md, cli.md, gaps.md (gap 1), target-app-parity.md and ROADMAP.md. Grading is kept low. Import/export goes to 86%/58% and file formats to 60%. The file-exchange discount stays at x0.85, because nothing shows that another app opens these files.
2026-10-12 07:19:57 +07:00
bflatastic 01bc5a9dac M11: plugin installs saved at once; directory and library requests off the UI thread
- **Installed plugins survive a crash.** Importing, installing, updating or removing a plugin, and a
  plugin changing its settings, now sets `Runtime::unsaved`; at the end of each frame the app hands
  the preferences to a new `Services::save_prefs`, and the desktop app writes `state.json` at once
  (temporary file + rename, the same atomic write the quit path uses, now shared). Nothing waits for
  quit any more.
- **The UI never waits on the network.** New `Session::start_background(id, params)`: the command
  runs as usual until it makes a server request; the request goes out on the link's own thread
  (`LiveLink::background`, implemented by the desktop `NetLink` with one short-lived thread per
  request) and the command stops; when the answer arrives (`Event::Net`, taken by `live_poll`, which
  the network's waker already triggers) the command runs again from the start on the file it
  started on, answered from the answers so far, until it needs no new request. All server requests
  go through one funnel (`Session::net`), so `plugin.directory.*`, `library.publishToServer`,
  `library.add` (links), `library.usage` and library fetches work this way without being split.
  A re-run of `library.publishToServer` caches the library it actually sent (a fresh copy differs
  in its time and, on a first publish, its id). One job per command; `cancel_background` drops a
  late answer.
- **UI:** `FigApp::run_background(ctx, id, params, done)` with a boxed completion. Manage plugins >
  Browse shows "Fetching from the server..." with Cancel instead of Browse; the install confirmation
  shows "Installing..." with Cancel and stays open until the download is in (an error leaves it open
  to retry); row buttons wait during an install; closing the window cancels. Assets > Libraries:
  Publish to server shows "Publishing..." + Cancel, Show usage and pasting a team library link show
  "Fetching from the server..." + Cancel. No polling repaints: the window draws again when the
  answer's waker fires; pending state is a scan of a short Vec, no per-frame allocation.
- **Programmatic callers are unchanged:** MCP, CLI, control channel and scripts still run the same
  commands synchronously (and the browser link, which has no background requests, too).
- New string "Installing..." (en/es); docs: plugins.md (immediate saves, Browse/Install never wait),
  server.md (team library requests never wait).
2026-10-12 06:15:00 +07:00