Merge main into #664

# Conflicts:
#	crates/engine/src/code_connect.rs
#	crates/engine/src/codegen.rs
#	crates/engine/src/component_codegen.rs
#	crates/engine/src/lib.rs
#	crates/engine/src/playground.rs
#	crates/ui-egui/locales/bn.toml
#	crates/ui-egui/locales/cs.toml
#	crates/ui-egui/locales/de.toml
#	crates/ui-egui/locales/en.toml
#	crates/ui-egui/locales/es.toml
#	crates/ui-egui/locales/fi.toml
#	crates/ui-egui/locales/fr.toml
#	crates/ui-egui/locales/id.toml
#	crates/ui-egui/locales/ja.toml
#	crates/ui-egui/locales/ka.toml
#	crates/ui-egui/locales/ko.toml
#	crates/ui-egui/locales/nl.toml
#	crates/ui-egui/locales/pl.toml
#	crates/ui-egui/locales/pt-BR.toml
#	crates/ui-egui/locales/ru.toml
#	crates/ui-egui/locales/sv.toml
#	crates/ui-egui/locales/tr.toml
#	crates/ui-egui/locales/zh-Hans.toml
#	crates/ui-egui/src/dev_mode.rs
#	docs/dev-mode-parity.md
#	docs/gaps.md
#	docs/mcp.md
#	docs/target-app-parity.md
This commit is contained in:
bflatastic
2026-10-26 23:52:51 +07:00
541 changed files with 53315 additions and 4270 deletions
+69
View File
@@ -0,0 +1,69 @@
# Boards parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
**Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind
(`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that
follow what they join (straight, elbow or curved, their ends dragged onto other objects), marker,
highlighter and eraser, stamps, text and sections, five built-in templates and tables, edited
together in the existing live sessions, with a shared countdown and voting sessions to run a
workshop. Music, board widgets, mind maps and the rest of the tail are missing. Remaining:
~20–44 h. Tracked as area 22 of
[`target-app-parity.md`](target-app-parity.md) (not in its weighted sum: the target there is the
design product). The work item is gap 24 in [`gaps.md`](gaps.md).
## How boards are built
Board objects are ordinary layers with a small `board` field on the node (`figcraft_doc::BoardItem`),
so rendering, hit testing, undo, saving, SVG/PNG/PDF export, copy and paste and live sessions work
on them unchanged. The file kind (`Document.kind`) is saved in `.figcraft` and reaches people who
join a live session in their snapshot. What follows from the metadata (a sticky's text size, a
shape's text room, connector routes and their labels) is recomputed in the engine's normalize pass,
deterministically from the document, so local edits, undo and edits from others in a live session
all end with the same geometry.
## Features
| Feature | Status | Notes |
|---|---|---|
| Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) |
| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Resources, Actions, the Design panel's button |
| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL), connector straight/elbow/curved and its arrow; the Design panel opens with its button or View ▸ Design panel |
| Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why |
| Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it |
| Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks |
| Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar |
| Shape auto-fit to its text, more shapes (cylinder, cross, star…), shape libraries | missing | |
| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); re-routed when either end moves or resizes; a deleted end stays where it was |
| Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) |
| Connector waypoints placed by hand | missing | routes are computed from the ends; no bend points of your own |
| Quick-create handles (drag out a connected copy) | missing | |
| Marker and highlighter | done | the Pencil's stroke fitting with board presets: marker opaque, highlighter wide, see-through and multiplying |
| Eraser | done | drag over marker and highlighter strokes to remove them, one undo step |
| Pen colours and widths in the toolbar | partial | `tool.marker {color}`, `tool.highlighter {color}`; colour changes on a selected stroke from the contextual bar |
| Stamps | done | thumbs up, heart, plus one, star, question, check: round badges drawn as vectors, the placer's name on hover |
| Stamps sticking to the object they land on, the stamp wheel, emotes, stickers | missing | |
| Text and sections | done | the existing Text tool and sections |
| Live sessions: cursors, selections, cursor chat, spotlight | done | the existing sessions; board objects, connectors following moved objects, colours and stamps sync both ways (checked with two app instances) |
| Timer | done | a pill at the top right of the board: presets (1, 3, 5, 10, 15 min) or any whole minutes, pause, resume, a minute more, stop; red at 0:00 until stopped; no sound. `board.timer {action, minutes?}`, `board.timerState`. Synced as its end time (or the time left while paused), so every client counts down on its own clock: nothing ticks over the network. Not an undo step |
| Timer music, timer sounds | missing | |
| Voting sessions | done | one at a time: votes per person (1–99), on every object, on sticky notes or on the selection; a click on an object places a vote (several on one object allowed), a click on your badge or an Alt-click takes it back; your own votes show as badges while it runs, everyone's once it ends, with a ranked list (ties share a rank) that selects an object from its row; Clear results. `board.vote {action: start\|cast\|retract\|end\|clear}`, `board.voteResults`. Ballots sync per voter (keyed by the live-session client id, or your name offline), so people voting at once never overwrite each other. Not undo steps |
| Voting: only the facilitator ends or clears it, who voted for what | missing | anyone who can edit the board can end or clear a vote; results are counts only |
| Board widgets | missing | design-file widgets exist (`crates/engine/src/widgets.rs`), not wired to boards |
| Templates (retrospective, brainstorm, flow chart…) | partial | five built-in templates drawn up for FigCraft (retrospective, brainstorm, weekly planner, flow chart, kanban board), placed from the toolbar's templates picker (each with a picture of itself) or `board.template {name, x?, y?}` as ordinary sections, stickies, shapes, connectors and text in one undo step; `board.templates` lists them. No community templates, no saving your own |
| Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; undoable and synced in live sessions. No column resizing by dragging, no cell colours, no Tab between cells, no CSV paste |
| Mind maps | missing | |
| Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files |
| Audio and huddles | missing | |
| AI features (summaries, sorting stickies, generating boards) | missing | |
| Dot grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it; 24 units apart, doubling when zoomed out until the dots are at least 14 points apart, the dots the next doubling drops fading out first; dark dots on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per dot; never in exports |
| `.jam` import and export | missing | see [`file-format-parity.md`](file-format-parity.md) |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | M12: the dot grid on board pages (a render pass under the layers), dragging a selected connector's end onto another object or empty canvas (`board.reattach`), curved connectors, and elbows that go around the objects they join. Breadth ~35% → ~37%, ready ~20% → ~21%, ~30–60 h → ~28–56 h |
| 2026-10-24 | minor | Facilitation (M12): a shared timer and voting sessions. Breadth ~35% → ~40%, ready ~20% → ~24%, ~30–60 h → ~26–54 h |
| 2026-10-24 | minor | Templates (retrospective, brainstorm, weekly planner, flow chart, kanban board) from a toolbar picker or `board.template`, and tables (`board.table`, `board.tableEdit`): breadth ~35% → ~40%, ready ~20% → ~24%, remaining 30–60 h → 26–54 h |
| 2026-10-24 | major | Created with board files (M12): sticky notes, shapes with text, connectors, ink and eraser, stamps, the board toolbar and contextual bar, live-session sync |
+36 -9
View File
@@ -4,7 +4,13 @@ FigCraft without a window: inspect, export, convert and render documents, run co
commands, serve MCP and measure performance. Build it with
`cargo build --release -p figcraft-cli` (binary `target/release/figcraft-cli`).
Bad arguments print usage and exit with code 2; failures exit with 1. The CLI never panics on input.
`figcraft-cli --help` (or `-h`, or `help`) prints usage on stdout and exits with 0, and so does
`--help` or `-h` after a subcommand (`figcraft-cli mcp --help`). Bad arguments print usage and exit
with code 2; failures exit with 1. The CLI never panics on input.
Put options before `--` to pass a filename beginning with a dash, for example
`figcraft-cli info --json -- --drawing.figcraft`. Every argument after `--` is literal,
including a filename named `--no-system-fonts`.
## Fonts
@@ -19,6 +25,16 @@ On Linux and other Unix systems except macOS, user fonts are read from
`$XDG_DATA_HOME/fonts`, or `$HOME/.local/share/fonts` when `XDG_DATA_HOME` is unset,
empty or relative. The legacy `$HOME/.fonts` directory is also scanned.
On macOS the font folders (`/System/Library/Fonts`, `/Library/Fonts`, `~/Library/Fonts` and the
system's downloadable fonts) are read, and so is every other font file Core Text lists as
available: fonts a font manager such as RightFont, FontBase or Typeface activates from its own
library, without copying them into a font folder. Fonts activated while the app runs are found by
`text.rescanFonts`, like newly installed ones.
On macOS, fonts activated through Adobe Fonts are read where Creative Cloud keeps them
(`~/Library/Application Support/Adobe/CoreSync/plugins/livetype`), so they're available while
they're active there. Files without an extension are read when they start like a font file.
The collaboration server is a binary of its own, `figcraft-server` (`cargo build --release -p
figcraft-server`): it keeps files with share links and serves their live sessions, and its
`key`, `files`, `links`, `reports`, `block-ip` and `check` subcommands manage them (a server can
@@ -36,7 +52,8 @@ layers}]` with `--json`); their text renders in a fallback font.
Every subcommand that reads a file also takes the public REST API file JSON (the response of
`GET /v1/files/:key` or of its nodes endpoint, written to a `.json` file): it is imported on the fly.
Image fills come from `images/<imageRef>.png` (or `.jpg`, …) next to the JSON when present, else a
grey placeholder. `.fig` files are read the same way (best effort, images from the file itself);
grey placeholder. `.fig` files are read the same way (best effort, images from the file itself), and
so are `.sketch` files saved by Sketch 48 or newer;
`info` lists what wasn't imported as `note:` lines.
```text
@@ -51,6 +68,10 @@ page 1 "Screens": 380 nodes, 4 top-level
- `<out>` is a directory: one file per top-level frame of the page (default the first page), named
after the frame (`Home.png`, `Settings.png`…); with no frames, one file per top-level layer.
The name is kept as it is, except for the characters file systems refuse (`/ \ : * ? " < > |`
and control characters), which become `_`, and leading or trailing spaces and dots, which are
dropped. A name is cut at 100 characters. The app's export replaces the same characters (there a
`/` makes a folder). Two layers with the same name get `Name 2`, `Name 3`…
- `<out>` has an image extension: one file with the given `--node`, or the whole page composited.
- `--node` takes a layer id (`12` or `0:12`) or a layer name.
- `--format fig` (or an `<out>` ending in `.fig`) writes the whole file as `.fig`, experimental:
@@ -86,7 +107,7 @@ figcraft-cli export app.figcraft -o handoff.fig
### `convert <in> <out>`
By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.fig` (best effort), `.svg` (an editable frame: groups,
By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.fig` (best effort), `.sketch` (Sketch 48 or newer, best effort), `.svg` (an editable frame: groups,
shapes, text, gradients, masks and effects; `info` lists what it left out as `note:` lines), `.png/.jpg/.webp/.gif/.tif/.bmp`
(an image layer). Outputs: `.figcraft`, `.json` (FigCraft JSON, pretty-printed), `.svg`,
`.png/.jpg/.webp` (the current page rendered at 1x with its background), `.pdf` (the current page's
@@ -106,14 +127,18 @@ Runs commands in a headless session (a new document, or `--in`), printing one JS
```
The whole script is **one undo step**, and it is **all-or-nothing**, exactly like running the same
commands through `command.batch` (the MCP tool `batch`, the control channel, plugins): `{"$step": n}`
in a step's params is the `id` in an earlier step's result, so a step can target a layer an earlier
step of the same script created:
commands through `command.batch` (the MCP tool `batch`, the control channel, plugins). A step named
with `"as"` can be used by later steps: `{"$ref": "name"}` in a step's params is the `id` in that
step's result (`{"$step": n}` does the same by zero-based position), and `"field"` takes another key
or a slash path such as `ids/0`. So a step can target a layer an earlier step of the same script
created ([Step references](mcp.md#step-references) has the rules):
```json
[
{"command": "node.create", "params": {"type": "frame", "name": "Card", "width": 200, "height": 100}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}, "text": "Hi"}}
{"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "width": 200, "height": 100}},
{"command": "node.create", "params": {"type": "text", "parent": {"$ref": "card"}, "text": "Hi"}},
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Card copy"}}}
]
```
@@ -186,7 +211,9 @@ Serve MCP on stdio: headless by default, or attached to a running app started wi
`figcraft --control PORT`. See [mcp.md](mcp.md). The automation roots (each repeatable; existing
folders) confine the files the agent's calls read (inside a read or write root) and write (inside a
write root); everything else is refused ([mcp.md › Confining file access](mcp.md#confining-file-access)).
With `--attach` they are refused: start the app with them instead. The headless session can host, join and publish
They apply to the headless session only, and that is why `--attach` refuses them: an attached server
is confined by the app it attaches to, from the flags that app was started with. The headless
session can host, join and publish
live sessions (`collab.*`: without a key on a server open to the public, with `collab.serverInfo`,
`collab.manageFile` for the owner token and `collab.report`) and use plugin directories
(`plugin.directory.*`); the one-shot `run` can't (no network link).
+14 -1
View File
@@ -99,6 +99,15 @@ fit FigCraft's architecture:
collide with what a connected client makes, because a client's numbers continue above every id of
its space already in the document. Removed entries are part of the saved session state
(`SessionState::removed`).
- **Branches** of a file in a session stay with the person who made them; nothing about them goes
over the wire until a merge, which is an ordinary batch from that person (no message or format
change). So that the server takes the layers a branch made, a branch made in the session makes
its ids in its author's own client space, 2^24 above every id made there on the main file and the
author's other branches. Layers of the branch's base that main deleted during the session come
back with their ids (the server accepts re-creating deleted ids). Merging a branch whose new
layers, styles, variables or comments lie in another id space (one made offline or in an earlier
session) is refused by the app before anything is sent, since the server would refuse it (see
`crates/engine/src/branches.rs`).
- Objects inside an entry are not separate objects: modes are part of their collection, a
variable's values per mode part of the variable and replies part of their comment thread. Two
people adding a mode to the same collection, editing different modes of one variable, or replying
@@ -142,7 +151,11 @@ As built:
colour profile, image presets, annotation categories and library data (whole values); styles,
comment threads, variable collections and variables (one entry per id plus the order of ids;
the whole list when ids aren't unique); dev-mode snapshots (per layer); images (per content
hash, the encoded bytes as base64). The page order is the pages' positions.
hash, the encoded bytes as base64); a board's timer and vote (whole values, `DocProp::Timer` and
`DocProp::Vote`) and the vote's ballots (`Entry::Ballot(voter)`, one per voter, so people voting
at once write different entries). The timer travels as its end time (or, paused, the time left),
never as ticks: each client counts down on its own clock. The page order is the pages'
positions.
- **Next**: this file's published library (it embeds a snapshot document; it moves with an asset
channel, together with images, which then stop travelling inside batches); the version history
(kept beside the document, not in it) on the standalone server; finer entries for modes,
+3 -2
View File
@@ -1,6 +1,6 @@
# Collaboration parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
@@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 |
| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
| Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 |
| Notifications, activity | yes | no | with sharing |
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged |
| 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged |
| 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove <name> from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged |
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
+6 -4
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -29,7 +29,7 @@ accounts).
| Assets panel: folders by page, frame and slash names (fold away), thumbnails, search, drag into frames and auto layout slots | done | virtualized; `component.list` gives each one's `folder` |
| Hidden from publishing: a leading `.` or `_` in the name | done | the name decides both ways (adding the prefix takes a published component out, removing it brings it back); Hide when publishing in the Assets panel's context menu and Libraries ▸ Hide when publishing (see [ui-parity](ui-parity.md)) |
| Instances pasted into other files follow their mains | done | missing main restorable |
| Component playground (try properties without placing) | partial | variant, boolean and text properties in the Inspect tab and `component.playground`; instance swaps and slots have no control there yet (0.5–1 h) |
| Component playground (try properties without placing) | partial | variant, boolean, text and instance-swap properties in the Inspect tab (the swap menu lists the preferred components first, then every component in the file, as in the Design panel) and `component.playground`; slots have no control there yet |
| Real-world libraries QA | **not checked** | [gaps](gaps.md) #3 |
The behaviour of instances, overrides, variants and swaps is checked against the public
@@ -61,12 +61,12 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs` |
| Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only |
| Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) |
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii and grid sizes, the rest through the command |
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command |
| Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); scopes filter pickers only |
| Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay |
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values |
| Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose code doesn't name variables yet |
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
| Explicit modes per frame, library collections' modes | done | |
| Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed |
| Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) |
@@ -77,6 +77,8 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Variables on a text style's fields from the style editor: the font and font style menus start with Apply variable (string variables), and the size, line height and letter spacing fields show a variable button on hover (number variables). A bound field shows its variable in place of the value; a click on it picks another variable and its detach button unbinds it (`style_picker_tests`). Left: the other effect and grid fields (commands only), library variables in the picker. Numbers unchanged. |
| 2026-10-23 | minor | Instance-swap properties in the component playground: the Inspect tab's Playground shows a dropdown for each, with the same menu as the Design panel's (the property's preferred components first, then every component in the file). The component picked changes the preview and the code only, never the file. Left: slots in the playground. Numbers unchanged. |
| 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. |
| 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged |
| 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently |
+42 -20
View File
@@ -41,11 +41,17 @@ still answers every method that acts on the app's state (`engine.execute`, `docu
`node.get`, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `ui.render`, `window.list`,
…), in the file that window shows, so an attached agent can edit, inspect and save while the app is
out of sight. The app is not drawn meanwhile and only wakes for requests, at most ten times a
second while minimised, so a request there takes up to about 0.1 s. What only a drawn frame can
answer (`ui.screenshot`, `ui.input`, `window.new`) is an error at once (`window 0 is minimised or
hidden: …`) instead of waiting for the window to show; `ui.render` draws the document without it.
Under Wayland the app can't tell that its window is minimised: where the compositor then stops
drawing it, a request waits for the window and gets the 60 s timeout error.
second while minimised, so a request there takes up to about 0.1 s. A request that needs a drawn
frame (`ui.screenshot`, `ui.input`) is kept queued while the window is hidden and asks it to paint
(`RequestPaintWhileHidden`; native eframe honours that). A screenshot whose window reports no
visibility at all is parked the same way with its deadline, so it cannot wait forever in the
queue. A screenshot settles on three completed, presented UI frames (a discarded sizing pass does
not count), then is issued, and expires once at its 60 s deadline if no paint arrives. `window.new`
remains an error at once (`window 0 is minimised or hidden: …`) instead of waiting for the window
to show; `ui.render` draws the document without it. Under Wayland the app can't tell that its
window is minimised: where the compositor then stops drawing it, a request waits for the window
and gets the 60 s timeout error. The browser's shared eframe runner does not implement
`RequestPaintWhileHidden` yet, so hidden-tab screenshots and injected input are unqualified there.
Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent
frame (groups have no coordinate space of their own).
@@ -100,13 +106,17 @@ JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"]
{"id":3,"method":"engine.execute","params":{"command":"command.batch","params":{"label":"Tidy","commands":[{"command":"align.left","params":{"ids":[2,3]}},{"command":"distribute.vertical","params":{"ids":[2,3]}}]}}}
```
Inside `command.batch`, `{"$step": n}` in a step's params stands for the `id` in step `n`'s result
(zero-based; `{"$step": n, "field": "…"}` for another field; any object with a `$step` key is read
as one, and a nested `command.batch` step keeps its own references to its own steps), so one batch
can create a frame and fill it:
Inside `command.batch`, a step can name itself with `"as"` and later steps use its result:
`{"$ref": "name"}` in a step's params stands for the `id` in that step's result, and `{"$step": n}`
for the `id` in step `n`'s result (zero-based). Either takes `"field"` for another key or a slash
path into the result, such as `ids/0` for the first copy `edit.duplicate` returned. Any object with a
`$ref` or `$step` key is read as a reference, names are unique within a batch, and a nested
`command.batch` step keeps its own references to its own steps (see [MCP: Step
references](mcp.md#step-references) for the rules and errors). So one batch can create a frame, fill
it and duplicate it:
```json
{"id":4,"method":"engine.execute","params":{"command":"command.batch","params":{"commands":[{"command":"node.create","params":{"type":"frame","width":200,"height":100}},{"command":"node.create","params":{"type":"rectangle","parent":{"$step":0},"width":20,"height":20}}]}}}
{"id":4,"method":"engine.execute","params":{"command":"command.batch","params":{"commands":[{"command":"node.create","as":"card","params":{"type":"frame","width":200,"height":100}},{"command":"node.create","params":{"type":"rectangle","parent":{"$ref":"card"},"width":20,"height":20}},{"command":"edit.duplicate","as":"copy","params":{"ids":[{"$ref":"card"}]}},{"command":"node.set","params":{"ids":[{"$ref":"copy","field":"ids/0"}],"props":{"name":"Card copy"}}}]}}}
```
### `engine.commands {}`
@@ -131,7 +141,8 @@ component/instance links, overrides, `slot` (a slot layer's property name), boun
`devStatus` (handoff status), `devChanged` (changed since it was marked ready for dev),
`annotations` (Dev Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's
links to code: `[{language, importPath, name, template}]`), `devResources` (links for developers,
its own and inherited: `[{name, url, index?, from?}]`), `pluginData` (layer data written with
its own and inherited: `[{name, url, index?, from?}]`), `measurements` (Dev Mode measurements
starting from the layer: `[{to} | {side}]`), `pluginData` (layer data written with
`node.set {props: {pluginData: {key: value}}}`, pages included: `{key: value}`, values as strings;
only when there is some). The auto layout props
that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know).
@@ -166,7 +177,8 @@ far the view is panned (`pan`, `panRange`), whether a scroll is still coasting (
active screen's `hotspots` (`[{id, rect: [x0, y0, x1, y1]}]` in window points, bottom-most first),
whether a click where nothing reacts flashes them (`hotspotHints`) and they are flashing now
(`hotspotFlash`), and whether the Inspect panel shows (`inspect`) with the layer it shows
(`inspected`) (`null` when nothing plays). `presentOptions` holds the options the next presentation
(`inspected`), and the link an Open link notice waits to open (`linkNotice`, else `null`) (`null`
when nothing plays). `presentOptions` holds the options the next presentation
starts with (`scale`, `deviceFrame`, `comments`, `hotspotHints`, `inspect`).
`textPreview` is true while a live text preview shows on the canvas (a hovered text style or named
instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)).
@@ -256,7 +268,7 @@ separate calls. Returns `{queued}`.
Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`.
### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?}`
### `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?, pluginsWindow?, pluginsTab?, pluginConfirm?, pluginForm?, devCompareVersion?}`
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
`home` (bool), `homeSection` (`recents`, `online` or `templates`: which section the home screen shows, as `ui.inspect` reports it; opening
@@ -293,21 +305,31 @@ closes it), `commentDraft` (`{x, y}`: start a new comment there), `commentFilter
text, focused with the cursor at its end: an `@` there shows the mention picker), `commentEdit` (a
message index of the open thread to edit, 0 for the first; `null` stops), `presentComments` (bool:
the presenter shows the presented frame's comments, also Shift+C there). Dev Mode: `devCompare` (a
ready-for-dev frame or section id opens the Compare changes view, like `dev.compare {id, show:
true}`; `null` closes it) with `devCompareMode` (`sideBySide|overlay`) and `devCompareSplit` (the
frame or section id opens the Compare changes view, like `dev.compare {id, show:
true}`; `null` closes it) with `devCompareVersion` (a version id to compare with; `null`: when it
was marked ready for dev), `devCompareMode` (`sideBySide|overlay`) and `devCompareSplit` (the
overlay slider, 0–1), `annotationFilter` (`all`, `none` for uncategorised, or a category id or name:
the annotations the canvas shows). `resetFrameStats` (true: start `framesDrawn` and
`repaintCauses` over). Returns `ui.inspect` (which reports `stylePicker`,
`stylePickerView`, `comments`: `open`, `draft`, `filter`, `sort`, `search`, `editing` and the
`mentions` the picker offers, `devCompare` (`{id, mode, split}` or `null`), `devFocus` (the frame the
`mentions` the picker offers, `devCompare` (`{id, mode, split, version}` or `null`), `devFocus` (the frame the
focus view shows, or `null`) and `annotationFilter`).
### `ui.screenshot {path?}`
PNG of the whole window (`window` picks one), UI included (needs a visible window; an idle one draws the frame to
capture). Returns `{path, width, height}`, without `path` `{format: "png", width, height, data}`
(base64); an error at once when the window is minimised or hidden, or after 60 s when the window
doesn't deliver.
PNG of the whole window (`window` picks one), UI included (an idle one draws the frame to capture;
a hidden one, or one whose platform visibility is not reported, is asked to paint and the request
is parked with its deadline until it does). Returns
`{path, width, height}`, without `path` `{format: "png", width, height, data}` (base64). Requests
are bounded to four pending, 128 MiB of pixels per image and 256 MiB in flight or
completed-but-undrained. The reservation is the larger of the encoder phase (readback +
straight-RGBA copy + PNG) and the reply phase (readback + PNG + base64); the encoder copy is
dropped before base64 is built, and an oversized encoded reply is refused before base64 is
allocated. The reply carries the request's own id, and the capture belongs to the document and
window it was admitted for; cancellation, a tab switch, a resize/DPI/zoom between issuance and
arrival (retried twice, then an error), an unrepresentable current surface (fail closed), and the
60 s deadline never write late files. A hidden window whose pixels arrived is asked to paint the
pass that drains them, so a completed readback is not left to time out.
### `ui.render {path?, scale?, ids?}`
+15 -9
View File
@@ -1,23 +1,26 @@
# Dev Mode parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dev resources links; inspect behaviour suite and fixes: colour formats, rem, blend, positions from the frame, CSS for rotation, gradients and text, icons and images as assets; code connect and Dev Mode plugins) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
**Breadth ~96%, ready ~72% (estimated).** The inspect surface is broad, components connect
to the code components of a repository and layers carry dev resources links; left: import lines
inside generated layer trees. Remaining: 0–1 h.
**Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect
to the code components of a repository (with their imports, also inside generated layer trees),
changes compare with any saved version, the canvas and the layers panel only inspect in Dev Mode,
and measurements stay on the canvas for everyone. Left: publishing mappings from the code
repository, third-party integrations. Remaining: 1–2 h.
| Feature | Status | Notes |
|---|---|---|
| Dev Mode toggle (Shift+D), Inspect panel | done | |
| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why), and the Pages list only opens pages (no adding, renaming, reordering, duplicating or deleting). Files dropped on the canvas aren't imported (a dropped document still opens as a file of its own), and the Assets panel places, swaps and applies nothing, doesn't change Hide when publishing, and doesn't accept library updates or add, remove or swap libraries; each says why |
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
| Ready for dev list, statuses, "Changed" after edits | done | |
| Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) |
| Focus view (one frame, previous / next) | done | |
| Compare changes (before/after side by side or overlaid, property diffs) | done | only against the snapshot taken when a frame was marked ready; not against any named version |
| Code connect (map components to repository code) | done | per language (React, Leptos, Vue, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected |
| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab; no plugin UI at run time, no third-party integrations |
| Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions |
| Code connect (map components to repository code) | done | per language (React, Leptos, Vue, SwiftUI, Compose, ArkUI): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. No CLI publishing mappings from the code repository |
| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab, with their panels (callbacks can only read); no third-party integrations |
| Measurements (redlines on hover, Alt in the design) | done | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas (`devMode.addMeasurement` between two layers, along a side or to the layer around it; stored on the layer, saved, synced, undoable) drawn in Dev Mode as labelled redlines that follow the layers, listed, placed and removed in the Inspect tab; a measurement whose other layer was deleted isn't drawn until an undo brings it back |
| VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors |
| Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them |
@@ -25,6 +28,9 @@ inside generated layer trees. Remaining: 0–1 h.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Dev Mode refuses design edits from files dropped on the canvas (a dropped document still opens as a file of its own, and the drop overlay shows the Dev Mode note for other files), from the Pages list's add, rename, reorder, duplicate and delete, and from the Assets panel's place, drag, Alt-drop swap, style, Hide when publishing, Update, and adding, removing and swapping libraries. Percentages and hours unchanged |
| 2026-10-24 | minor | 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. Breadth 97% → 98%, ready 76% → 78%, 2–4 h → 1–2 h |
| 2026-10-15 | minor | Dev Mode tail (gap 16): generated layer trees (React, Leptos, SwiftUI, Compose) start with the import lines of the connected components they call, once each, also those in a component's definition, in slots an instance fills and placed by a connected template's instance swaps; Compare changes takes any saved version as the before (`dev.compare {version}`, the view's Compare with menu; frames that aren't ready for dev compare with versions). Behaviour suite (5 tests; 4 failed before the fixes): the Dev Mode canvas only inspects (no moving, resizing, drawing, text or vector editing; menus, shortcuts, nudges, opacity keys and paste refuse design edits with a note; toolbar drawing tools go back to the design), the ready list filters and badges Changed items, statuses are refused on layers inside frames. Read-only plugins run from the Inspect tab show their panels (the runtime's panels, reused). Breadth 96% → 97% (measurements kept on the canvas and the read-only layers panel counted as missing), ready 72% → 76%, 0–1 h → 2–4 h |
| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h |
| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes `<text>`), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Dev Mode breadth 90% → 93%, ready 64% → 70%, hours unchanged (1–3, dev resources); feature breadth and ready for real work unchanged at this rounding |
| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–720 h |
+214
View File
@@ -0,0 +1,214 @@
# egui 0.37 local implementation — stage 1 notes and follow-ups
Current candidate font pin: `37827adc41fd165f9a9234ac40f94dbad353aa69` restores the complete prior Latin catalog
alongside the migration fallbacks. Central font tests and license checks passed;
publication is pending approval. Managed run `egui037-figcraft-latin-ci-20261025-0711`
passed with required fonts: five targeted build-input tests plus full CI, including
WASM and plugin builds (3,257 passing executions, zero failures, nine ignored across
72 summaries; repeated tests are counted). This supersedes the optional-font-only
qualification below for the restored catalog.
## Current local qualification — 2026-10-25
Latest combined-source qualification includes main `2a3b7d4fa78e67ecdc7bdfe8b4781e50e5b2612e`, preserving its text-on-path, style-variable, export and Dev Mode changes. Managed full CI `egui037-figcraft-main2a-ci-20261025-0745` passed with required fonts: 3,297 passing executions, zero failures and nine ignored across 71 summaries, including WASM and plugin builds. Fresh independent review accepted the integration. The capture/IME/field-save refactor addresses the prior Sonar complexity findings; remote analysis still needs the approved update. Native/browser, platform IME and paired performance gaps below remain open.
### Earlier review-fix qualification
The published refresh preserves main `9f788753` and passed managed full CI
`egui037-figcraft-main-refresh-ci-20261025-0618`: 3,247 passing executions, zero
failures and nine ignored. The 3,242 counts below describe the earlier `10702311`
qualification, before five additional main-branch tests. These are execution counts,
not distinct regressions. The release font input is under review separately; a
passing optional-font host build does not qualify the required web font catalog.
### Earlier qualification at `10702311`
The migration preserves captured main `10702311f345393ee1d8868c3257679befcfa14d`
(53 commits and 114 paths beyond the first integration). The final review commit records
both the migration history and that upstream target as parents. The selected egui revision is
`6d518fc799681571963842f95b55a6b68431b15a`, winit is
`a517b3846ee8457d84ee4975110bfda6797c44dc`, and craft-window is
`25da1314d443171b4adeaad2cd9454b273f35c6f`. Cargo.lock SHA-256 is
`7ba8af4de70ba657a652266ddee2920bfc501c851520a49ac60c8044a5c23cc1`.
The normal UI enables `default_fonts` and `monochrome_emoji_fonts`.
Managed full CI `egui037-figcraft-final-ci-20261024-2312` passed with bundled fonts.
Managed full CI `egui037-figcraft-system-font-ci-20261025-0402` also passed without
bundled craft-fonts, using a licensed DejaVu Sans system-font fixture via XDG_DATA_HOME.
The final qualification run `egui037-figcraft-final-qualification-ci-20261025-0453`
repeated that configuration successfully after the documentation reconciliation.
Both runs completed formatting, strict lint, workspace tests, doctests, translations,
layering, assets, WASM checks, and plugin fmt/lint/tests/release-WASM builds. Each log
contains 3,242 passing executions, zero failures and nine ignored across 70 summaries;
this includes repeated translation checks and is not a count of distinct regressions.
The main doctest stage selects zero tests. Three plugin bundles were hash-verified and
preserved for each configuration.
The no-bundled-font run includes two test fixture corrections: text-fit cases explicitly
scan installed fonts, and a concurrent collaboration test explicitly selects its foreground
document after both joins complete instead of depending on network completion order.
Independent review accepted these changes with the original layout, synchronization and
isolation assertions intact. All 17 field-save regressions remain covered, including
owner-bound deferred saves, same-pass document replacement and later valid edits/saves.
Incoming IME interruption, screenshot geometry admission, and document/window ownership
are implemented. Historical notes below describe the earlier planning stages.
The final review packet binds source, lock, logs, plugin outputs and commit ancestry.
Native visible GUI/browser workflows, native/custom-editor IME, paired performance,
current-source platform runtime and actual release installation/signing remain open.
The system-font result is not a claim that missing document fonts are interchangeable.
Human review and human merge are required; no publication is authorized by this document.
## What this stage changes
- Dependency family at 0.37 (`egui`, `egui_extras`, `eframe`, the app's `clipboard` feature), with
`ui-egui` enabling the production `default_fonts` fallback and tests explicitly enabling
`default_fonts` + `monochrome_emoji_fonts` (kittest's own dev-features do not propagate to us).
- Mechanical API ports: `Id::new` → `Id::unique`, `Ui::id` → `Ui::scope_id`,
`WidgetType` → `Role`, `MouseWheel::source`, `Shape::Band`, `ScrollArea::clamped_offset`.
- Lifecycle split: `FigApp::background_tick` (logic-safe work: control queues, live polling,
background completions, autosave/recovery deadlines) and `FigApp::ui_setup` (fonts/language/
theme/UI zoom/render worker, once per real frame). Direct `run_ui` harnesses tick the
background once in `FigApp::ui` and clear the marker afterwards, so every direct frame ticks
instead of every other one; `App::logic` ticks it for native/web. Child viewports do not repeat
the root's background work, but each runs `FigApp::viewport_setup` in its own frame: theme and
`ui.dark`, language, fonts (including CJK faces for its own document) and the zoom plan.
- A monotonic `FrameClock` (`web_time::Instant`) drives autosave, auto-save and capture deadlines.
Tests inject it (`set_test_now`); UI-time (`ctx.input(i.time)`) is not used for deadlines.
- Local capture-slot adapter in `crates/ui-egui/src/control.rs` (no `craft-ui` dependency):
weak one-shot callback, per-request identity/serial, deadline/cancel, document + viewport
generation, capture-time geometry epoch (size/DPI/zoom only, not window position), retry on
geometry changes, fail-closed completion when current geometry is unrepresentable, error on
document changes, straight-RGBA PNG reply, and the 4-request / 128 MiB-pixels /
256 MiB-aggregate budget counted over in-flight and completed-but-undrained images. The budget
reserves the larger of the encoder phase (readback + straight-RGBA copy + PNG) and the reply
phase (readback + PNG + base64); the encoder copy is dropped before base64 is built, and a
post-encode reply cap refuses an oversized PNG before the base64 string is allocated. The
request's document and window are fixed at admission and never rewritten at issuance, so a tab
switch while settling is an error rather than the new tab's pixels. Admission and issuance happen
in the UI phase. A hidden request, or one whose platform visibility is not reported, is parked
with its deadline and asks for `RequestPaintWhileHidden`; a completed slot asks again for the
real pass that drains it instead of waiting for the deadline. Settlement counts completed UI
frames, so a discarded sizing pass never serves as a settled capture frame.
- Accessibility: the custom panel edges (`Id::unique(("panel_edge", …))`) no longer sense focus;
`a11y::begin` records the root `Ui::scope_id` so a real `panel.__resize` ID is derivable if a panel
ever becomes resizable. Preferences (`prefs.panel_widths`) and fixed geometry are unchanged.
- Synthetic input from the control channel: one pending batch per window, tagged with the
document it was injected for (dropped if the active file changed), queued in the live pane of the
window drawing now (a child window's batch is not written into the swapped-out default pane),
applied exactly once per raw-input frame (shortcuts return on a discarded pass's rerun instead of
inserting or running again), acknowledged only from the pass that presents it, and fenced from the
user's system clipboard in one place: `FigApp::clipboard_set_text`/`clipboard_set_png` drop every
copy path's write (copy/cut, copy-as-CSS/SVG/PNG/text/link, property/code/comment/join-code
copies) while the frame's input is synthetic. A synthetic `Cut` still deletes the text-edit
selection in the document; only its OS clipboard write is fenced. Acknowledgements belong to the
window whose batch they acknowledge, so an empty child frame cannot error the root's. Focus-epoch fencing for
text/IME batches remains part of the E21b nextstep.
- Incoming IME byte deletion is implemented for the custom text editor
(`figcraft_engine::textedit::delete_surrounding_bytes`, wired from `ImeEvent::DeleteSurroundingBytes`)
and tested for multibyte CJK, a supplementary emoji, deletion-only input and one undo step.
Partial characters are preserved (a byte count ending inside one deletes only the whole
characters it covers, matching the fork and egui's `surrounding_bytes_to_chars`). The selection
itself is kept, as in stock `TextEdit`, so the paired `Commit` replaces it; only the complete
characters before/after the selection go. A delete that finds nothing is a true no-op: it keeps
an in-progress composition (pre-edit), the revision and the document untouched. The older
character-counted `DeleteSurrounding` event follows the same rules and has an app-level test.
## Outgoing IME surrounding text (E21b/P7b)
Implemented for FigCraft's one custom IME producer, `shortcuts::ime_output`, against the reviewed
fork API. The descriptor now carries:
- a stable owner `(document uid, text node)` and the engine's `TextEdit::editing_session` as the
replacement epoch (a document switch or another node changes the owner; re-entering the same
node starts a new generation without reusing the old one);
- the tracker's viewport-scoped `context_generation`, observed exactly once per viewport pass
after the pass's IME events and engine commands. `FigApp::observe_ime` is the single call site
(editor passes, the home/present early returns and the first-font frame all pass through it);
an idle `observe(None)` followed by a focused one in the same pass would drop receipts and bump
the generation, so the early shortcut/palette idles were removed. A late `window.new` that moves
the edited file to another window makes the old viewport observe idle for that pass;
- a bounded (≤ 4000 UTF-8 bytes) committed-text snapshot from
`figcraft_engine::textedit::ime_surrounding`: logical model text, never `bidi::display`, with the
UI-only pre-edit excluded, character caret/anchor converted independently to UTF-8 byte offsets
(reversed selections stay reversed), and an explicit `Unavailable` reason when no edit/text node
exists or the selection alone exceeds the budget. Empty text is `Available("",0,0)`.
- explicit change cause: `InputMethod` only for an accepted `Commit`/`DeleteSurrounding*` receipt
captured immediately around that mutation (delete+commit chains are one receipt chain); plain
typing, paste, keys, text shortcuts, undo, control-channel and AutoCorrect/list AutoFormat
changes report `Other` and dominate the frame. No-op, failed and stale receipts earn nothing.
When the bounded snapshot is unavailable (for example a selection wider than 4000 bytes), both
the pass observation baseline and the pre/post receipt stamps carry the engine's document
revision, so an accepted commit that replaces that selection still earns `InputMethod`.
- `geometry` remains the actual caret (the composition cursor while composing).
A hidden/unfocused/no-editor pass observes `None`, and closing a child window calls
`end_viewport` (now covered by a windows regression). The outgoing text is copied only as the
bounded window; the query never clones the document. Converting the caret/anchor character
indices to UTF-8 bytes now takes one character walk (both offsets) instead of two or three full
scans. Remaining cost: the caret is a character index, so a caret near the end of a huge layer is
still O(n) per query; `ime_stamp` runs the query before and after each text-affecting event, so
such a layer pays O(caret/anchor) per event and at most 4000 copied bytes. The generic edit path
also still counts characters once to clamp the caret and clones the pre-edit document for
normalisation/undo.
Tests: engine (`ime_surrounding` Unicode/reversed selection, pre-edit exclusion, empty/bounded/
too-wide selection, stable owner and session replacement, reverse-selection byte deletion with a
preserved selection, character-counted deletion, no-op composition preservation, list AutoFormat
and normalisation classification, double-click epoch stability) and UI (`ime_tests.rs`: commit vs
typing/mixed causes, transformed commit cause, bare-newline/control no-ops, byte and character
delete+commit, pre-edit, logical UTF-8, empty vs unavailable, owner replacement, double-click
inside the edit, one observation per frame, late `window.new`, discarded pass, two viewports).
These are source tests only; the parent runs the managed build/test commands.
## Web hidden paint
`RequestPaintWhileHidden` is honoured by native eframe but not by the shared web runner, which logs
an unhandled command for hidden tabs. Hidden-browser-tab `ui.screenshot`/`ui.input` therefore remain
unqualified even though the native path now queues and settles them. This is a forkfix prerequisite,
not a passing claim.
## Dependency and lock prerequisites
- The committed root `[patch.crates-io]` table pins the egui family and winit to
immutable published fork revisions. `craft-window` is pinned to the reviewed
craft-libs revision targeting egui 0.37; its optional `egui` feature stays disabled
because FigCraft needs only the frame probe.
- `Cargo.lock` is committed and resolves the canonical Git sources without local
path overrides. Use `--locked`; the earlier parent integration config is not
required to reproduce this graph.
- Fonts remain an optional central `CRAFT_FONTS_DIR` input with native system-font
fallback; no font binaries are added here. Required release-font qualification
is separate from optional-font host CI. Color emoji still needs a separate
`epaint/color_fonts` feature check.
## Qualification gaps and baseline backports/perf operations
Synthetic `ui.input` for deferred child viewports still relies on the existing per-pane queue and
`windows::take_input`; eframe invokes `App::raw_input_hook` for the root update, so injected input
into a child window is not newly qualified here.
Surface generation is not exposed by the pinned eframe/egui API, so the capture epoch covers
logical content size, DPI and zoom but cannot distinguish a same-size surface loss/recreation; that
remains a qualification gap (reported by the sibling capture review).
Remaining lifecycle item from the plan, not changed in this stage: prototype/after-delay/animated-GIF
clocks still advance from `present::show` and the GIF drawing path (UI only). They should move to a
non-drawing step called from `background_tick` so hidden playback advances once per deadline and the
visible pass never repeats it.
Unverified here: compile/tests/clippy/fmt, native X11/Wayland/Windows/macOS, browser WebGPU/WebGL2,
screen readers, hidden-window forced paint, PRIMARY/W15P, and all performance numbers. Baseline
operations to measure once the parent can build: `#8731` text tessellation, `#8736` DX12 pipeline
startup, `#8512` font discovery, `#8763`/PR `#8764` TextEdit click-away, `#8748` hidden deferred
children. None of these were applied, removed or claimed as fixed.
## Required web font catalog
Native optional-font builds retain the system-font policy. A required web build must
embed Inter Regular and SemiBold plus Noto Sans Georgian Regular after filtering;
missing required face/style coverage fails the build, regardless of which manifest supplies it. Required mode
also rejects an unset `CRAFT_FONTS_DIR`. The migration's original `b076417` catalog
lacks Inter and is not a qualified required-web input. A combined central font pin restores the prior Latin catalog while preserving the new
core fallbacks. Source Sans 3 and Source Serif 4 are excluded on every target; no font binary belongs in this repository. Required-web, required-native
and optional-font configurations remain separate validation gates.
File diff suppressed because it is too large Load Diff
+19 -11
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (`.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -18,9 +18,10 @@ and prototype interactions are still unchecked there.
| Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours |
|---|---|---|---|---|---|
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) |
| `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — |
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
## Import (place / paste / drop)
@@ -49,9 +50,9 @@ export limit, not a native document nesting limit.
| Format | Figma | FigCraft | Notes | Hours |
|---|---|---|---|---|
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them; sides round up to whole pixels | — |
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; ignore overlapping layers (on by default; off: every layer showing in the area) and include bounding box (the layer's own box; the Export section offers it for text layers, and `file.export` and `export.update` take it for any layer); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with ignore overlapping layers on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels | — |
| JPG | yes | yes | white where transparent; fixed quality (90) | — |
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `<text>` with one `<tspan>` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 |
| WebP | — | yes (ours) | | — |
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
@@ -115,17 +116,17 @@ each.
properties, arc fields, font weight: written as the values they give), text styles on part of a
text (written as values), variables' publishing, code syntax and inheritance settings, extended
collections (written as collections of their own), comments, Dev Mode annotations, statuses,
resources and code links, plugin data, ruler
resources, measurements and code links, plugin data, ruler
guides, prototype device settings, slot properties and slot content, brush
strokes (written plain), strokes wider on one side of their path (written as filled outlines, or
with the width on both sides when the shape has a fill too), pattern fills, WebP export settings (written as PNG),
image adjustments, glass settings other than frost, springs other than Gentle (written as ease
out), set-variable, set-variable-mode and conditional actions, scroll-to offsets, text decoration
looks, variable font axes, text on a path, nested list levels, list spacing, hanging punctuation,
leading trim, OpenType features, per-layer operations inside boolean groups, grid tracks of fixed
or content size, different stroke ends on lines. Not written at all: stroke outlines of dashed and
per-side strokes and of inside or outside strokes on polygons, stars and vectors, glyph outlines
of text (a reader lays text out with its own fonts), version history and branches.
leading trim, OpenType features, per-layer operations inside boolean groups, different stroke ends
on lines. Not written at all: stroke outlines of dashed and per-side strokes and of inside or
outside strokes on polygons, stars and vectors, glyph outlines of text (a reader lays text out
with its own fonts), version history and branches.
- **Checked by opening written files in other software:** the example documents and a small test
file open there, and layers, fills, gradients, strokes, shadows, vectors, components and text draw
as in FigCraft. A file saved again there and read back keeps the layer tree and properties; two
@@ -159,12 +160,19 @@ each.
| Local fonts | yes (font helper / desktop app) | yes (system scan) |
| Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) |
| Variable fonts | yes | yes (axes, named instances, per-range axis values) |
| Missing-font replacement on open | yes | warnings only (REST import) |
| Missing-font replacement on open | yes | listed, not replaced: in the desktop app the Missing fonts window opens after a file that uses them opens and from Text ▸ Missing fonts (`text.missingFonts`; `figcraft-cli info` prints `missing font` lines), and their text draws in a fallback font |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | patch | PNG: the export settings were listed as "contents only" and "include bounding box (the layer's own box)"; the row now names ignore overlapping layers, on by default, and says that the Export section offers include bounding box for text layers (#1307), while `file.export` and `export.update` take it for any layer. Numbers unchanged |
| 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged |
| 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before |
| 2026-10-24 | minor | Boards (M12): `.figcraft` saves the file kind (`kind: "board"`, absent for design files) and each board object's `board` metadata; the `.jam` row points at boards |
| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Sketch 2025 and newer: frames, graphics, stacks as auto layout, corners, pins and sizing, tints, per-side borders, blur lists. Checked on fixtures and locally on real Sketch 50, 64 and 2026 files and the IBM Carbon Design Kit (Sketch 68 and 83 open; the Sketch 47 edition is refused). Numbers unchanged |
| 2026-10-15 | minor | `.fig` import (#279, #281; #266, #267): resized instances lay out their layers by their constraints, and explicit variable modes whose collection is named by its asset key come across (before, they were dropped and bound layers resolved in the default mode); fixture-tested, real files still owed. `.figcraft`: atomic saves and exports through unique staging files (#284), optional auto-save (#305). SVG export keeps a nested frame's fill and stroke inside its parent's clip (#327). Numbers unchanged |
| 2026-10-20 | minor | `.fig` export writes grid track sizes (`gridColumnsSizing` / `gridRowsSizing`): fixed px, fractional fill and content-sized tracks keep their sizes and come back through our own importer (#465). Tracks whose size the format can't hold (a negative or out-of-range value) keep their id and read back equal, with a warning. Numbers unchanged |
| 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged |
| 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged |
| 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged |
+109 -25
View File
File diff suppressed because one or more lines are too long
+7 -4
View File
@@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -12,13 +12,13 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu
| Feature | Figma | FigCraft (macOS / Windows / Linux / web) | Hours |
|---|---|---|---|
| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
| Effects (blurs, glass, noise) | GPU | CPU | in the above |
| HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — |
| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 |
| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.37 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 |
| Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — |
| Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 |
| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window 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. Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 |
| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window 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; a minimised or covered window still answers control and MCP requests, while screenshots with hidden or unknown visibility request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame (#313, #332, #720). Browser and Wayland hidden-paint behavior remain unqualified. Only the main window has a screen-reader tree (eframe 0.37), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 |
| Screen readers | partial | AccessKit tree on desktop (names, roles, states), never tested with a real screen reader; none in the browser | 4–8 + human QA |
| Keyboard layouts (non-US) | yes | keymap uses logical keys; not checked on AZERTY/QWERTZ/JIS | 1–2 |
@@ -50,6 +50,9 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | egui/eframe 0.37 dependency migration: the workspace pins the egui family at 0.37, and the wide-gamut and several-windows rows now cite 0.37. The limitations are unchanged: egui-wgpu 0.37 offers no surface colour-space option, so the screen still shows sRGB; eframe 0.37 still gives only the main window an AccessKit adapter, so files stay tabs while a screen reader runs. Numbers unchanged |
| 2026-10-25 | minor | Hidden or unknown-visibility screenshots request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame. Browser and Wayland hidden-paint behavior remain unqualified. Numbers unchanged. |
| 2026-10-15 | minor | Community fixes (#290, #306, #312, #313, #328, #332, #346, #474): coarse zoom previews when renders from scratch are slow; a minimised or covered window answers control and MCP requests (screenshots and input fail at once while it is hidden); `.figcraft` files open from Finder on macOS; a message box when the window can't open on Windows; on Linux the window id matches the desktop entry, the RPM requires glibc 2.35 and user fonts under `XDG_DATA_HOME` are found. Numbers unchanged |
| 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged |
| 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged |
| 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. |
Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

+38 -31
View File
@@ -1,10 +1,10 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts (machine-drafted,
Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Czech drafts (machine-drafted,
waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key
languages of the craftrules standard, then the other languages Figma ships. How to add a
language: [`translating.md`](translating.md).
@@ -18,26 +18,27 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6
Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is
supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text.
## What FigCraft has (measured 2026-10-17, from source)
## What FigCraft has (measured 2026-10-22, from source)
- **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by
the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text
(`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/few/many/other plural forms), so
a missing translation shows English, never a raw key. One English text that needs two
translations takes a context (`"Fill|sizing"`).
- **Strings:** 1,680 UI keys in `en.toml` (41 with plural forms; ~110 are labels from engine and
document data, such as tool names, blend modes and Dev Mode properties) plus 561 command labels
and 31 menu wordings by command id: 2,272 entries (counted against `en.toml` and the complete
- **Strings:** 1,747 UI keys in `en.toml` (42 with plural forms; ~110 are labels from engine and
document data, such as tool names, blend modes and Dev Mode properties) plus 574 command labels
and 31 menu wordings by command id: 2,352 entries (counted against `en.toml` and the complete
Spanish catalog's command and menu tables). Command ids, the CLI, MCP and the control
channel stay English. Accessibility names come from the same strings.
- **Language preference:** Preferences ▸ Language (System, then each catalog by its own name:
English, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語,
한국어, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a
한국어, Português (Brasil), Русский, Svenska, Türkçe, Tiếng Việt, 简体中文), applied at once without a
restart; System takes the first of the OS's preferred languages that has a catalog (on Windows
only the first preferred language counts, since that list also holds the languages added for a
keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional
tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the
other Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans,
other Latin American and US Spanish tags to es-419; regional ru, tr, cs and id tags (ru-RU,
tr-TR, cs-CZ, id-ID) to their catalogs; zh, zh-CN, zh-SG and zh-Hans read zh-Hans,
while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the
next OS language, never Simplified) or the browser's language on the web.
`pref.language {language}` sets it from the CLI, MCP and the control channel.
@@ -45,15 +46,15 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
(`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the
overlay, then the base language, then English.
- **Check:** `cargo test -p figcraft-ui-egui i18n` fails on keys the code uses that `en.toml`
lacks, keys no code uses, unknown command ids, placeholders that differ from English, overlay
entries that read as in their base language and, for complete languages (Spanish), anything
untranslated. The other catalogs are not held to it: a string added later shows English there
until someone translates it. Every draft translated every string on 2026-10-12; by 2026-10-17
the strings added since left them at 95–99% (table below).
lacks, keys no code uses, unknown command ids, placeholders that differ from English and overlay
entries that read as in their base language. No language is held to full coverage: a string
added later shows English until someone translates it, and `cargo xtask i18n-report` (part of
`cargo xtask ci`) lists what each language lacks. Every draft translated every string on
2026-10-12; by 2026-10-17 the strings added since left them at 95–99% (table below).
- **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values
such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules
(`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian
Portuguese take the singular for 0 as well; Japanese, Korean and Chinese have one form;
Portuguese take the singular for 0 as well; Japanese, Korean, Chinese and Vietnamese have one form;
CLDR's "many" for millions in French, Spanish and Portuguese reads as the plural).
- **Script support in documents:** Unicode bidi with Arabic shaping and mirrored brackets in text
layers; right-to-left list markers and indents; right-to-left names in the layers panel, tabs
@@ -63,7 +64,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
- **Script support in the UI:** menus widen to their longest label; dropdowns, the two-column
labels of the right panel and the home screen's cards keep a long translation on one line with an
ellipsis and show it in full on hover (checked in German, the longest); no mirrored
(right-to-left) layout of panels. CJK translations are shorter than English; the same ellipsis
(right-to-left) layout of panels. Cyrillic, Turkish and Czech letters draw from the UI fonts
(no font added; Russian checked in screenshots). CJK translations are shorter than English; the same ellipsis
and tooltip cover the few that don't fit (the sizing dropdowns).
- **CJK UI fonts:** egui's fonts and Inter have no CJK glyphs. Once the UI language, the open
file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han,
@@ -85,23 +87,24 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full |
|---|---|---|---|---|---|---|---|
| English | en | 2,272 / 2,272 (100%, source) | yes | Latin | yes (source) | full | 0 |
| Simplified Chinese (Mandarin) | zh-Hans | 2,166 / 2,272 (95.3%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) |
| Spanish | es, es-419 | 2,272 / 2,272 (100%, draft); es-419 overlays 101 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) |
| English | en | 2,352 / 2,352 (100%, source) | yes | Latin | yes (source) | full | 0 |
| Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) |
| Spanish | es, es-419 | 2,352 / 2,352 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) |
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 |
| Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 |
| French | fr | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Portuguese | pt-BR | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Indonesian | id | 2,244 / 2,272 (98.8%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Japanese | ja | 2,254 / 2,272 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
| German | de | 2,176 / 2,272 (95.8%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) |
| Korean | ko | 2,166 / 2,272 (95.3%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
| Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 |
| French | fr | 2,328 / 2,352 (99.0%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Indonesian | id | 2,292 / 2,352 (97.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
| Japanese | ja | 2,304 / 2,352 (98.0%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
| German | de | 2,226 / 2,352 (94.6%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) |
| Korean | ko | 2,216 / 2,352 (94.2%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
| Vietnamese | vi | 2,352 / 2,352 (100%, draft) | yes | Latin with stacked diacritics | no | draft | 1–2 (after native review) |
| Thai | th | 1,791 / 1,791 UI strings, 589 of 598 command labels, 31 menu wordings (draft) | yes | Thai text in documents; UI text (and layer and page names in Thai, whatever the UI language) in an installed Thai face, desktop only; the web build leaves Thai out | no | draft | 1–2 (after native review) |
Other languages (measured 2026-10-17): Russian (`ru`) 2,244 / 2,272 (98.8%), reviewed and approved
by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,264 (99.6%), its command labels and menu
wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,243 (98.7%), whose header
says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,244 (98.8%), a draft
Other languages (measured 2026-10-22): Russian (`ru`) 2,352 / 2,352 (100%), reviewed and approved
by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,314 / 2,352 (98.4%), its command labels and menu
wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,291 / 2,352 (97.4%), whose header
says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,292 / 2,352 (97.4%), a draft
with one/few/other plurals. Figma ships none beyond the eight above.
The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries
@@ -113,13 +116,17 @@ right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h**
every string adds).
Priority: native reviews of the drafts and the es-419 overlay (every one of Figma's languages
and variants has one, plus Simplified Chinese, Indonesian and Czech, which Figma doesn't ship), then
Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users see English).
and variants has one, plus Simplified Chinese, Indonesian, Vietnamese and Czech, which Figma doesn't ship), then
Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see English).
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Thai (`th`) draft catalog: `th.toml` translates every UI string (1,791), 589 command labels and the 31 menu wordings, machine-drafted and marked as a draft awaiting native review (9 newer command labels read English, as in the other drafts). The desktop UI now adds an installed Thai face (Noto Sans Thai, Leelawadee UI, Tahoma, Sukhumvit Set, Thonburi, Ayuthaya, Sarabun, Loma, Garuda or Waree) when a layer or page name or the UI language needs it, so Thai names no longer show as tofu squares. No plural forms for Thai. Grade and hours unchanged |
| 2026-10-24 | minor | Vietnamese (`vi`) draft catalog (#1026), assessed as a machine draft awaiting review like the others; string counts re-measured: 2,352 entries. |
| 2026-10-23 | minor | A missing translation fails no check: Spanish is no longer held to full coverage, and the tests of one language's wording, names and plural forms are gone (the plural rules and language-tag tests stay). `cargo xtask i18n-report`, run by `cargo xtask ci`, lists each language's missing UI strings, command labels and menu wordings. Grade unchanged |
| 2026-10-15 | minor | Community catalogs (#257, #263, #469, #477, #288): Russian (`ru`, Русский, reviewed and approved by VanTan) and Turkish (`tr`, Türkçe, reviewed by a native speaker), Czech (`cs`, Čeština) and Indonesian (`id`, Bahasa Indonesia) drafts, and the Japanese draft caught up; Preferences ▸ Language lists them and System maps their regional tags. Catalogs may give few and many plural forms; Russian picks one, few or many by its integer rule (11–14 take many), the catalog supplying the text choosing the rule. Only Spanish is held to completeness. Counts re-measured: 1,587 UI keys (38 plural), 559 command labels and 31 menu wordings, 2,177 entries; Spanish 100%, Japanese 2,176, Czech and Indonesian 2,166, Russian and Turkish 2,165, the five older drafts 2,088–2,089 (95%). Localization 39% and 29–51 h unchanged at this rounding |
| 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac |
| 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) |
| 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog |
+330 -50
View File
@@ -34,6 +34,10 @@ of the window itself (`screenshot` with `window`) needs it on screen: it is then
### Confining file access
The roots below belong to the **headless** side: the session `figcraft-cli mcp` runs itself, with
no window. A server attached with `--attach` runs the commands inside the app instead, so the app
confines them — from the flags it was started with, not from anything passed to the CLI.
```sh
figcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out
```
@@ -144,6 +148,8 @@ Or in a project's `.mcp.json`:
there is none, so they are page coordinates then). Groups and boolean groups have no coordinate
space of their own: a layer in a group is placed in the frame (or on the page) the group is in.
`inspect_document` reports `x`/`y` the same way, plus `absoluteBounds` in page coordinates.
In `props`, `fill` and `stroke` also accept `"none"` (either case) or `null` as no paint, the
same as an empty `fills`/`strokes` list, so an outline-only shape can be made in one call.
- Frames, groups, sections and masks: drawing a frame or section with the `pointer` tool around
layers puts those fully inside it in it; sections go only on the page or in sections (moves,
drags, pastes and new sections elsewhere are refused or land on the page); resizing a section
@@ -190,6 +196,12 @@ Or in a project's `.mcp.json`:
the CSS `fr` unit: a track never gets less than its content (a fixed-size child bigger than
its share keeps it; the other `fr` tracks share the rest). `inspect` reports a grid child's
resolved `gridCell`.
- Layer order: `node.reparent {ids?, parent, index?}` moves layers into `parent` (keeping their place
on the canvas, or taking their place in its auto layout flow) and `node.reorder {id, index}` moves
one within its parent. `index` counts the parent's children bottom first (in auto layout, the
first in the flow) and is where the (first) moved layer ends up after the move, in the same parent
or a new one; several layers land together in their stacking order. No `index`, or one past the
end, puts them on top (last in the flow).
- **Every menu action is a command** (`list_commands`), and most commands act on `ids` when given,
else on the selection. New layers become the selection.
- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills and strokes, `color` may also be a hex
@@ -267,9 +279,11 @@ Or in a project's `.mcp.json`:
- **Styles stay true to their layers**: editing a styled fill, stroke, effect or layout grid list
(`node.set`, `fill.set`, `fill.add`, a bound variable…) unlinks that style (`styles.fill` etc. go
`null`), so a later `style.update` doesn't overwrite the edit; `style.apply` replaces variables
bound to what the style sets; a text style goes on text layers only. Names are trimmed per `/`
folder part. `style.move {styles, before?, folder?}` reorders (and with `folder`, renames into it),
`style.duplicate {style}` adds "<name> copy" right below, `style.renameFolder {folder, name}`.
bound to what the style sets; a text style goes on text layers only. In one call, a style link
(`fillStyle`, `textStyle`…) wins over values it sets; set a deviation in a later call, which
unlinks it. Names are trimmed per `/` folder part. `style.move {styles, before?, folder?}`
reorders (and with `folder`, renames into it), `style.duplicate {style}` adds "<name> copy" right
below, `style.renameFolder {folder, name}`.
- **Variables**: `variable.bind` refuses a variable of a type the field doesn't take (a colour on
`width`); scopes only filter pickers. `variable.delete` / `variable.deleteCollection` unbind the
layers using them (they keep their last value) and give aliases to them their values. An
@@ -294,16 +308,16 @@ Or in a project's `.mcp.json`:
| Tool | What it does |
|---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: `{"$step": n}` anywhere in its params becomes the `id` in step n's result (zero-based), and `{"$step": n, "field": "…"}` another field of it (see Step references below). |
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose or ArkUI for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). |
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
| `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. |
| `select {ids}` | Replace the selection (`[]` deselects). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps `<text>`, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `<layer><suffix>.<format>`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). |
| `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. |
| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. |
| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. |
| `undo` / `redo` | Step through history. |
| `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). |
| `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity, arrows that pan the view when nothing is selected; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. |
@@ -330,6 +344,137 @@ tips; headless, it only sets the preference.
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"export","arguments":{"format":"png","path":"dashboard.png"}}}
```
### Filling copies with data
`data.fill {id, rows, gap?, formats?, dir?, format?, scale?}` repeats a frame once per row (a carousel, a set
of posts, a card per item) in one undo step: each key of a row names layers in the copy, and a
string (or number) sets their text, `{text?, fill?, visible?}` sets more; `name` names the copy
(default "<frame> <n>"). Copies go in a row to the right, `gap` apart (default 100). With `dir`, each
copy is exported there as `<name>.<format>` (png by default) after the edits land. Returns `{ids,
missing (keys no layer is named), files?, errors?}`. `errors` lists the exports that failed as
`[{id, path, error}]`; the copies stay, and the files that were written are in `files`.
Copy that changes length per row can size itself: `{"Headline": {"text": "…", "fit": {"maxLines": 2}}}` runs
`text.fit` on those layers after the text is set.
`formats: [{name?, width, height}]` makes every row once per size: the copy is resized so its auto layout
reflows, named `<name>-<format name>` (default `<width>x<height>`), and placed under the row's previous
format. Text `fit` runs after the resize. Use case: one call turns a 1080×1350 post into feed, story
(1080×1920) and square (1080×1080) files for each slide of a carousel; the frame must be auto layout (fixed-
position children keep their place).
`text.fit {ids?, maxLines?, minSize?, orphans?}` gives a text layer with a fixed width (auto height or fixed size)
the largest font size, down to `minSize` (default 60% of the current), at which it stays within `maxLines` lines
and its box's height. Every style run scales by the same factor. A one-word last line (an orphan) is avoided by
shrinking up to 8% more, else by keeping the last two words together with a no-break space. A one-word paragraph after a hard line break is not an orphan. Text never
grows, text that already fits keeps its exact size, and sizes round to a tenth without going below `minSize`.
Returns `{fitted: [{id, fontSize, lines, fits, orphan}]}`. Use case: a carousel slide "Lighthouse 100. Loads in under
1s." at 96px broke into three lines with "1s." alone; with `fit: {maxLines: 3}` it is two balanced lines.
```json
{"command": "data.fill", "params": {"id": 80, "dir": "out/carousel", "scale": 1, "rows": [
{"name": "01-cover", "Headline": "Your website and AI chat assistant in 7 days", "Badge": {"visible": true}},
{"name": "02-proof", "Headline": "Lighthouse 100, loads in under a second", "Badge": {"visible": false}}
]}}
```
### Boards
A board is a file of its own kind for thinking together: `file.newBoard {name?}` opens one (its
page is light; `inspect_document` says `kind: "board"`). Board objects are ordinary layers with a
`board` entry `get_node` shows (`{type: sticky|shape|connector|label|stamp|ink, …}`), so the usual
commands move, resize, recolour, copy, delete and undo them. On a design file the board commands
refuse; on a board `proto.*`, `dev.*`, `view.prototype` and `view.devMode` refuse. Each of these is
one undo step and returns the ids it made:
- `board.sticky {x, y, text?, color?, author?, parent?}` → `{id, text}`: a sticky note (a square
frame whose height grows with its note; its text size shrinks as the note gets longer). `color`
is a board colour (`yellow orange red pink violet blue green gray`) or a hex colour; `author`
defaults to your name (`pref.userName`, or the one a live session gave you; `false` for none).
- `board.nextSticky {id?, side?: right|below|left|above, edit?}` → `{id, text}`: a sticky of the
same colour beside the selected (or edited) one; with `edit: true` the app goes on typing in it
(`Shift+Cmd/Ctrl+Enter` in the app does both).
- `board.shape {shape, x, y, width?, height?, text?, color?, parent?}` → `{id, text}`: a shape with centred,
wrapping text: `rectangle roundedRectangle ellipse diamond triangle speechBubble chevron`.
- `board.connector {from, to, fromSide?, toSide?, style?: straight|elbow|curved, startCap?, endCap?, label?}`
→ `{id, label?}`: `from`/`to` are layer ids or `{x, y}` page points; sides are `auto` (the side
facing the other end) or `top right bottom left`; caps are stroke caps (default an arrow at the
end). An elbow goes around the objects it joins when the short way would cross them; a curved
one leaves and arrives along the sides. The connector re-routes itself whenever what it joins
moves or resizes, also when someone else in a live session moves it; a deleted end stays where it
was as a point. `board.label {id, text}` sets its label (empty removes it).
- `board.reattach {id?, end: from|to, target, side?}` → `{id}`: moves one end of a connector
(default the selected one; its label stands for it) onto `target`, a layer id or an `{x, y}`
page point where the end stays free. Refused when both ends would be on one layer or the target
is a connector. Dragging a selected connector's end handle in the app does the same.
- `board.stamp {x, y, stamp: thumbsUp|heart|plusOne|star|question|check}` → `{id}`: a round badge
centred there, with your name on it for hover.
- `board.ink {path | points, highlighter?, color?, select? (default true)}` → `{id}`: a marker (or
wide, see-through highlighter) stroke.
- `board.setColor {ids?, color}`: stickies and shapes take it as their fill, connectors and ink as
their line. `board.setStyle {ids?, shape?, fontSize?, style?: straight|elbow|curved, startCap?,
endCap?}`: a shape's outline and text size, a connector's routing and arrowheads.
- `board.templates` → `[{id, name, description, width, height}]` lists the built-in templates:
`retrospective brainstorm weeklyPlanner flowChart kanban`. `board.template {name, x?, y?}` →
`{name, ids}` places one (its top-left at `x`, `y`; centred in the view without them) as ordinary
sections, stickies, shapes, connectors and text, selected, in one undo step.
- `board.table {rows?, columns?, cells?, x?, y?}` → `{id, rows, columns, cells}`: a table (default
3 × 3, at most 50 × 20; `cells` is `[["Name", "Role"], …]` row by row and sets the size when
`rows`/`columns` are left out). It is an auto layout frame of row frames holding cell frames, each
with one text layer; `cells` in the answer holds those text layers' ids row by row, so
`node.set {id, props: {characters}}` changes a cell. A cell's row grows with its text. `board.tableEdit {id?, action: addRow|addColumn|removeRow|removeColumn,
index?}` → the same answer: `index` is where the new row or column goes, or which one goes
(default the last); `id` may be the table or any layer in it.
- Tools for `pointer`: `tool.sticky {color?}`, `tool.shape {shape?}`, `tool.connector {style?: straight|elbow|curved}`
(drag from one object to another), `tool.marker {color?}`, `tool.highlighter {color?}`,
`tool.eraser` (drag over strokes), `tool.stamp {stamp?}`; Text and Section are the usual tools.
```json
{"command": "file.newBoard", "params": {"name": "Retro"}}
{"command": "board.sticky", "params": {"x": 0, "y": 0, "text": "Went well", "color": "green"}}
{"command": "board.sticky", "params": {"x": 240, "y": 0, "text": "To improve", "color": "pink"}}
{"command": "board.connector", "params": {"from": 2, "to": 4, "style": "elbow", "label": "leads to"}}
{"command": "board.stamp", "params": {"x": 200, "y": 0, "stamp": "plusOne"}}
```
`from` and `to` are the `id`s the two `board.sticky` calls returned (here `{"id": 2, "text": 3}` and
`{"id": 4, "text": 5}` in a new board without an author name; a sticky with its author's name takes
one more id). In a `batch`, step references (`{"from": {"$step": 1}, "to": {"$step": 2}}`, the `id`s steps 1 and 2 returned) avoid counting.
Running a session on a board: a countdown and votes. Both are part of the board, so everyone in a
live session sees the same, and neither is an undo step (undo leaves them as they are). On a
design file they refuse.
- `board.timer {action: start|pause|resume|add|stop, minutes?}` → `{state, left, text, ends}`: one
countdown per board. `start` takes `minutes` (default 5) and `add` adds them (default 1), from
one second up to 1440 minutes in all; `pause` keeps the time left, `resume` counts it down again.
The timer keeps its end time (milliseconds since 1970) or, paused, the time left, and each
client counts down on its own clock. `board.timerState {}` reads it: `state` is `off`,
`running`, `paused` or `done` (it ran out: 0:00 until someone stops it), `left` milliseconds,
`text` as `m:ss` (or `h:mm:ss`).
- `board.vote {action: start|cast|retract|end|clear, …}` → the vote as `board.voteResults` gives
it. One vote at a time: `start {votes? (per person, 1–99, default 3), on?: all|stickies|selection
(default all), ids? (with selection; default the selected layers)}` (a vote that ended may be
replaced; one still running is refused), `cast {id}` puts one of your votes on an object (several
on the same object are fine; once yours are used up it refuses), `retract {id}` takes one back,
`end` shows everyone's votes, `clear` removes the vote and its results. `voter` (any action)
votes as someone else, for scripts and tests; by default you are your live-session client, or
your name (`pref.userName`) when the file isn't shared. In the app a click on an object votes
while a vote runs, and a click on your badge (or an Alt-click) takes the vote back.
- `board.voteResults {voter?}` → `{state: off|running|ended, votesPerPerson, on, by, left, mine:
[{id, votes}], voters, results?}`: while it runs you see your own votes only (`mine`, `left`) and
how many have voted; once it ends `results` lists `[{id, name, votes, rank}]`, most votes first
(ties share a rank), with `name` the sticky's text or the layer's name. Votes on deleted objects
don't count.
```json
{"command": "board.timer", "params": {"action": "start", "minutes": 10}}
{"command": "board.vote", "params": {"action": "start", "votes": 2, "on": "stickies"}}
{"command": "board.vote", "params": {"action": "cast", "id": 2}}
{"command": "board.vote", "params": {"action": "end"}}
{"command": "board.voteResults", "params": {}}
```
### Sections, handoff and arrow keys
`run_command` with `object.wrapInSection {ids?, name?}` (Wrap in new section: layers on the page or
@@ -364,6 +509,10 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
- `view.designPrototype` (`Shift+E`), `view.devMode` (`Shift+D`) and `view.leftSidebar`
(`Shift+Cmd+\`) switch the right panel's tab or hide the left sidebar in the app (no-ops headless).
- `doc.nextTab` / `doc.previousTab` (`Ctrl+Tab` / `Ctrl+Shift+Tab`): the next or previous open file.
- Board tools (boards only): `tool.sticky` (`Q`), `tool.shape` (`B`), `tool.connector` (`X`),
`tool.marker` (`M`), `tool.highlighter` (`U`), `tool.eraser` (`E`), `tool.stamp` (`W`), and
`board.nextSticky` (`Shift+Cmd/Ctrl+Enter`, also while typing in a sticky). Keys no design-file
shortcut uses; on a design file they refuse.
- `view.pan {dx, dy, big?}`: the arrow keys with nothing selected (`key ArrowRight` does the same)
pan the view by 20 screen pixels a step, 200 with `big` (Shift).
- `view.multiplayerCursors {on?}` (`Alt+Cmd+\`): show or hide others' cursors in a live session.
@@ -436,7 +585,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
- `pref.startOnHome {on?}`: the desktop app starts on the Home screen instead of in a new file
(off by default; files named on the command line still open, and `--control` starts in a new
file anyway, so control callers have a document).
- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system":
- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|vi|zh-Hans|…}`: the app's interface language, applied live ("system":
the OS or browser language; a language without a catalog shows English). Command ids, labels and
every result stay English.
@@ -448,6 +597,17 @@ its proportion). A path or point set that draws nothing is refused instead of ma
0×0 layer, as is a size on an axis the geometry has none of (except 0, such as a horizontal line's
height) and a size of 0 or less on an axis it has, which would flatten it.
`node.set {path}` (or `set_properties`) replaces a vector's geometry with new SVG path data and
keeps the layer: its id, name, place in the layer list, paints, effects, styles, variable bindings
and constraints. The path is read as `vector.create` reads it, and `width`/`height` in the same call
scale it the same way. The path's own box goes at the layer's current x/y (rotation and flips
stay); with `x`/`y` in the same call it goes at `x` + its left edge and `y` + its top, as
`vector.create` places it. Points' own settings (per-point corner radius, caps, joins) go with the
old geometry. On several layers, layers that aren't vectors are skipped with a warning; on a
single one, or when none is a vector, the call is refused (convert shapes with
`vector.edit.begin` or Flatten first). A layer inside an instance is refused too: its geometry
comes from its main component, so edit that or detach the instance. Undo brings the old path back.
Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures):
`vector.edit.begin {id? | ids?}` (default the selection; converts shapes to vectors; several layers
are edited at once, the first active; `key` Enter with shapes selected does the same),
@@ -584,7 +744,8 @@ rotation?, color?, width?, height?, responsive?}` (`None`, `iPhone 16`, `iPhone
`Black`, `White`, `Pink`, `Teal`, `Blue` for the iPhone 16 (an error lists them), `""` for the
default; `width` / `height` set the custom size's screen, 1–10000 points, and pick `Custom size`,
which is a bare screen without a body). On a device the presented frame is sized to the device's
screen: what doesn't fit is clipped, or scrolls when the frame's `overflow` allows it, while `fixed`
screen: a frame bigger than the screen scrolls through its whole size whatever its `overflow`, content
past the frame's own edges scrolls only as its `overflow` allows (else it's clipped), and `fixed`
layers stay on screen (pinned to the edges their constraints name). With `responsive: true` (saved
in the file) the presented frame is instead resized to the device's screen, or without a device to
the presenter's window at 100%, and its constraints and auto layout respond as when it is resized
@@ -597,7 +758,10 @@ device, whether a click where nothing reacts briefly flashes the screen's hotspo
interactions, on by default; also in the presenter's Options menu) and whether its Inspect panel
shows the size, colours and typography of the layer under the pointer (also the Inspect button in
its bar); the options are kept for later presentations (in the presenter Z cycles them and R
restarts). The player's state is
restarts). An Open link to an `http` or `https` address first shows a notice that the link leaves
the prototype, with Open link, Cancel and "Don't show this again"; `proto.linkNotice {show?}`
turns the notice on (the default) or off, kept with the desktop app's preferences (the web build
keeps it until the page reloads). The player's state is
in `inspect_ui` (`presenter`, `preview`: screen, scroll, `scale`, `zoom`, `pan`, the screen's
`hotspots` and whether they are flashing, the `inspected` layer) when attached, and
`ui.set {presentScroll: [x, y]}` scrolls its screen (see [control-protocol.md](control-protocol.md)).
@@ -713,6 +877,14 @@ most when it can't be reached).
moves the chosen variant there, `{name, index}` reorders variant properties (the variants'
names follow). `component.properties` on a set reports `conflicts`: variants sharing one
combination of values.
- **Boolean properties**: on the main component, a boolean property's value is the visibility of
the layers bound to it. `component.addProperty {type: "boolean", default, layer}`,
`component.bindProperty {field: "visible"}` and `component.editProperty {name, default}` show or
hide the main's bound layers to match the default (without `default`, `addProperty` takes the
bound layer's visibility). Showing or hiding a bound layer on the main sets the default, and the
property's other bound layers (in every variant of a set) follow. New instances start from the
default; existing instances keep the values they have. Files open as saved: a main whose bound
layer disagrees with the default stays that way until one of them is edited.
- `component.pushChanges {id?}` copies the instance's overrides into its main (from a layer of a
nested instance too: always into the outer instance's main).
@@ -720,7 +892,9 @@ most when it can't be reached).
- `instance.swap {ids?, component}` shows another component in the instances (overrides on layers
with matching names stay; property values with the same names too). Nested instances can be
swapped on their own (an override of the instance they're in).
swapped on their own (an override of the instance they're in); a swapped nested instance keeps
what its outer main overrode on matching layers as its own, so later edits of those in the main
don't reach it.
- `instance.swapAt {component, x, y}` swaps the instance at a page point: of the instances under
it (innermost first), the first showing a variant of the same component set, else the innermost
(never one inside the dropped component); only text layers keep their overrides. The Assets
@@ -798,6 +972,8 @@ click or drag across regions with `pointer` to merge, add `alt` to remove; `shap
- **Text on a path**: `text.onPath {text?, path?, start?, flip?}` (default: the selected text layer
and the other selected layer) puts a text layer on a vector's or shape's path: the text takes the
path layer's place, transform and box, and the path layer is removed (`edit.undo` brings it back).
The text also takes the path layer's effects when it has any, and its fills when one of them
shows, each with its style and variable bindings; otherwise the text keeps its own.
The baseline follows the path from `start` (0..1 of its length); the text's horizontal alignment
places it there (left starts, centre centres, right ends at it); `flip` runs it on the other side
the other way. Glyphs past an open path's end are hidden; a closed path (ellipse, rectangle) wraps
@@ -805,7 +981,12 @@ click or drag across regions with `pointer` to merge, add `alt` to remove; `shap
them, `text.removeFromPath {ids?}` makes it plain auto-width text again. Resizing the layer
scales the path. The text stays editable (`text.edit.*`, `type_text`); `get_node` shows
`textPath {d (SVG path data in the layer's space), start, flip}`. Exports draw it as outlines
(PDF too: not selectable); underline and strikethrough aren't drawn on a path.
(PDF too: not selectable); underline and strikethrough aren't drawn on a path. With the tool
`tool.textOnPath`, a `pointer` press within 6 screen pixels of a shape's or vector's first
subpath puts a new, empty text layer on it, starting at the press, and starts typing: the text
also takes the path layer's constraints and effects, and its fills when one of them shows. Ending
the edit with nothing typed puts the path layer back with no undo step. Locked layers and layers
inside instances are skipped.
- **Pattern fills**: `fill.pattern {source (layer id), ids?, index?, tile? (grid|horizontalHex|
verticalHex), scale?, spacing? [x, y], align? (start|center|end), opacity?}` makes the fill at
`index` (default the top one) the source layer repeated, at `scale` × its own size with `spacing`
@@ -903,7 +1084,9 @@ frame back to Auto, inheriting its parent's mode).
The same JSON carries variables between FigCraft files: `variable.export {collections?, format?,
path?}` writes this file's collections (default: all but library copies) as a local variables
response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default
mode on `:root`, each other mode on `[data-<collection>="<mode>"]`, aliases as `var(--name)`), and
mode on `:root`, each other mode on `[data-<collection>="<mode>"]`, aliases as `var(--name)`, numbers
in px unless the layers bound to them, their scopes or the variables aliasing them make them an
opacity or a font weight, so `border-radius: var(--radius)` reads `12px`), and
returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object,
JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level) reads
one back: a collection joins this file's collection of the same name
@@ -985,7 +1168,8 @@ branches). Driven with `run_command`:
- `branch.create {name?}` copies the file into a new branch tab and returns `{id, name, index}`.
The branch remembers the file as it is now (its base) and makes new ids in its own id space, so
layers, styles and comments made on both sides never clash.
layers, styles and comments made on both sides never clash. Its history starts with a `Branch
created` version.
- `branch.list` lists the branches of the file shown (or of the file a branch tab belongs to):
`[{id, name, time, state: active|merged|archived, index?}]` (`index`: its open tab).
- `branch.open {id}` opens a branch in a tab (or switches to it). Edit it like any document.
@@ -1007,15 +1191,26 @@ branches). Driven with `run_command`:
conflict key to `"branch"` or `"main"`; the merge is refused while a conflict has none, so
neither side's work is dropped without a choice.
- `branch.updateFromMain {id?, resolutions?}` brings the main file's changes since the base into the
branch the same way (the new base is the main file as it is now). It is not undoable: the branch
tab's undo history starts over.
branch the same way (the new base is the main file as it is now) and adds an `Updated from main`
version to the branch's history. It is not undoable: the branch tab's undo history starts over.
- `branch.archive {id?}` sets a branch aside without merging (it stays in the file and can still be
opened, but not merged).
Each branch keeps its own version history: on a branch tab, `file.saveVersion`, `version.autosave`
and the `version.*` commands (list, preview, restore, rename, delete, duplicate) act on the
branch's versions, which are saved in the file with the branch and stay with it when it is merged
or archived. Branches aren't available in a live session.
or archived.
In a live session branches work the same way and stay with the person who made them (others
don't see them). A branch made in the session makes its ids in your own client space, far above
the ids the main file makes, so the session server takes them. Everyone's edits on main while the
branch is open are main's changes in the review (against the branch's own base), and conflicts
with them are resolved as offline. `branch.merge` is one edit of the session document: everyone
gets it, and it is one step of your own undo history. It is refused on a view link, and for a
branch made outside the session (or in an earlier one) whose new layers, styles, variables or
comments lie in another id space, which the server would refuse; merge those once the file has
left the session. While a session is still starting, branch commands that need your client id
wait for it.
### Collaboration changes
@@ -1246,17 +1441,24 @@ Plugin settings are kept per plugin with the preferences (in a headless session,
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.event","params":{"id":"org.figcraft.examples.swatch-grid","control":"generate"}}}}
```
Widgets (API 3, layers on the canvas a plugin draws) are driven the same way: `widget.insert
Widgets (API 3 and 4, layers on the canvas a plugin draws) are driven the same way: `widget.insert
{plugin, x?, y?}` puts one on the page (centred in the view without a position) and returns `{id,
plugin, state, targets, output, logs, calls, fuel}`; `widget.click {id, target}` clicks one of its
`targets` as a click on the canvas would (the plugin redraws it; one undo step; returns the same);
`widget.list` lists every widget in the file with its plugin, page, state and targets. A widget's
state is layer data, so it is saved, undone and synced with the file.
`widget.list` lists every widget in the file with its plugin, page, state and targets. With plugin
API 4 a widget can have editable text fields and a property menu (both listed with it, as `fields`
and `menu`): `widget.setText {id, field, value}` sets a field as typing in it would (the value,
at most 1,000 characters, goes into the state under the field's name and the plugin redraws; one
undo step), and `widget.menu {id, item, value?}` uses a menu item (a toggle's `value` is `true` or
`false`, by default the opposite of now; a colour's is one of its options). A widget's state is
layer data, so it is saved, undone and synced with the file.
```json
{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.insert","params":{"plugin":"org.figcraft.examples.counter"}}}}
{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.click","params":{"id":12,"target":"increment"}}}}
{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.list","params":{}}}}
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.setText","params":{"id":12,"field":"label","value":"Votes"}}}}
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.menu","params":{"id":12,"item":"big"}}}}
```
Plugins from a `figcraft-server`'s plugin directory ([`plugins.md`](plugins.md#plugin-directories)):
@@ -1275,15 +1477,33 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
### Dev Mode
- **Component code:** `inspect.code {id, language: react|leptos|vue|swiftui|compose}` on a component,
- **Pages as React + Tailwind:** `inspect.code {id, language: "tailwind"}` writes a layer tree as a
React function component styled with Tailwind CSS v4 utilities and no stylesheet. Auto layout
becomes `flex`/`flex-col`/`gap-*`/`p-*` on the 4px spacing scale, fixed-width auto layout frames
and text become fluid (`w-full max-w-[960px]`; as wide as the frame: `w-full`) and a frame's
fixed height a minimum, per-side strokes become `border-b` and the like, and instances are
inlined with their overrides (component code is `language: react`), except instances whose component
is connected to code (`codeConnect.set`/`codeConnect.import`, React): those are their snippet, e.g.
`<Button variant="outline">{"See examples"}</Button>`, with each import once at the top. A design
system mapped to shadcn/ui this way exports pages that use the project's own components. Bound variables become theme
utilities (`bg-accent`, `rounded-card`, `text-muted`) that `variable.export {format: "tailwind"}`
declares: `@import "tailwindcss";` and an `@theme static` block (every token is emitted, also those only read through `var()`) named by namespace (`--color-*`,
`--radius-*`, `--text-*`, `--spacing-*`, `--font-*`; a number's namespace and unit come from what
layers bind it to), other modes on `[data-<collection>="<mode>"]`. With `mobile: <frame id>` (the
same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the
desktop frame's that differ behind `lg:`, and layers only on desktop `hidden` plus `lg:flex`
(auto layout frames) or `lg:block` (other layers).
- **Component code:** `inspect.code {id, language: react|leptos|vue|swiftui|compose|arkui}` on a component,
a component set or a variant writes the component as a reusable unit: React (TSX) with a props
interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), Vue as a
single-file component (`<template>`, a `<script setup>` with `defineProps` and its defaults, a
`<style scoped>`; a slot layer is Vue's own `<slot>`, so it takes no prop), SwiftUI
with a `View` struct, Compose with a `@Composable` function. Its component properties are the
with a `View` struct, Compose with a `@Composable` function, ArkUI with a `@Component` ArkTS
struct (`@Prop` parameters, `@BuilderParam` for swaps and slots). Its component properties are the
parameters, with their defaults: variant properties become string unions (React, Vue) or enums,
booleans become flags around the layers they show, text properties strings, instance swaps views
(`React.ReactNode`, Leptos `ViewFn`, `AnyView`, a composable lambda) and slots optional content.
(`React.ReactNode`, Leptos `ViewFn`, `AnyView`, a composable lambda, an ArkUI builder) and slots
optional content.
Every variant of a set shares one definition; in React, Vue and Leptos the other variants'
differences become CSS rules on `data-*` attributes. A Vue template holds no arbitrary
expressions, so what React writes as a braced expression has its own spelling there: text is
@@ -1292,13 +1512,14 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
instances) each instance is a call passing only the values it changes, e.g. `<Button
state="Hover" label="Buy" />`, `<Button state=ButtonState::Hover label="Buy" />`,
`<Button state="Hover" />`, `Button(state:
.hover, label: "Buy")`, `Button(state = ButtonState.Hover, label = "Buy")`. Names are escaped and
.hover, label: "Buy")`, `Button(state = ButtonState.Hover, label = "Buy")`, `Button({ state:
ButtonState.Hover, label: "Buy" })`. Names are escaped and
unique (keywords get a suffix, `default` → `defaultValue`, Rust's `type` → `type_`; Leptos names
are snake_case; names that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
{id?, language?}` returns the parts: `{component, name, set, properties: [{name, param, type,
default, value? (instances)}], definition, usage? (instances)}`.
- **Code connect:** link a component (a variant links its set) to the code component it is built
as, per language (`react`, `leptos`, `vue`, `swiftui`, `compose`): `codeConnect.set {id?, language?, importPath?,
as, per language (`react`, `leptos`, `vue`, `swiftui`, `compose`, `arkui`): `codeConnect.set {id?, language?, importPath?,
name?, template?}` (undoable; fields left out keep their value; the defaults are the generated
name and a call passing every property) → `{component, language, code, unknown}`. Templates are
text, never executed: `{property}` (its name or parameter name) is the instance's value escaped
@@ -1314,7 +1535,9 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
strings, any other `{…}` stays as written (`unknown` lists placeholder names that aren't
properties). Then `inspect.code` on an instance or the component returns the import line and the
snippet instead of generated code (`connected: true`), connected instances inside a generated
layer tree are their snippet, and `inspect.componentCode` returns `connected: {importPath, name,
layer tree (also in a component's definition, in the content an instance puts in its slots, and
the components a connected template's instance swaps place) are their snippet with their import
lines once each at the top of the code, per language, and `inspect.componentCode` returns `connected: {importPath, name,
template, import, code}` with the snippet as `usage`. Example: template `<Button
variant="primary" icon="{Has icon ? "cart" : "none"}">{label}</Button>` with import path
`@acme/ui` gives `import { Button } from "@acme/ui";` then `<Button variant="primary" icon="none">{"Buy
@@ -1327,7 +1550,8 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
warning). `get_node` lists a component's links as `codeLinks`. In the app, the Inspect tab's
Connect to code section edits them in the Code section's language, with a live preview and a
Connected badge. An editor extension isn't needed: agents and editors read the same snippets
through this server. Plugins whose manifest asks only for `read` also run from the Inspect tab.
through this server. Plugins whose manifest asks only for `read` also run from the Inspect tab,
with their panels (`plugin.ui`); their callbacks can only read.
- **Dev resources:** links for developers (docs, code, tickets) on any layer, each a URL and a
name. `devResource.add {id?, url, name?}` → `{id, index}` (no name: the URL without its scheme,
`www.` and a trailing slash; no page title is looked up), `devResource.edit {id?, index, url?,
@@ -1339,6 +1563,22 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
are clipped to 200 characters; a layer holds at most 32 links. Links are data: never fetched or
opened by the engine (the app opens one when it is clicked). `get_node` and `inspect_document`
list them as `devResources`; the Inspect tab lists, adds, edits and removes them.
- **Measurements kept on the canvas:** distances a developer leaves on the canvas for everyone.
`devMode.addMeasurement {from? (default the first selected layer), to?, side?}` → `{from, index,
lines}` places one between `from` and `to` (a layer on the same page), along one side of `from`
(`side`: `top`, `right`, `bottom` or `left`: that side's length) or, with neither, to the layer
around `from`; `devMode.removeMeasurement {from?, index?}` → `{removed}` (no index: every one
starting from the layer); both undoable. `devMode.measurements {page?, id?}` → `[{from, fromName,
index, to?, toName?, side?, labels, lines: [{x0, y0, x1, y1, label, dashed}]}]` lists those on a
page (default the current one; `id`: only those from or to that layer), with their lines in page
space measured from the layers' current bounds, so they follow the layers. A measurement is
stored on the layer it starts from (`get_node` lists them as `measurements: [{to} | {side}]`),
saved with the file and synced; the same two layers are measured once (either way round), a
layer starts at most 64. One whose other layer was deleted or moved to another page isn't listed
or drawn (an undo brings it back); a copy keeps the side measurements and those to layers copied
with it. In the app, Dev Mode draws them with the hover redlines, and the Inspect tab lists them
(with nothing selected, every one on the page), selects a measurement's layer on a click, removes
one with −, and places new ones from its Add measurement menu.
- **Annotations:** notes for developers on any layer, each with optional pinned properties that
Dev Mode shows with their current values (and the style or variable behind them).
`annotation.add {id?, label?, properties?}` (a note, pinned properties or both; returns `{id,
@@ -1366,17 +1606,23 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
tab filters the callouts by category and edits the categories (colour, name, delete, add); each
note has a category menu.
- **Ready for dev:** frames at the top of a page or section, and sections, carry a handoff status
(`node.set {devStatus: none|readyForDev|completed}`). `inspect.readyForDev {status?:
readyForDev|completed|any, page?}` lists them across pages (or on one page) in page and layer
order: `{items: [{id, name, page, status}]}`. The Inspect tab sets the status of the selected
frames and, with nothing selected, lists them with a status filter (click one to go to it, or its
focus button to open it in the focus view).
(`node.set {devStatus: none|readyForDev|completed}`; other layers are refused a status).
`inspect.readyForDev {status?: readyForDev|changed|completed|any, page?}` lists them across pages
(or on one page) in page and layer order: `{items: [{id, name, page, status, changed}]}`
(`changed`: ready for dev and edited since). The Inspect tab sets the status of the selected
frames and, with nothing selected, lists them with a status filter (Ready for dev, Changed,
Completed, Any status; changed ones carry a Changed badge; click one to go to it, or its focus
button to open it in the focus view). In the app's Dev Mode the canvas only inspects: presses
select (and pan, and comment), never move, resize, draw or edit text and vectors, and the menus
and shortcuts refuse commands that edit the design (Dev Mode's own annotations, statuses, dev
resources, measurements, code connections and comments still work); choosing a drawing tool in
the toolbar goes back to the design, and the layers panel only selects and expands. Commands sent over MCP or the control channel aren't affected.
- **Compare changes:** when a layer marked Ready for dev first changes (it turns "Changed"), the
file keeps a copy of it and everything in it as it was just before that edit (saved in the
`.figcraft` file only while there is a change; resolving the change, marking it again, another
status or deleting it drops the copy, so the next change compares with that state).
`dev.compare {id?, show?}` → `{id, name, changed, snapshot, added, removed, modified, truncated,
changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section,
`dev.compare {id?, version?, show?}` → `{id, name, changed, snapshot, added, removed, modified,
truncated, version: {id, name, time} | null, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section,
property, before, after, text}]}]}`: layers matched by id, added and removed ones listed as a
whole (`layers` counts what's inside), changed ones with their properties formatted like Inspect
(`width 120 → 136`, `fill #FF0000 → #E03030`, `text “Welcome” → “Hello”`, `parent Badge → Screen`,
@@ -1384,7 +1630,11 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
opacity, visibility, name), nested layers and instance contents included. `snapshot: false` means
the frame changed before copies were kept (an older file). `show: true` opens the compare view in
the app: before and now side by side (or overlaid with a slider), changed layers outlined (added
green, changed orange, removed red), the list of changes and Resolve change.
green, changed orange, removed red), the list of changes and Resolve change. With `version` (an id
from `version.list`) any top-level frame or section, ready for dev or not, is compared with that
saved version instead (a layer the version doesn't have was added as a whole); the compare view
picks it at the bottom (Compare with: When marked ready, or a version; a frame that isn't ready
for dev compares with the newest version), and `ui.set {devCompareVersion}` sets it.
- **Focus view:** `dev.focus {id? | direction: next|previous}` → `{id, name, page, index, count}`
shows one top-level frame or section alone on the canvas (fitted, selected, other layers neither
drawn nor hit), switching page when needed; `direction` steps through the ready-for-dev frames
@@ -1473,6 +1723,14 @@ Pass the returned target list to:
- `selection.applyColorStyle {targets, style}` to apply a document paint style. As with `style.apply`,
this replaces the matching node's entire fill/stroke list and links the style. Text ranges copy
resolved paints and keep their typography, without a paint-style link.
- `selection.bindColorVariable {targets, variable}` to bind a document color variable (id or name)
to those solid fill and stroke slots, as `variable.bind` does with `fills/<i>/color` or
`strokes/<i>/color`. A color style on the list and an earlier binding of the slot are removed.
Text ranges with fills of their own take the variable's value in the layer's modes, without a
binding. Gradients and other variable types are refused; scopes only filter what the Selection
colors menu offers, as for `variable.bind`.
- `selection.detachColor {targets}` to unlink the color style and the color variable of those
slots, keeping their values.
Keep the targets fixed throughout a live interaction. A color becoming equal to another color
must not add slots to the gesture. Invalid or missing targets fail atomically. Direct paint edits
@@ -1667,27 +1925,49 @@ Align and distribute as one undo step:
### Step references
A step in a `batch` can use what an earlier step returned. `{"$step": n}` anywhere in a step's
params (a value, an item in `ids`, a nested field) is replaced by the `id` in the result of step `n`
(zero-based) before the step runs; `{"$step": n, "field": "name"}` takes another field of that
result. This builds a nested tree in one call and one undo step, without guessing ids:
A step in a `batch` can use what an earlier step returned. Give the step a name with `"as"` (next to
`command` and `params`), and `{"$ref": "name"}` anywhere in a later step's params (a value, an item
in `ids`, a nested field) is replaced by the `id` in that step's result before the later step runs.
This builds a nested tree in one call and one undo step, without guessing ids or counting steps:
```json
{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Card","commands":[
{"command":"node.create","params":{"type":"frame","name":"Card","props":{"layout":"vertical","gap":12}}},
{"command":"node.create","params":{"type":"text","parent":{"$step":0},"text":"Ada Lovelace"}},
{"command":"node.create","params":{"type":"frame","parent":{"$step":0},"name":"Button"}},
{"command":"node.create","params":{"type":"text","parent":{"$step":2},"text":"Follow"}}]}}}
{"command":"node.create","as":"card","params":{"type":"frame","name":"Card","props":{"layout":"vertical","gap":12}}},
{"command":"node.create","params":{"type":"text","parent":{"$ref":"card"},"text":"Ada Lovelace"}},
{"command":"node.create","as":"button","params":{"type":"frame","parent":{"$ref":"card"},"name":"Button"}},
{"command":"node.create","params":{"type":"text","parent":{"$ref":"button"},"text":"Follow"}}]}}}
```
A reference to the same or a later step, a negative or non-integer index, a missing `field` or any
other key next to `$step` fails the whole batch, which is rolled back. Only an object whose keys are
`$step` (and optionally `field`) is a reference: text such as `"$step 0"` is left as written. The
`$step` key is reserved in step params: an object holding it anywhere (in plugin data or a variable
value too) is read as a reference. A step that is itself a `command.batch` is passed on as written,
so the references in its steps are to its own steps, not the outer batch's.
`{"$step": n}` refers to a step by its zero-based position instead, and works in the same places;
the two can be mixed. Names are any non-empty string and must be unique within the batch.
The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of realistic tasks
`"field"` takes something other than the `id`: a key of the result (`{"$ref": "card", "field":
"name"}`) or a path into it, with `/` between the parts and list items by zero-based index, the way
`variable.bind` names `fills/0/color`. `edit.duplicate` returns `{"ids": [...]}`, so its first copy is
`ids/0`:
```json
{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"batch","arguments":{"label":"Copy","commands":[
{"command":"edit.duplicate","as":"copy","params":{"ids":[5]}},
{"command":"node.set","params":{"ids":[{"$ref":"copy","field":"ids/0"}],"props":{"name":"Card copy"}}}]}}}
```
A path may start with `/`, as in JSON Pointer (`/ids/0`). A key that itself holds `/` is matched as
written before it is read as a path. A path ends at whatever value it reaches, so `"field": "ids"` is
the whole list.
These fail the whole batch, which is rolled back, with an error naming the step: two steps with the
same `as` (before any step runs); a `$ref` to a name no step has (the error lists the names there
are) or to the same or a later step; a `$step` to the same or a later step, or that is negative or
not an integer; a `field` the result doesn't have (the error names the full path and, for a list,
how many items it has); any key next to `$ref` or `$step` other than `field`. Only an object holding
a `$ref` or `$step` key is a reference: text such as `"$ref card"` is left as written. Both keys are
reserved in step params: an object holding either anywhere (in plugin data or a variable value too)
is read as a reference. A step that is itself a `command.batch` is passed on as written, so the
references in its steps are to its own steps and names, not the outer batch's; the outer batch can
reach into its result list with a path (`{"$ref": "inner", "field": "1/id"}`).
The end-to-end tests in `crates/mcp/tests/it/agent_tasks.rs` are worked examples of realistic tasks
(auto layout, a card built in one batch with step references, components and overrides, gradients
and shadows, paint styles, Light/Dark variables, align/distribute/group, boolean subtract + flatten,
export, undo/redo, drawing with tools).
+2 -2
View File
@@ -1,8 +1,8 @@
# Parity checklist
> **Generated:** 2026-10-17 by `cargo xtask parity`. Do not edit by hand.
> **Generated:** 2026-10-21 by `cargo xtask parity`. Do not edit by hand.
FigCraft commands registered for the incumbent's main menu and toolbar (561 commands in the engine).
FigCraft commands registered for the incumbent's main menu and toolbar (575 commands in the engine).
**212 / 212 menu items (100.0%)**
+75 -16
View File
@@ -18,7 +18,8 @@ permissions its manifest asks for, and nothing more.
plugin can also keep settings from one run to the next.
- **Widgets (API 3).** A plugin can put a layer of its own on the canvas: it describes the layer
(frames with auto layout, text, shapes, images), FigCraft builds it, and a click on one of its
parts calls the plugin back to redraw it from its state, which is kept in the document.
parts calls the plugin back to redraw it from its state, which is kept in the document. Since
API 4 a widget can also have editable text fields and a small property menu.
- **Permissions.** The manifest says whether the plugin only reads the document or may edit it, and
can narrow the commands it may call. FigCraft enforces both.
- **Distribution.** A team's own `figcraft-server` can keep a plugin directory: plugins are
@@ -91,6 +92,17 @@ and its state (a small JSON object) in its own layer data, so a saved file, a co
a live session carries the widget as it is, and a copy is a widget of its own. A widget whose
plugin isn't installed stays as it was drawn; clicking it says the plugin is missing.
**Fields and menus (API 4).** A text layer of a widget can be an editable field: double-click it
with the Move tool and it is edited like any text (all of it selected first). When editing ends
(Escape, or a click elsewhere), the typing is put back and the new value goes to the widget
instead: it is cleaned (line breaks kept, tabs become spaces, other control characters dropped),
refused when longer than 1,000 characters, written into the widget's state under the field's
name and into the field's text, and the plugin is called back to redraw, all as one undo step
(none when the text didn't change). The value lives in the state, so it syncs and saves like the
rest of it. A widget can also carry a property menu: while it is the only layer selected, a
small bar over it shows its actions and toggles as buttons and its colour choices as swatches;
using one calls the plugin back, one undo step. The labels are the plugin's own text.
In a presented prototype (the presenter or the preview pane) clicks on a widget's clickable parts
call its plugin back the same way, with the same limits, but on the prototype's own copy of the
file: the widget changes in the prototype only, the file and its undo history stay as they are,
@@ -115,6 +127,8 @@ From scripts, the CLI and MCP the same commands apply, panels included:
{"command": "widget.insert", "params": {"plugin": "org.figcraft.examples.counter", "x": 0, "y": 0}}
{"command": "widget.list", "params": {}}
{"command": "widget.click", "params": {"id": 12, "target": "increment"}}
{"command": "widget.setText", "params": {"id": 12, "field": "label", "value": "Votes"}}
{"command": "widget.menu", "params": {"id": 12, "item": "color", "value": "#14AE5C"}}
```
`figcraft-cli run script.jsonl --in design.figcraft --out tidy.figcraft` runs that headless; over
@@ -132,9 +146,11 @@ plugins: import them in the script.
| `plugin.close` | `{id}` | Closes plugin `id`'s panel and ends its session. `{closed}` says whether one was open. |
| `plugins.runLast` | `{}` | Runs the last plugin command again. |
| `plugins.manage` | `{}` | Opens the Plugins window; returns the list like `plugin.list`. |
| `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. |
| `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, fields, menu, output, logs, calls, fuel}`: `targets` are the names of its clickable parts, `fields` of its editable fields, `menu` its property menu items. |
| `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. |
| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. |
| `widget.setText` | `{id, field, value, fuel?}` | Sets editable field `field` of widget `id` (or of the widget a layer `id` is in) to the text `value`, as editing it on the canvas would: the value (at most 1,000 characters, cleaned) goes into the state under the field's name and into the field's text, and the plugin is called back. One undo step; returns like `widget.insert`. |
| `widget.menu` | `{id, item, value?, fuel?}` | Uses item `item` of widget `id`'s property menu, as its bar would: an action takes no `value`; a toggle takes `true` or `false` (default: the opposite of now); a colour takes one of its options (hex, any case). The plugin is called back. One undo step; returns like `widget.insert`. |
| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets, fields, menu}]}`: every widget in the file. |
| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. |
| `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. |
| `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. |
@@ -224,7 +240,8 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p
| `version`, `description` | Optional text. A plugin published to a [directory](#plugin-directories) needs a `version` of one to four numbers (`1.2.0`), each version newer than the last. |
| `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. |
| `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. |
| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings) or `3` (widgets). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels or widgets fails clearly on an older FigCraft. |
| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files, widget fields and property menus). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. |
| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"). A plugin directory's entries don't carry it (their wire format stays as it is), so installing any API 4 plugin from one says it may offer files to save. |
| `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). |
| `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. |
| `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. |
@@ -236,6 +253,9 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p
Whatever its manifest says, FigCraft refuses these calls (the plugin gets the refusal as an error
it can handle):
- read or write files: a plugin never gets a path or the disk. With `permissions.saveFile` it can
only hand FigCraft a file (one per run, 16 MB at most, a plain file name) for the user to save
through FigCraft's own dialog; the user can cancel it;
- files and the app: `file.*`, `app.*`, `pref.*`, `help.*`, `library.add`, `libraries.*`,
`image.place` with a `path` (pass the image as base64 `data` instead), `variable.export`,
`codeConnect.export` and `component.playground` with a `path` (leave it out to get the result
@@ -259,17 +279,22 @@ WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100
16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys
and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is
at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and
its images 1 MB each, 2 MB together and 4096 pixels on a side.
its images 1 MB each, 2 MB together and 4096 pixels on a side. A property menu has at most 8
items (labels up to 32 characters), a colour item 1–8 colours; a field's value is at most 1,000
characters.
Copying a byte across the guest-memory boundary costs one additional fuel, reserved before the
allocation or copy. This also applies when the configured per-call overhead is zero. A reply
read is charged for the bytes actually returned, not the requested buffer size. `calls` counts
every ABI import, including calls which only query the reply length.
## ABI (version 3)
## ABI (version 4)
Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2
module runs unchanged and gets the input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values
Version 2 adds panels and settings to version 1, version 3 adds widgets, and version 4 adds
`save_file` and widget fields and property menus (new keys in the widget description and new
widget events); a module written for an older version runs unchanged and gets the input it always
got. A widget description with a field or a menu from a plugin whose manifest says `3` is refused,
and a FigCraft that runs an older API refuses a manifest that asks for `4` when it's imported. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values
are `i32`.
The module **exports** `memory` and `figcraft_run(input_len) -> status`. When `figcraft_run` starts,
@@ -295,6 +320,15 @@ for a new widget) and what was clicked (`event` is null when it's inserted):
{"api": 3, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2}, "event": {"target": "increment"}}}
```
With API 4 the event can also be an edited field (its value is already in `state` under the
field's name) or a property menu item (`value`: null for an action, the new `true`/`false` for a
toggle, the colour picked for a colour item):
```json
{"api": 4, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2, "label": "Votes"}, "event": {"field": "label", "value": "Votes"}}}
{"api": 4, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2}, "event": {"menu": "color", "value": "#14AE5C"}}}
```
It returns `0` for success; anything else fails the run (and rolls it back), with the text passed
to `set_output` as the error message.
@@ -315,6 +349,7 @@ included, is refused when the plugin is imported):
| `setting_get` | `(key_ptr, key_len) -> status` | API 2. `0`: the reply holds the setting's JSON. `1`: there is none. |
| `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> status` | API 2. Stores JSON under the key, for this plugin only, kept with the preferences. `1`: refused (not JSON, a bad key, over 64 KB), the reply says why. |
| `setting_delete` | `(key_ptr, key_len)` | API 2. Forgets a setting. |
| `save_file` | `(name_ptr, name_len, mime_ptr, mime_len, data_ptr, data_len) -> status` | API 4, needs `permissions.saveFile`. Offers a file to save; FigCraft asks the user where it goes once the run succeeds. `1`: refused (no permission, a bad file name, over 16 MB, a second file in one run), the reply says why. |
| `widget_render` | `(ptr, len) -> status` | API 3. Draws the widget being inserted or clicked (the widget description below, as JSON). `1`: the description is invalid, the reply says why. A widget call that doesn't draw keeps the widget as it was (inserting one must draw it). |
A pointer or length outside the plugin's memory traps, which fails the run. Host functions can only
@@ -364,16 +399,29 @@ ignored in auto layout), `width` and `height`, `fill` and `stroke` (hex colours)
`vertical`), `gap`, `padding`, `justify` and `align` (`min`, `center`, `max`: along and across the
layout) and `children`;
with auto layout it hugs its contents on an axis without a size. A text has `text`, `size`,
`color` and `bold` (with a `width` it wraps). An image has `data`, the image's bytes (PNG, JPEG…)
`color` and `bold` (with a `width` it wraps), and with API 4 may have a `field` (a name like a
target's): the text is then an editable field showing its current value. An image has `data`, the image's bytes (PNG, JPEG…)
as base64, usually carried in the module itself; without a size it takes the image's.
FigCraft checks the whole description (kinds, sizes, numbers, colours, lengths, counts, depth,
image sizes) and refuses it if anything is off.
With API 4, `menu` beside `root` is the widget's property menu (left out: none), for example:
```json
"menu": [{"item": "reset", "label": "Reset"},
{"item": "big", "label": "Steps of 10", "type": "toggle", "checked": true},
{"item": "color", "label": "Colour", "type": "color", "options": ["#1E1E1E", "#0D99FF"], "value": "#0D99FF"}]
```
Each item has an `item` name (like a target's, each once), a `label` (default: the name) and a
`type`: `action` (the default), `toggle` (with `checked`) or `color` (with `options`, 1–8 hex
colours, and optionally `value`, one of them: the current one). Every redraw replaces the menu.
## Writing a plugin in Rust
`plugins/` in the repository is a workspace of its own: `plugins/sdk` (the crate
`figcraft-plugin-sdk`, `no_std` with `alloc`) wraps the ABI with serde_json, and
`plugins/examples` has three plugins:
`plugins/examples` has four plugins:
- **Tidy names** (`tidy-names`): cleans up the selected layers' names (extra spaces, the "copy" left
by duplicating) and optionally their letter case, inside the selection too. Two commands: one
@@ -384,13 +432,20 @@ image sizes) and refuses it if anything is off.
button; the palette
picked last is remembered as a setting for next time. **From hex colours…** takes a palette of
hex colours in the parameters form.
- **Counter** (`counter`, API 3): a widget, a count between − and + buttons; each click changes the
count kept in the widget's state. It only reads the document.
- **Counter** (`counter`, API 4): a widget, a count between − and + buttons under a label; each
click changes the count kept in the widget's state, a double-click on the label edits it, and its
menu resets the count, counts in tens or colours it. It only reads the document.
- **Export names** (`export-names`, API 4): saves the names of the selected layers (optionally the
layers inside them too) as `layer-names.txt`. It reads the document and offers the file with
`save_file`; FigCraft asks where it goes.
`cargo xtask plugins` (part of `cargo xtask wasm` and `cargo xtask ci`; it needs the
`wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`) checks, tests and
builds them, and leaves each example's manifest and module side by side in
`target/plugins/dist/<example>/`, ready to import.
`target/plugins/dist/<example>/`, ready to import. With `CARGO_TARGET_DIR` set, that location is
`$CARGO_TARGET_DIR/plugins/dist/<example>/`. Plugin compilation and host tests use the caller's
same Cargo target directory; wasm modules are built under its `wasm32-unknown-unknown/release/`
directory. The plugin workspace keeps its own lockfile and release profile.
A plugin of your own:
@@ -442,7 +497,11 @@ panel calls back: `control`, `value` and every field's `values`); returning `Err
rolls it back. With `"api": 2` in the manifest: `show_ui(&json!({...}))` and `close_ui()` for the
panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. With
`"api": 3` and `"widget": true`: `input.widget` (`id`, `state`, `target`) when the widget is
inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. Outside
inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. With
`"api": 4`, `input.widget` also has `field` or `menu` and the `value` that came with it, and the
description may have fields and a `menu`; with `"permissions": {"saveFile": true}` too,
`save_file(name, mime, &bytes)` offers a file; FigCraft asks the user where it goes and the plugin
never learns a path. Outside
WebAssembly every host call fails, so the plugin's own logic can be unit-tested with `cargo test`.
Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`,
@@ -455,8 +514,8 @@ import what you need from `figcraft`.
install counts, and updates are found when you browse, not in the background. The browser build
can't use a directory yet.
- Plugins written for other apps (JavaScript plugins) don't run.
- Widgets: clicks are their only events (no text input, hover, property menu or timers), they
can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no
- Widgets: clicks, field edits and menu items are their only events (no hover or timers), a field
is plain text (no placeholder, length or single-line setting), they can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no
vectors, components or variables).
- Panels: no images, sliders or free layout inside a panel.
- Data in the document: plugins keep data on layers (pages included) with `node.set {props:
+14 -5
View File
@@ -1,20 +1,21 @@
# Prototyping parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** patch (the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
**Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except
media control, every transition and easing exist; key/gamepad triggers take controller buttons,
read from controllers on Windows and in the browser. Every rule of the public
documentation on triggers, actions, overlays, animations, easing, scrolling and interactive
components is a test case with what it implies (`crates/engine/src/proto_behaviour_tests.rs`, 7
tests on the model, and `crates/ui-egui/src/present_behaviour_tests.rs`, 12 tests driving the
player, 21 gestures among them; the tail: `crates/engine/src/proto_tail_tests.rs`, 4 tests, and
tests on the model, and `crates/ui-egui/src/present_behaviour_tests.rs`, 14 tests driving the
player, 21 gestures among them; presentation tabs: `crates/ui-egui/src/present_tab_tests.rs`, 6
tests; the tail: `crates/engine/src/proto_tail_tests.rs`, 4 tests, and
`crates/ui-egui/src/present_tail_tests.rs`, 5 tests), and all pass. Not yet checked against
larger real prototypes. Remaining: 2–5 h.
| Group | Figma | FigCraft | Missing |
|---|---|---|---|
| Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress (keys, combinations and controller buttons) | media triggers, delays on mouse enter/leave/down/up; controllers on macOS, Linux and FreeBSD |
| Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress (keys, combinations and controller buttons) | media triggers; controllers on macOS, Linux and FreeBSD |
| Actions | Navigate to, change to, back, scroll to, open link, open / swap / close overlay, set variable, set variable mode, conditional, play/pause/mute/seek media | 11: Navigate, Back, ScrollTo (with an offset), OpenOverlay, SwapOverlay, CloseOverlay, OpenUrl, ChangeTo, SetVariable, SetVariableMode, Conditional | media actions |
| Transitions | Instant, dissolve, smart animate, move in/out, push, slide in/out | all 8 | — |
| Easing | Linear, ease in/out/in-and-out, back variants, gentle, quick, bouncy, slow, custom bezier, custom spring | all 13 (springs physical) | — |
@@ -54,8 +55,10 @@ case pins the current behaviour).
| Open link | pass | always a new tab or browser window (opening in place would leave the prototype) |
| Change to: another variant of the instance, in place, with the interaction's animation | pass | `interactive_components_change_in_place` |
| Interactive components: every instance plays the interactions of the variant it shows; an interaction on the instance with the same trigger wins | **fixed** | instances played only interactions copied onto them when they were placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown; `instances_play_their_variants_interactions` |
| Presentation tabs: one per page of a file, named "file - page" (renames follow). Play on a page that has a tab shows it as it was (from another start frame it starts again). Tabs keep their own copy of the prototype and play on after their file closes; a saved file opened again gets its tabs back. A tab never changes a file that isn't in front. Its X or Escape ends it and goes back to its file, or Home | **fixed** | one "Present" tab: a second presentation replaced the first, Play restarted it, and closing the file ended it; `present_tab_tests` |
| Change to on a layer inside a nested instance changes the instance whose variant set holds the destination | **fixed** | the innermost instance (an icon inside a button) was changed instead |
| Set variable, set variable mode, conditional | pass | `proto_run_tests`, `proto_conditional_tests`, `present_variables_tests` |
| Change to, set variable and set variable mode lay out what they change as the canvas does: text sizes to what it shows, hugging frames around it | **fixed** | the player didn't lay out after them: an instance with a longer label changed to the main's width, and a bound text kept its old size; `change_to_lays_out_the_instance_around_its_own_content`, `set_variable_and_set_mode_lay_out_the_text_they_change` |
| Expressions: a variable reads the mode where the interaction runs (a frame that chose a mode, else the page's); `name[Mode]` reads a given mode whatever is chosen; operators on numbers, text (`+` joins), booleans and comparisons | pass | `mode_lookups_read_a_given_mode_or_the_layers_current_one`, `proto_expr_mode_tests` |
| Set variable mode, then a conditional or set variable later in the same interaction: they see the new mode | pass | `set_variable_mode_changes_what_later_expressions_read` |
| A set variable writes every mode of the variable, so a lookup of any mode reads the new value; a frame inside the screen that chose a mode keeps it after set variable mode | silent | pinned as stated |
@@ -86,10 +89,11 @@ case pins the current behaviour).
| Widgets react to clicks in a presented prototype | done | clicks only, like on the canvas |
| Interactive components: instances play their variant's interactions, overridden per trigger | done | |
| Drag-driven animations that follow the pointer, in their direction | done | |
| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens its own Present tab, which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put, as the frame's overflow setting allows ("No scrolling" frames clip); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's |
| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens a tab for the page, named "file - page", which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put: a top-level frame bigger than the screen always scrolls (as in Figma, with no overflow setting), content past the frame's own edges as its overflow setting allows ("No scrolling" clips it); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's |
| Hotspot hints, inspect panel in the presenter, comments in the presenter | done | |
| Preview pane following edits live | done | |
| Edit interactions on several layers at once | done | |
| Open link: a notice before a website opens, with a checkbox to skip it on this device | done | `http` and `https` links (a mail link opens at once); Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) |
| Opening a link in place | missing | |
| Controllers on macOS, Linux and FreeBSD | missing | the usual Rust controller library links the system's libudev on Linux; the pure-Rust desktop builds read none there yet |
| Prototype in a separate window; mirror to a phone | missing | see [hardware-parity](hardware-parity.md) |
@@ -100,6 +104,11 @@ case pins the current behaviour).
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | patch | The Triggers row's Missing column no longer lists delays on mouse enter, leave, down and up, which landed on 2026-10-13 (`crates/engine/src/proto_delay_tests.rs`, `crates/ui-egui/src/present_delay_tests.rs`). Numbers unchanged |
| 2026-10-24 | patch | Open link to a website first shows a notice that it leaves the prototype, with the link, Open link, Cancel and "Don't show this again", which the desktop app saves with its preferences and the web build keeps until the page reloads (`proto.linkNotice` turns the notice back on); a second link to a website that fires while the notice shows is dropped, and a mail link still opens. Before, the player opened the link at once, also from an After delay trigger. Numbers unchanged |
| 2026-10-23 | patch | A top-level frame taller (or wider) than the device's screen scrolls with no overflow setting, as Figma's documentation says; before, "No scrolling" frames such as the landing page template's Phone frame only clipped. Content past a frame's own edges still scrolls only as its overflow allows. Checked in the app on macOS. Numbers unchanged |
| 2026-10-22 | minor | Change to, set variable and set variable mode lay out what they change in the player as the canvas does: text sizes to what it shows and hugging frames follow (an instance with a longer label changed to the main's width; a bound text kept its old size). Two player tests. Breadth and ready unchanged. |
| 2026-10-17 | minor | Presentation tabs: one per page of a file instead of a single "Present" tab, named "file - page" (renames follow); Play shows a page's tab as it was; tabs outlive their file and come back when a saved file opens again. Numbers unchanged. |
| 2026-10-17 | minor | Community batch #753: a Present tab (#537): playback in its own tab that keeps its screen and history, a current-frame thumbnail, previous / next, device presets and scale; the frame reflows to the device's screen on the prototype's copy; phone and tablet content drags inside a fixed device as its overflow allows ("No scrolling" frames clip; iPad mini, MacBook Air and Desktop keep their bodies); the unsaved-changes question shows above the presentation. Checked in the app on Windows. Percentages and hours unchanged |
| 2026-10-13 | minor | 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-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h |
+24 -5
View File
@@ -68,6 +68,22 @@ FIGCRAFT_DEFAULT_SERVER=wss://design.example.org/ cargo build --release -p figcr
The release workflow leaves it unset. The public server's terms are in
[`online-sharing-terms.md`](online-sharing-terms.md).
## The default font
New text starts in Inter (`figcraft_doc::DEFAULT_FONT`). A distribution whose users mostly write a
script Inter lacks (Thai, say) can name another family with `FIGCRAFT_DEFAULT_FONT` when building;
unset or empty means Inter. Pick a family the build embeds from craft-fonts (the web build has no
other fonts) or one its users have installed:
```sh
FIGCRAFT_DEFAULT_FONT="Noto Sans Thai" cargo build --release -p figcraft
```
Files record each text's family, so the variable only changes what new text starts in: a file
saved by one build keeps its fonts in a build with another default. Like any other family, it
renders only where it is available (embedded or installed); elsewhere the text falls back to the
faces that are. The release workflow leaves it unset.
## What gets built
| Platform | Artifacts | Built on |
@@ -101,11 +117,14 @@ builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so releases embed it
rather than ship without them. The browser has no system fonts, so a web build without craft-fonts
can't draw text.
`crates/text/build.rs` reads both `fonts/manifest.txt` and `fonts/latin-manifest.txt`, so the pin
must be a commit that has the Latin manifest (today `4391305`, on craft-fonts'
`pr/latin-presentation-fonts` branch; re-pin to `main` once that lands). That commit has no
Noto Sans Georgian (craft-fonts' main has it but no Latin manifest), so desktop Georgian uses an
installed font; re-pin when one commit has both (craft-fonts#42). The web build embeds only Inter,
Noto Sans Georgian and the Archivo SemiBold wordmark, when the pin has them. Packages carry each embedded font's licence as `OFL-<family>.txt`
must combine the Latin presentation catalog and the current core UI faces. Required web builds
validate the emitted Inter Regular/SemiBold and Noto Sans Georgian Regular faces after filtering;
a missing required face, style or asset fails the required web build. Excluded Adobe rows
are skipped before resolving their asset paths.
Native builds retain the permitted Latin presentation faces. Source Sans 3 and Source Serif 4
are filtered on every target under FigCraft's asset policy. The web build keeps its smaller
allowlist: Inter, Noto Sans Georgian and Archivo SemiBold when supplied. Packages carry each
embedded font's licence as `OFL-<family>.txt`
(`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app).
### macOS
+64 -7
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -16,8 +16,9 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr
3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)):
40–60 h, parallel by tool.
4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja,
ko and zh-Hans drafts and the es-419 overlay (people), then the other key languages: 3–7 h per language
plus native review.
ko, zh-Hans, id and cs drafts and the es-419 overlay (people; Russian and Turkish are reviewed),
the entries added since each catalog was written, then the other key languages (Hindi,
Vietnamese, Arabic): 3–7 h per language plus native review.
5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h.
## Alpha gate
@@ -53,13 +54,14 @@ blockers.
| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 |
| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11; vector edit mode regions 2026-10-13) | spell check, Figma Draw multi-edit | 7–18 |
| M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 |
| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 |
| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11; best-effort `.sketch` import 2026-10-21) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 |
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 |
| M10 Localization (new) | not started | layer + 11 languages | 60–100 |
| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 |
| M12 AI features (new, owner decision) | not started | local models | 30–60 |
| M10 Localization | ~39%: the 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 | native reviews, Hindi, Vietnamese, Arabic (with mirrored panels), engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 |
| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update); widget fields and property menus (API 4) | a public catalogue, publisher accounts and signatures, the directory in the browser, widget hover and timers; Figma plugin compatibility is an owner decision | 6–22 |
| M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions; then a shared timer and voting sessions | widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 20–44 |
| M13 AI features (new, owner decision) | not started | local models | 30–60 |
## Next, after the current focus
@@ -73,8 +75,63 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | 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 | minor | M12: a shared board timer and voting sessions; 30–60 h → 26–54 h |
| 2026-10-24 | minor | M11: plugin widgets take text input (editable fields, `widget.setText`) and a property menu (`widget.menu`), plugin API 4; 8–26 h → 6–22 h |
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Milestone M12 Boards added; AI features move to M13 |
| 2026-10-21 | minor | M6: best-effort `.sketch` import (Sketch 48 and newer), see [file-format-parity](file-format-parity.md). Percentages unchanged |
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
| 2026-10-15 | minor | Removing someone from a live session (#338): right-click a person's avatar ▸ Remove *name* from session, or `collab.removePerson {client}`, when you host the session or own the file on a server; the session ends for them (they keep their copy), the code or link they hold no longer gets them back in while it runs, and everyone else stays. Numbers unchanged |
| 2026-10-15 | minor | Pages list (#272, #258): a long pages list scrolls in its own area, so the layers below stay in view; page and layer drops count only inside the list's visible rows. Numbers unchanged |
| 2026-10-15 | minor | Variables export in the browser (#368): Export… ▸ Variables JSON / CSS custom properties did nothing in the web build and now arrives as a download; importing there still needs a file picker. Numbers unchanged |
| 2026-10-15 | minor | Font picker keys (#352, #345): Up and Down step through the font families from the current one, keeping the highlight in view and previewing it on the canvas; Enter applies it, Escape leaves the text as it was. Numbers unchanged |
| 2026-10-15 | minor | Leaving Design ends the pen path (#308, #307): switching to Prototype or Inspect while the Pen draws ends the path and goes back to Move (pressing Design again keeps drawing), and a click on the toolbar no longer reaches the canvas under it. Numbers unchanged |
| 2026-10-15 | minor | Vector edit mode keeps the canvas still (#322): the edit mode hints in the Design panel wrap, so entering edit mode and picking a region no longer shift the canvas by about 25 points in the middle of the click (which dragged the region picked). Numbers unchanged |
| 2026-10-15 | minor | Drop shadows under a background blur (#317, #255): a layer with both casts its shadows from what it paints (a translucent fill casts a lighter shadow than an opaque one) and reads the backdrop only after drawing them, so a shadow shown behind transparent areas is seen through the blur. Numbers unchanged |
| 2026-10-15 | minor | When the window can't open (#306, #261): Windows release builds have no console, so a graphics failure at start quit without a word; the error is now logged and shown in a message box in the interface language, with what to try (unset `WGPU_BACKEND`, update the driver, OpenGL) and where to report it, skipped with `--control`. Numbers unchanged |
| 2026-10-15 | minor | Crashes on Windows (#271, #314, #337; #224 and two more): a text field focused before it was drawn aborted the screen-reader layer; double-clicking a page, creating a variable collection and renaming a style folder no longer close the app. Numbers unchanged |
| 2026-10-15 | minor | Deeply nested layers (#330, #210): one depth guard (256) on the three recursive layout paths, so a deep file can't run the layout out of stack. Numbers unchanged |
| 2026-10-15 | minor | Image memory budgets (#326; #203, #189): decoded images and a file's images have budgets; the decode cache evicts its least recently used entries. Numbers unchanged |
| 2026-10-15 | minor | macOS file dialogs (#253, #250) no longer re-enter the event loop; a dialog's result goes to the file and window it was opened for, and dialogs are parented to the main window. Numbers unchanged |
| 2026-10-15 | minor | Recovery copies (#297, #303, #331, #336; #202, #185, #187): a copy counts only after its write succeeds and a failed one is retried; saves and closes wait for the write in flight; copies keep version history and branches; a recovered copy stays until this run has its own. Numbers unchanged |
| 2026-10-15 | minor | Text edits (#280, #278) no longer roll back earlier changes; a drag released off the canvas ends at the pointer's last canvas position. Numbers unchanged |
| 2026-10-15 | minor | Rollback (#282, #301; #196): a failed command rolls back during a drag or other gesture too; a failed batch keeps the redo steps. Numbers unchanged |
| 2026-10-15 | minor | Atomic save and export (#284): staging files are uniquely named and created exclusively, so a file or link already at the old fixed staging name is never written through, and two writers of one file no longer share one. Numbers unchanged |
| 2026-10-15 | minor | Alt-drag copies (#283) snap to their original, and Duplicate (Cmd+D) then repeats the last copy's offset. Numbers unchanged |
| 2026-10-15 | minor | The pen (#277) shows its Bezier handles while a point is dragged out. Numbers unchanged |
| 2026-10-15 | minor | Corner handles (#293, #292): a press on a corner handle resizes instead of picking the corner-radius handle. Numbers unchanged |
| 2026-10-15 | minor | New text (#372, #362) takes a readable colour from the frame it is drawn in, not only the page (text drawn in a white frame on a dark page was white). Numbers unchanged |
| 2026-10-15 | minor | Outline stroke (#386) works on every selected layer. Numbers unchanged |
| 2026-10-15 | minor | Send to back (#444) counts per parent when the selection spans frames. Numbers unchanged |
| 2026-10-15 | minor | Align buttons (#276) are off when the selection has nothing to align to. Numbers unchanged |
| 2026-10-15 | minor | Gradient type icons (#275) show their gradient. Numbers unchanged |
| 2026-10-15 | minor | Corner resize cursors (#388) stay diagonal at any proportions; the text selection highlight (#390) stays inside the box on the last line. Numbers unchanged |
| 2026-10-15 | minor | New pages (#311, #295) take the current page's background; the theme still decides a new file's page. Numbers unchanged |
| 2026-10-15 | minor | A slice's export preview (#265) shows the content under it. Numbers unchanged |
| 2026-10-15 | minor | SVG export (#327, #325) keeps a nested frame's fill and stroke inside its parent's clip. Numbers unchanged |
| 2026-10-15 | minor | Home screen (#318, #324): recent files notice files deleted while it is open; a long path no longer draws over the file name. Numbers unchanged |
| 2026-10-15 | minor | System theme on first run (#262): the first frame follows the system theme, and going back to System from a manual theme works. Numbers unchanged |
| 2026-10-15 | minor | Web clipboard (#375): copy and paste in the browser go through the system clipboard, so a paste after a copy brings the copied layers instead of whatever text the clipboard held. Numbers unchanged |
| 2026-10-15 | minor | Zoom previews (#312): when renders from scratch are slow, a new zoom first shows the whole view coarsely instead of the page colour, then the refined image; images of another file or page never show. Numbers unchanged |
| 2026-10-15 | minor | Hidden windows (#313, #332; #269): a minimised or covered window answers control and MCP requests (commands, inspection, `ui.render`) at once, each window on its own; screenshots and input still wait for it to be on screen. Numbers unchanged |
| 2026-10-15 | minor | Server (#340, #302): a compaction cut short closes the file instead of confirming lost edits (#183); each team library version is kept in its own file (#206). Numbers unchanged |
| 2026-10-15 | minor | Counters (#343, #344, #353; #309): collab sequence numbers, layer name counters and opacity digits can't overflow. Numbers unchanged |
| 2026-10-15 | minor | Commands refuse bad indexes and ids (#475, #384, #310): `page.select` and `doc.switch`, `effect.set` and `export.update` / `export.remove`, `page.move` with a non-page id. Numbers unchanged |
| 2026-10-15 | minor | Find and replace (#358, #361) changes every occurrence, case-insensitively, also when the replacement contains the search text. Numbers unchanged |
| 2026-10-15 | minor | The gradient shorthand (#254, #252) is read by the engine, so the CLI, control channel and MCP share it. Numbers unchanged |
| 2026-10-15 | minor | .fig import (#279, #281): resized instances lay out their layers by their constraints (#266); explicit variable modes whose collection is named by its asset key, as library collections are, come across (#267). Numbers unchanged |
| 2026-10-15 | minor | Colour emoji (#300, #268) draw from their own colour tables (sbix and CBDT/EBDT bitmap strikes, COLR v0 layers), a colour face tried first; COLR v1 still draws outlines. Numbers unchanged |
| 2026-10-15 | minor | macOS (#290, #289): `.figcraft` files open from Finder, Open With and the Dock. Numbers unchanged |
| 2026-10-15 | minor | Windows (#294, #291): the first preferred language alone picks the UI language. Numbers unchanged |
| 2026-10-15 | minor | Unix font discovery (#474) follows `XDG_DATA_HOME`. Numbers unchanged |
| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged |
| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged |
| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). Numbers unchanged. |
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-16 | minor | 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-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged |
| 2026-10-14 | minor | M7: Home screen recent files as cards: 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. |
+2 -2
View File
@@ -336,8 +336,8 @@ the address it came from and when it was last opened (for expiry).
beside it, flushed, then renamed over the old one. The log is only ever appended to, and synced
before the server tells anyone about the change.
- **Compaction** writes the file's state as the next snapshot and starts an empty log, after
`compact_after` entries or `compact_mb` of log, when the last person leaves, and when the server
stops. A log left over from a compaction cut short is replayed over the snapshot, which already
`compact_after` entries or `compact_mb` of log, when the last person leaves a file that was
edited (the ids of people who only joined stay in the log), and when the server stops. A log left over from a compaction cut short is replayed over the snapshot, which already
holds it all, so nothing changes. If a damaged generation field made a current log look like such
a leftover, its edits the snapshot lacks are recovered the same way and the damage is reported.
- **Damage is skipped and reported, never deleted.** At startup a folder whose description, links
File diff suppressed because one or more lines are too long
+26 -23
View File
@@ -17,12 +17,12 @@ Simplified.
The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay
`es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese
(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish
(`fi`), Polish (`pl`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Georgian
(`ka`) was written with AI assistance and reviewed by a native speaker, and stays a draft until a
maintainer changes its status. Russian (`ru`, Русский) was
reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar
tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker,
and Swedish (`sv`) translates every UI string, command label and menu wording.
(`fi`), Bengali (`bn`), Italian (`it`), Vietnamese (`vi`) and Thai (`th`), machine-drafted and waiting for native speakers' review. Polish (`pl`,
Polski) was reviewed and approved by Lucas (@lukasz-sklad). Georgian (`ka`) was written with AI
assistance and reviewed by a native speaker, and stays a draft until a maintainer changes its status.
Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and
Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and
reviewed by a native speaker, and Swedish (`sv`) translates every UI string, command label and menu wording.
## The catalogs
@@ -61,10 +61,17 @@ formal Anda (never kamu), keeps established design terms in English (frame, laye
instance, auto layout, plugin, file) and keeps a noun unchanged after a count, so its plural entries
give only `other`. Georgian has no letter case (never write Mtavruli for capitals), uses verbal nouns
for commands (გვერდის დამატება) and keeps a noun singular after a numeral, so counts read
"შრე: {n}" and its plural entries give the same text for `one` and `other`.
"შრე: {n}" and its plural entries give the same text for `one` and `other`. Vietnamese uses concise
imperatives for buttons and menu items (Lưu, Xóa) and keeps a noun unchanged after a count, so its
plural entries give only `other`. Thai is concise, with bare imperatives for buttons and
menu items (บันทึก, ลบ), no ครับ/ค่ะ endings, no spaces between Thai words (a space only separates
clauses, and Thai from Latin words or numbers), and design terms transliterated as Thai designers say
them (เฟรม, เลเยอร์, คอมโพเนนต์, อินสแตนซ์, ออโต้เลย์เอาต์). It has no plural forms: plural entries give
only `other`, and dates read `{day} {month} {year}` with Thai month abbreviations.
CJK text needs a CJK font, which egui and Inter lack: the desktop app adds an installed one when
something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts,
CJK and Thai text need their own fonts, which egui and Inter lack: the desktop app adds an installed
one when something on screen needs it, so layer and page names in those scripts show whatever the
UI language (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts,
so Japanese, Korean and Chinese appear there only in a build with craft-fonts and
`FIGCRAFT_EMBED_CJK=1` (Noto Sans CJK SC, +16 MB); otherwise they are left out of the picker. A
Chinese UI puts its Chinese face first so Han takes Simplified Chinese shapes. Georgian is drawn in
@@ -80,8 +87,8 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its
set `[meta] name` and `status = "draft — needs native review"`.
2. Translate every value; keep the keys. Keep `{placeholders}` exactly, "…" where English has it,
brand names and keyboard glyphs. English labels of commands: `figcraft-cli commands`.
3. Plurals: give `one` only if the language has a singular form (Japanese, Korean and Chinese give
only `other`). `other` is required; `one`, `few` and `many` are optional. `trn` picks `one` for
3. Plurals: give `one` only if the language has a singular form (Japanese, Korean, Chinese and
Vietnamese give only `other`). `other` is required; `one`, `few` and `many` are optional. `trn` picks `one` for
1 (also 0 in French and Brazilian Portuguese). Russian uses `one` for 1, 21, 31… except 11;
`few` for 2–4, 22–24… except 12–14; and `many` for the other integers, including 0. Keep `{n}`
in all Russian forms. Add new rules to `plural` in
@@ -91,13 +98,9 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its
three forms. Czech's CLDR `many` applies only to decimals; `trn` takes integer counts.
Polish uses `one` for 1 only, `few` like Russian (2–4, 22–24… except 12–14) and `many` for
the other integers, including 0 and 21 (e.g. `{n} warstwa`, `{n} warstwy`, `{n} warstw`).
4. To require full coverage, add the code to `COMPLETE` in
[`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that
adds a string then has to translate it into each complete language, so only Spanish is there:
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and
Georgian are registered as drafts, Russian and Turkish are reviewed but not required, and a string added
later shows English in them until it is translated. To list what a draft lacks,
add its code to `COMPLETE` locally and run the check.
4. A string added later shows English in every language until someone translates it; a missing
translation never fails a check or a build. `cargo xtask i18n-report` lists what each language
lacks (UI strings, command labels, menu wordings), and `cargo xtask ci` prints the same list.
5. Run the check and look at the panels in the app (longer words must fit the right panel):
cargo test -p figcraft-ui-egui i18n
@@ -106,9 +109,9 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its
with "…" and show it in full on hover; elsewhere, look for clipped or wrapped text.
The check fails on keys the code uses that `en.toml` lacks (it prints the lines to paste), keys no
code uses, unknown command ids, placeholders that don't match English, and, for complete languages,
anything untranslated. Machine-drafted catalogs stay marked as drafts until a native speaker has
reviewed them; a reviewed catalog stays a draft until a maintainer approves it (its `status` says which).
code uses, unknown command ids and placeholders that don't match English. Machine-drafted catalogs
stay marked as drafts until a native speaker has reviewed them; a reviewed catalog stays a draft
until a maintainer approves it (its `status` says which).
## Adding strings in code
@@ -116,11 +119,11 @@ Wrap every string a user sees or a screen reader hears: `tr` for plain text, `tr
placeholders, `trn` for counts, `tr_noop` for literals in `const` tables that are looked up later
with `tr(label)`. The key must be a string literal as the first argument (the check reads the
source). Don't wrap ids, command ids, JSON, log messages, file extensions or document content. Then
add the key to `en.toml` and to each complete language (Spanish and Russian); the drafts may follow later.
add the key to `en.toml`; translations may follow later.
Labels from the engine and document crates (tool names, blend modes, easing curves…) are looked up
at display with `tr(x.label())`; they are listed in `dynamic_labels` in `i18n_tests.rs`.
The importers' fixed notes (the string literals passed to `warn` in `crates/format/src/fig` and
`crates/format/src/rest`) are keys too: the import report shows them in the UI language, so a new
note goes into `en.toml` and each complete language. Notes built with `format!` show in English.
note goes into `en.toml`. Notes built with `format!` show in English.
+15 -9
View File
File diff suppressed because one or more lines are too long