mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-02 06:46:50 -06:00
Merge main into feat/board-canvas
This commit is contained in:
+13
-5
@@ -8,6 +8,10 @@ commands, serve MCP and measure performance. Build it with
|
||||
`--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
|
||||
|
||||
Text lays out and renders with the same fonts as the desktop app: the fonts built into FigCraft
|
||||
@@ -112,14 +116,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"}}}
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (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)
|
||||
> **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
|
||||
@@ -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,7 @@ 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 |
|
||||
|
||||
+35
-15
@@ -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 {}`
|
||||
@@ -167,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)).
|
||||
@@ -306,10 +317,19 @@ 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?}`
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ repository, third-party integrations. Remaining: 1–2 h.
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| 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); files dropped on the canvas still edit (ours to tighten) |
|
||||
| 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 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 |
|
||||
@@ -28,6 +28,7 @@ repository, third-party integrations. Remaining: 1–2 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 |
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (`.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); 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 (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
|
||||
@@ -49,7 +49,7 @@ 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, 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 |
|
||||
@@ -159,12 +159,15 @@ 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-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 |
|
||||
|
||||
+24
-10
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (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)
|
||||
> **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
|
||||
@@ -15,10 +15,10 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu
|
||||
| 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; a minimised or covered window still answers control and MCP requests, while screenshots and input fail at once until it is on screen (#313, #332, #720). 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,8 @@ 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 |
|
||||
|
||||
+136
-19
@@ -196,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
|
||||
@@ -303,13 +309,13 @@ Or in a project's `.mcp.json`:
|
||||
|---|---|
|
||||
| `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, 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 and Leptos, `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). |
|
||||
| `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. |
|
||||
| `undo` / `redo` | Step through history. |
|
||||
@@ -338,6 +344,40 @@ 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
|
||||
@@ -512,6 +552,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),
|
||||
@@ -662,7 +713,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)).
|
||||
@@ -778,6 +832,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).
|
||||
|
||||
@@ -865,6 +927,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
|
||||
@@ -872,7 +936,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`
|
||||
@@ -970,7 +1039,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
|
||||
@@ -1342,6 +1413,22 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
|
||||
|
||||
### Dev Mode
|
||||
|
||||
- **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|swiftui|compose}` 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), SwiftUI
|
||||
@@ -1557,6 +1644,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
|
||||
@@ -1751,25 +1846,47 @@ 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.
|
||||
|
||||
`"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
|
||||
|
||||
+4
-1
@@ -390,7 +390,10 @@ image sizes) and refuses it if anything is off.
|
||||
`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:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Prototyping parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** patch (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)
|
||||
> **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,
|
||||
@@ -15,7 +15,7 @@ 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) | — |
|
||||
@@ -93,6 +93,7 @@ case pins the current behaviour).
|
||||
| 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) |
|
||||
@@ -103,6 +104,8 @@ 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. |
|
||||
|
||||
+24
-5
@@ -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
|
||||
|
||||
+2
-2
@@ -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
+6
-6
@@ -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`) and Italian (`it`), 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
|
||||
|
||||
|
||||
+7
-4
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user