From 297426c595b7b681d537d92bc39f2e4b593e9853 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Fri, 6 Nov 2026 22:21:51 -0800
Subject: [PATCH 001/103] Export: color profile text states which formats are
tagged and that CMYK PDF is DeviceCMYK
The export tool and command descriptions, docs/mcp.md and the file-format
table said PDF is always sRGB, that sRGB JPG is untagged and that every
exported file is tagged with its profile. RGB PDF is sRGB and PDF with cmyk
is DeviceCMYK; JPG embeds an ICC profile for sRGB and Display P3; WebP embeds
one for Display P3 only. The JPG quality (1 to 100, default 90) now has one
row in the format table.
---
crates/engine/src/cmd/file.rs | 2 +-
crates/mcp/src/tools.rs | 2 +-
docs/file-format-parity.md | 7 +++----
docs/mcp.md | 4 ++--
4 files changed, 7 insertions(+), 8 deletions(-)
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 0ed12b8d3..e52960d43 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -120,7 +120,7 @@ pub fn specs() -> Vec {
spec!(
"file.export",
"Export",
- "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), colorProfile? (PNG, JPG and WebP: sameAsFile (default: the document's profile)|srgb|displayP3; colors convert, out-of-gamut ones clip; SVG, PDF and XML are always sRGB), quality? (JPG: 1-100, default 90)}",
+ "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), colorProfile? (PNG, JPG and WebP: sameAsFile (default: the document's profile)|srgb|displayP3; colors convert, out-of-gamut ones clip; SVG, XML and RGB PDF output are always sRGB; PDF with cmyk is DeviceCMYK), quality? (JPG: 1-100, default 90)}",
false,
export
),
diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs
index 9e901f5bb..5d4296f01 100644
--- a/crates/mcp/src/tools.rs
+++ b/crates/mcp/src/tools.rs
@@ -280,7 +280,7 @@ fn tools() -> Vec {
"svgIdAttribute": boolean("SVG: layer names as unique `id` attributes (default false)"),
"svgSimplifyStroke": boolean("SVG: strokes as `stroke` attributes where exact (default true); false writes outlines"),
"vdCompat": {"type": "string", "enum": ["bestEffort", "exact", "ignore"], "description": "XML: what a Vector Drawable can't draw exactly is approximated with a warning (bestEffort, default), left out with a warning (exact), or written as far as it maps without warnings (ignore: overlapping layer opacity dropped, even-odd fills kept as fillType=evenOdd, diamond gradients left out)"},
- "colorProfile": {"type": "string", "enum": ["sameAsFile", "srgb", "displayP3"], "description": "PNG, JPG and WebP: the color profile of the file (default sameAsFile: the document's); colors are converted and out-of-gamut ones clip. SVG, PDF and XML are always sRGB"},
+ "colorProfile": {"type": "string", "enum": ["sameAsFile", "srgb", "displayP3"], "description": "PNG, JPG and WebP: the color profile of the file (default sameAsFile: the document's); colors are converted and out-of-gamut ones clip. SVG, XML and RGB PDF output are always sRGB; PDF with cmyk is DeviceCMYK"},
"quality": {"type": "integer", "minimum": 1, "maximum": 100, "description": "JPG: the encoder's quality, 1 (smallest file) to 100; default 90"},
}),
&[],
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index bce90b008..335115ad1 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -54,12 +54,11 @@ 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"; 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; quality 1 to 100 (`quality` on `file.export`, export settings and MCP `export`; default 90; the Export panel has no control for it yet); the file's colour profile embedded as ICC (APP2), sRGB too | — |
+| JPG | yes | yes | white where transparent; quality 1 to 100 (`quality` on `file.export`, export settings and MCP `export`; default 90; the Export panel has no control for it yet); a color profile per setting as for PNG (`colorProfile`), the file tagged with an ICC profile (APP2) for sRGB and for Display P3 | — |
| 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; a color profile per setting (Same as file, the default, sRGB or Display P3: `colorProfile`), the pixels converted and the file tagged (`sRGB` chunk, or ICC and `cICP`) | — |
-| JPG | yes | yes | white where transparent; fixed quality (90); a color profile per setting as for PNG (sRGB untagged, Display P3 with its ICC profile) | — |
| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `` with one `` 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; RGB is always sRGB (no color profile setting and no output intent) | CMYK output intent 2–4 |
-| WebP | — | yes (ours) | a color profile per setting as for JPG | — |
+| WebP | — | yes (ours) | a color profile per setting as for PNG; sRGB files untagged, Display P3 files with the generated ICC profile | — |
| Android Vector Drawable (XML) | — | yes (ours) | an `.xml` drawable resource (`file.export {format: "xml"}`, export settings, File ▸ Export…, the CLI and MCP), pretty-printed, sized in dp with the viewport in layer units, for AndroidX (VectorDrawableCompat: gradients and even-odd need API 24 without it). Shapes, booleans and text as path data with transforms baked in; solid fills and linear, radial and sweep gradients (`aapt:attr`, exact for any handles: radial and sweep in a `` that maps the unit circle); centred strokes as stroke attributes where Android draws them the same (uniform scale, one cap, no dashes or variable width), other strokes as filled outlines; clipping frames and masks as `` with ``; `android:name` from layer names with the id attribute option. What the format can't hold goes by the compatibility mode (`vdCompat`): best effort (default) applies layer opacity to each shape, rewrites even-odd fills and clip outlines as non-zero, draws diamond gradients as radial and alpha masks as outline clips, each with a warning; exact leaves out what it can't draw exactly, with a warning; ignore writes what maps without warnings: layer opacity over overlapping shapes is dropped (drawn opaque), even-odd fills keep `fillType="evenOdd"`, even-odd clip outlines clip as non-zero, diamond gradients are left out and alpha masks clip to their outline. Effects, image and pattern fills and blend modes are always left out (warned except in ignore); an export that draws nothing writes one empty path (Android refuses a drawable without a path), with a warning. Checked by parsing the XML, against FigCraft's renderer (gradients pixel by pixel), with `aapt2 compile` and `link`, and by rendering samples (linear, skewed radial and sweep gradients, a sweep with mirrored handles, even-odd booleans, centred and inside strokes, rotated text, a clipping frame, an alpha mask, a translucent group, each compatibility mode) with Android 13's (API 33) VectorDrawable on an emulator, next to FigCraft's PNG export: they match, including where the sweep starts and which way it turns, except what the compatibility mode and its warnings say (best effort: overlapping shapes in a translucent group look denser; exact and ignore leave out or draw opaque what they list). Not checked on API 21–23 (VectorDrawableCompat) | — |
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML, React, Vue, Leptos, ArkUI and Flutter | see [dev-mode-parity](dev-mode-parity.md) | — |
@@ -193,7 +192,7 @@ source paint coordinates.
| 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). |
| 2026-11-04 | minor | `.fig` and `.jam` import, #1910: an elbowed connector saved with its path keeps it. FigJam stores `connectorControlPoints`, one per run between the first and the last, each a point on its run (in the connector's parent's coordinates) with the run's axis; the first run leaves the start's side and the last arrives at the end's. They become the `ConnectorPath::Runs` a board connector reshaped by hand has (`figcraft_doc::route_along`), so a connector drawn around a layer in FigJam stays around it instead of being routed through it. Points that don't make such a path (off both axes, two runs in a row the same way, a first or last run sideways to its side or going back into its object, more runs than a hand could make) leave the connector to the router as before, and only connectors routed here count in the "follow FigCraft's own routing" note. A curved connector's `connectorBezierControlPoints` are still not read. Tests: `tail_tests::an_elbow_connector_saved_with_its_path_keeps_it`, `tail_tests::an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here`. |
| 2026-11-01 | patch | `.fig` and `.jam` import: a connector without a saved snapshot (elbowed and curved ones are routed here) took no stroke, so it was invisible (#1807); its line's paint, weight, cap and dashes now come from the file's generated line record, as a saved snapshot's do, and the connector's own values, when it has any, win. Test: `fig::connector_label_tests::a_routed_connector_takes_its_line_style_from_the_generated_record`. Numbers unchanged |
-| 2026-10-31 | minor | Export settings: each PNG, JPG and WebP setting has a color profile, Same as file (the default; the document's profile when it exports), sRGB or Display P3, in its advanced settings and as `colorProfile` on `export.add`, `export.update`, `file.export`, `file.exportLayers` and MCP `export`. The pixels convert to the chosen profile (out-of-gamut colors clip) and the file is tagged as before: sRGB PNG with an `sRGB` chunk, Display P3 with the generated ICC profile (and `cICP` in PNG); sRGB JPG and WebP stay untagged. A setting keeps its choice when its format changes; settings saved without one read as Same as file. SVG, PDF and XML show no choice and stay sRGB (PDF has no output intent yet). Numbers unchanged |
+| 2026-10-31 | minor | Export settings: each PNG, JPG and WebP setting has a color profile, Same as file (the default; the document's profile when it exports), sRGB or Display P3, in its advanced settings and as `colorProfile` on `export.add`, `export.update`, `file.export`, `file.exportLayers` and MCP `export`. The pixels convert to the chosen profile (out-of-gamut colors clip) and the file is tagged as before: sRGB PNG with an `sRGB` chunk, Display P3 with the generated ICC profile (and `cICP` in PNG); sRGB JPG embeds the sRGB ICC profile (#2004) and sRGB WebP stays untagged. A setting keeps its choice when its format changes; settings saved without one read as Same as file. SVG, PDF and XML show no choice and stay sRGB (PDF has no output intent yet). Numbers unchanged |
| 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged |
| 2026-10-30 | minor | `.fig` import: a connector's label without a saved snapshot was skipped; now the connector imports as a frame holding its line and the label, as a connector with a saved snapshot does, the label centred on the line's middle by length (in a 200-wide box, laid out again with the fonts at hand), in the connector's fill paints or black. Its background isn't known without the snapshot and is left out. Not checked on real files. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` |
| 2026-10-30 | minor | Pasted content row: an image the clipboard doesn't carry keeps its name and shows a placeholder that reads as missing; the paste names those layers in its message and opens the import report with its notes (#1646). Numbers unchanged |
diff --git a/docs/mcp.md b/docs/mcp.md
index aebcbf46d..11945a76c 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -255,7 +255,7 @@ Or in a project's `.mcp.json`:
profile into the file's space: an embedded ICC profile (PNG, JPG, WebP, TIFF; RGB or grey), a PNG's
`sRGB` / `gAMA` / `cHRM` chunks, else sRGB (also for CMYK or damaged profiles); `image.place` reports
the profile's name as `imageProfile` (`null`: none usable, taken as sRGB). PNG exports are tagged (`sRGB` chunk, or a Display P3 ICC profile plus
- `cICP`), Display P3 JPG and WebP embed the ICC profile, and SVG and PDF export Display P3 files
+ `cICP`), JPG embeds the ICC profile for sRGB and for Display P3, WebP embeds it for Display P3 only (sRGB WebP is untagged), and SVG and PDF export Display P3 files
converted to sRGB (out-of-gamut colours clip). The app's canvas shows sRGB (P3 colours outside sRGB clip on screen):
`view.proofSrgb {on?}` (View ▸ Soft-proof sRGB) stripes those pixels so `screenshot {window:
true}` shows where they are (after the next canvas render), and the colour picker says Outside
@@ -380,7 +380,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
| `inspect_document {depth, allPages, fields?, outline?}` | The document as JSON (its `id`, layer tree, styles, selection, history); `fields` selects layer properties, `outline: true` gives a compact tree (below). The tools that act on a document also take `document` (see Naming the document below). |
| `get_node {id, depth, fields?, outline?}` | One layer as JSON; takes the same `fields` and `outline` options; `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?, vdCompat?, colorProfile?, quality?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`; `quality` is the JPG quality, 1 to 100 (default 90). 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 ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline; png, jpg and webp: `colorProfile` is `sameAsFile` (default: the document's profile), `srgb` or `displayP3`, the pixels converted (out-of-gamut colors clip) and the file tagged for it (SVG, PDF and XML are always sRGB). 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, XML as text followed by `{format, bytes, warnings}`. 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 `.`, 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?, vdCompat?, colorProfile?, quality?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`; `quality` is the JPG quality, 1 to 100 (default 90). 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 ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline; png, jpg and webp: `colorProfile` is `sameAsFile` (default: the document's profile), `srgb` or `displayP3`, the pixels converted (out-of-gamut colors clip) and the file tagged for it (PNG: `sRGB` chunk, or ICC and `cICP` for Display P3; JPG: ICC profile; WebP: ICC profile for Display P3, none for sRGB) (SVG, XML and RGB PDF output are always sRGB; PDF with `cmyk` is DeviceCMYK). 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, XML as text followed by `{format, bytes, warnings}`. 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 `.`, 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?, reload?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` or FigJam `.jam` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open gives its `index`, with `stale: true` when the file changed on disk since that tab read or saved it; `reload: true` reads it again into the tab, refused while the tab has unsaved changes) and the document's `document` outline. |
| `undo` / `redo` | Step through history. |
From 12ade9c78c62783dfe539bbbfb62c1fbae03d9ab Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Fri, 6 Nov 2026 23:31:02 -0800
Subject: [PATCH 002/103] Docs: the 2026-10-31 export entry keeps the JPG
tagging it had on that date
---
docs/file-format-parity.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index 335115ad1..0d51910f0 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -192,7 +192,7 @@ source paint coordinates.
| 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). |
| 2026-11-04 | minor | `.fig` and `.jam` import, #1910: an elbowed connector saved with its path keeps it. FigJam stores `connectorControlPoints`, one per run between the first and the last, each a point on its run (in the connector's parent's coordinates) with the run's axis; the first run leaves the start's side and the last arrives at the end's. They become the `ConnectorPath::Runs` a board connector reshaped by hand has (`figcraft_doc::route_along`), so a connector drawn around a layer in FigJam stays around it instead of being routed through it. Points that don't make such a path (off both axes, two runs in a row the same way, a first or last run sideways to its side or going back into its object, more runs than a hand could make) leave the connector to the router as before, and only connectors routed here count in the "follow FigCraft's own routing" note. A curved connector's `connectorBezierControlPoints` are still not read. Tests: `tail_tests::an_elbow_connector_saved_with_its_path_keeps_it`, `tail_tests::an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here`. |
| 2026-11-01 | patch | `.fig` and `.jam` import: a connector without a saved snapshot (elbowed and curved ones are routed here) took no stroke, so it was invisible (#1807); its line's paint, weight, cap and dashes now come from the file's generated line record, as a saved snapshot's do, and the connector's own values, when it has any, win. Test: `fig::connector_label_tests::a_routed_connector_takes_its_line_style_from_the_generated_record`. Numbers unchanged |
-| 2026-10-31 | minor | Export settings: each PNG, JPG and WebP setting has a color profile, Same as file (the default; the document's profile when it exports), sRGB or Display P3, in its advanced settings and as `colorProfile` on `export.add`, `export.update`, `file.export`, `file.exportLayers` and MCP `export`. The pixels convert to the chosen profile (out-of-gamut colors clip) and the file is tagged as before: sRGB PNG with an `sRGB` chunk, Display P3 with the generated ICC profile (and `cICP` in PNG); sRGB JPG embeds the sRGB ICC profile (#2004) and sRGB WebP stays untagged. A setting keeps its choice when its format changes; settings saved without one read as Same as file. SVG, PDF and XML show no choice and stay sRGB (PDF has no output intent yet). Numbers unchanged |
+| 2026-10-31 | minor | Export settings: each PNG, JPG and WebP setting has a color profile, Same as file (the default; the document's profile when it exports), sRGB or Display P3, in its advanced settings and as `colorProfile` on `export.add`, `export.update`, `file.export`, `file.exportLayers` and MCP `export`. The pixels convert to the chosen profile (out-of-gamut colors clip) and the file is tagged as before: sRGB PNG with an `sRGB` chunk, Display P3 with the generated ICC profile (and `cICP` in PNG); sRGB JPG and WebP stay untagged (builds before 2026-11-04; from that date sRGB JPGs embed the sRGB ICC profile, see the 2026-11-04 JPG export entry). A setting keeps its choice when its format changes; settings saved without one read as Same as file. SVG, PDF and XML show no choice and stay sRGB (PDF has no output intent yet). Numbers unchanged |
| 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged |
| 2026-10-30 | minor | `.fig` import: a connector's label without a saved snapshot was skipped; now the connector imports as a frame holding its line and the label, as a connector with a saved snapshot does, the label centred on the line's middle by length (in a 200-wide box, laid out again with the fonts at hand), in the connector's fill paints or black. Its background isn't known without the snapshot and is left out. Not checked on real files. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` |
| 2026-10-30 | minor | Pasted content row: an image the clipboard doesn't carry keeps its name and shows a placeholder that reads as missing; the paste names those layers in its message and opens the import report with its notes (#1646). Numbers unchanged |
From 386c6c1d3eb923459111e9df0fb75c231a5c3722 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Sat, 7 Nov 2026 13:45:31 +0100
Subject: [PATCH 003/103] Reject negative and fractional index in
image.savePreset
Found while testing crates/engine/src/cmd/image.rs:239 (save_preset, `index = num(p, "index").filter(..).map(|k| k as usize)`)
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/engine/src/cmd/image.rs | 7 +++++--
crates/engine/src/paint_tests.rs | 10 ++++++++++
2 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/crates/engine/src/cmd/image.rs b/crates/engine/src/cmd/image.rs
index 865e4d19a..37472b2d4 100644
--- a/crates/engine/src/cmd/image.rs
+++ b/crates/engine/src/cmd/image.rs
@@ -5,7 +5,7 @@ use figcraft_doc::{Document, ImagePreset, Node};
use serde_json::{Value, json};
use super::{CommandSpec, ok};
-use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets};
+use crate::params::{f64_of, get, req_index, req_string, req_targets, string, targets};
use crate::{EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec {
@@ -236,7 +236,10 @@ fn save_preset(s: &mut Session, p: &Value) -> Result {
Some(v) => with_values(cmd, ImageFilters::NONE, v)?,
None => {
let stroke = strokes(cmd, p)?;
- let index = num(p, "index").filter(|k| k.is_finite() && *k >= 0.0).map(|k| k as usize);
+ let index = match get(p, "index") {
+ None | Some(Value::Null) => None,
+ Some(_) => Some(req_index(cmd, p, "index")?),
+ };
let doc = s.doc()?;
targets(s, p)
.iter()
diff --git a/crates/engine/src/paint_tests.rs b/crates/engine/src/paint_tests.rs
index 1e97b05ca..00ada8d79 100644
--- a/crates/engine/src/paint_tests.rs
+++ b/crates/engine/src/paint_tests.rs
@@ -135,6 +135,16 @@ fn image_adjust_refuses_what_it_cannot_do() {
assert_eq!(s.execute("image.adjust", &json!({"ids": [plain, r], "index": 1, "preset": "vivid"})).unwrap()["changed"], 1);
}
+#[test]
+fn save_preset_rejects_bad_index() {
+ let mut s = Session::new();
+ let r = image_rect(&mut s);
+ assert!(s.execute("image.savePreset", &json!({"ids": [r], "name": "B", "index": 0})).is_err());
+ assert!(s.execute("image.savePreset", &json!({"ids": [r], "name": "B", "index": -1})).is_err(), "negative index accepted");
+ assert!(s.execute("image.savePreset", &json!({"ids": [r], "name": "B", "index": 1.5})).is_err(), "fractional index accepted");
+ assert!(s.execute("image.savePreset", &json!({"ids": [r], "name": "B", "index": 1})).is_ok());
+}
+
#[test]
fn saved_image_presets() {
let mut s = Session::new();
From b33da8a7b0f3dd92d0a2a88ec22ab72f9d028a1d Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 11:52:52 -0500
Subject: [PATCH 004/103] Ellipse: an unsliced ellipse edits as four anchor
points, not six (#2080)
A whole ellipse and each ring contour are built as four cubics through the extremes instead of a tolerance-subdivided arc. Sliced ellipses are unchanged.
Co-Authored-By: Claude Fable 5.1
---
crates/geom/src/lib.rs | 23 ++++++++++++++++-----
crates/geom/src/tests.rs | 43 +++++++++++++++++++++++++++++++++++++++-
2 files changed, 60 insertions(+), 6 deletions(-)
diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs
index a775a4170..f290882d8 100644
--- a/crates/geom/src/lib.rs
+++ b/crates/geom/src/lib.rs
@@ -472,15 +472,28 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B
let mut path = BezPath::new();
// A whole turn as an arc with these radii: kurbo's `Ellipse` recovers its radii from an
// affine map, which overflows to infinity for radii near f64's limit (and infinite cubics).
- let whole = |rx: f64, ry: f64| kurbo::Arc::new(c, (rx, ry), 0.0, TAU, 0.0);
+ // Four cubics through the right, bottom, left and top extremes (clockwise from 3 o'clock),
+ // the same four anchors an ellipse has when edited as a vector; an arc subdivided by
+ // tolerance would give five or six.
+ let whole = |rx: f64, ry: f64| {
+ const K: f64 = 0.552_284_749_830_793_4;
+ let (kx, ky) = (rx * K, ry * K);
+ let mut p = BezPath::new();
+ p.move_to(c + Vec2::new(rx, 0.0));
+ p.curve_to(c + Vec2::new(rx, ky), c + Vec2::new(kx, ry), c + Vec2::new(0.0, ry));
+ p.curve_to(c + Vec2::new(-kx, ry), c + Vec2::new(-rx, ky), c + Vec2::new(-rx, 0.0));
+ p.curve_to(c + Vec2::new(-rx, -ky), c + Vec2::new(-kx, -ry), c + Vec2::new(0.0, -ry));
+ p.curve_to(c + Vec2::new(kx, -ry), c + Vec2::new(rx, -ky), c + Vec2::new(rx, 0.0));
+ p
+ };
if full && inner <= 0.0 {
- return whole(rx, ry).to_path(tol);
+ return whole(rx, ry);
}
if full {
// A ring: outer clockwise, inner counter-clockwise.
- path.extend(whole(rx, ry).path_elements(tol));
- let inner_path = whole(rx * inner, ry * inner).to_path(tol);
- path.extend(inner_path.reverse_subpaths());
+ path.extend(whole(rx, ry).elements().iter().copied());
+ let inner_path = whole(rx * inner, ry * inner);
+ path.extend(inner_path.reverse_subpaths().elements().iter().copied());
return path;
}
let outer = kurbo::Arc::new(c, (rx, ry), start, sweep, 0.0);
diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs
index 459b2b101..909a3c475 100644
--- a/crates/geom/src/tests.rs
+++ b/crates/geom/src/tests.rs
@@ -263,7 +263,7 @@ fn smoothed_polygon_smooths_every_corner() {
#[test]
fn ellipse_ring_area() {
let full = area(&ellipse(100.0, 100.0, 0.0, TAU, 0.0));
- assert!((full - PI * 2500.0).abs() < 2.0);
+ assert!((full - PI * 2500.0).abs() < 5.0, "{full}");
let ring = ellipse(100.0, 100.0, 0.0, TAU, 0.5);
// Winding area of a ring with a reversed inner contour: outer - inner.
let a = ring.area().abs();
@@ -321,6 +321,47 @@ fn reverse_subpaths_preserves_open_and_closed() {
assert!(matches!(r.elements().last(), Some(kurbo::PathEl::ClosePath)), "{:?}", r.elements());
}
+fn cubics(path: &BezPath) -> Vec {
+ path.segments()
+ .filter_map(|s| match s {
+ kurbo::PathSeg::Cubic(c) => Some(c),
+ _ => None,
+ })
+ .collect()
+}
+
+#[test]
+fn a_whole_ellipse_is_four_cubics_through_the_extremes() {
+ for (w, h) in [(100.0, 100.0), (200.0, 60.0), (3.0, 900.0)] {
+ let segs = cubics(&ellipse(w, h, 0.0, TAU, 0.0));
+ assert_eq!(segs.len(), 4, "{w}x{h}");
+ let (rx, ry) = (w / 2.0, h / 2.0);
+ let want = [Point::new(rx, h), Point::new(0.0, ry), Point::new(rx, 0.0), Point::new(w, ry)];
+ for (i, s) in segs.iter().enumerate() {
+ assert!((s.p3 - want[i]).hypot() < 1e-9, "{w}x{h}: anchor {i} is {:?}", s.p3);
+ // Handle length is 0.5523 of the radius along the tangent.
+ let k = 0.552_284_749_830_793_4;
+ let (r_along, r_across) = if i % 2 == 0 { (ry, rx) } else { (rx, ry) };
+ let _ = r_across;
+ assert!(((s.p1 - s.p0).hypot() - k * r_along).abs() < 1e-9, "{w}x{h}: handle {i}");
+ }
+ }
+}
+
+#[test]
+fn a_whole_ring_is_four_cubics_per_contour() {
+ let segs = cubics(&ellipse(100.0, 100.0, 0.0, TAU, 0.5));
+ assert_eq!(segs.len(), 8);
+}
+
+#[test]
+fn a_sliced_ellipse_still_builds_its_arc() {
+ let pie = ellipse(100.0, 100.0, 0.0, PI, 0.0);
+ assert!(!cubics(&pie).is_empty());
+ assert!(matches!(pie.elements().last(), Some(kurbo::PathEl::ClosePath)));
+ assert!((area(&pie) - PI * 1250.0).abs() < 2.0);
+}
+
#[test]
fn a_huge_ellipse_arc_is_a_few_cubics() {
let big = ellipse(1e300, 1e300, 0.3, 2.0, 0.5);
From 4118be890c2e15c9183293104a8a7e20e77e25b7 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 12:42:44 -0500
Subject: [PATCH 005/103] Ellipse: the four-cubic contour is closed, and the
band test allows apex antialiasing (#2080)
The band test compared row 10, the ellipse top, where the cubic now ends exactly at its level extreme; 8 pixels antialias slightly differently between a short band and the whole view. The row mean limit now grows by 0.05 per such pixel; whole-row artifacts still fail.
Co-Authored-By: Claude Fable 5.1
---
crates/geom/src/lib.rs | 1 +
crates/render/src/band_tests.rs | 3 ++-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs
index f290882d8..41669488f 100644
--- a/crates/geom/src/lib.rs
+++ b/crates/geom/src/lib.rs
@@ -484,6 +484,7 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B
p.curve_to(c + Vec2::new(-kx, ry), c + Vec2::new(-rx, ky), c + Vec2::new(-rx, 0.0));
p.curve_to(c + Vec2::new(-rx, -ky), c + Vec2::new(-kx, -ry), c + Vec2::new(0.0, -ry));
p.curve_to(c + Vec2::new(kx, -ry), c + Vec2::new(rx, -ky), c + Vec2::new(rx, 0.0));
+ p.close_path();
p
};
if full && inner <= 0.0 {
diff --git a/crates/render/src/band_tests.rs b/crates/render/src/band_tests.rs
index b9a7bfbcc..e01fad5d7 100644
--- a/crates/render/src/band_tests.rs
+++ b/crates/render/src/band_tests.rs
@@ -83,7 +83,8 @@ fn bands_match_one_piece_rendering() {
assert_eq!((banded.width(), banded.height()), (one.width(), one.height()));
for (y, (mean, off)) in row_diffs(&one, &banded).into_iter().enumerate() {
assert!(
- mean < 0.25 && off * 20 <= v.width as usize,
+ // Each pixel antialiased differently (the level top of an ellipse, where a cubic ends at its extreme) may add up to 0.05 to the row's mean.
+ mean < 0.25 + 0.05 * off as f64 && off * 20 <= v.width as usize,
"scale {scale}, {bands} bands: row {y} differs ({off} pixels, {mean:.2} on average)"
);
}
From 0774e44fa8c4218821a300e341237cfcb071f822 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 13:41:25 -0500
Subject: [PATCH 006/103] Render band test: only small antialiasing differences
are forgiven, a real band artifact still fails (#2080)
Pixels whose largest channel differs by 24 or less are noise and left out of the row mean, which keeps its original 0.25 limit; the differing-pixel limit is unchanged. A unit test shows a 44-pixel row off by 60 still fails.
Co-Authored-By: Claude Fable 5.1
---
crates/render/src/band_tests.rs | 68 ++++++++++++++++++++++++---------
1 file changed, 51 insertions(+), 17 deletions(-)
diff --git a/crates/render/src/band_tests.rs b/crates/render/src/band_tests.rs
index e01fad5d7..4ea9fb1f6 100644
--- a/crates/render/src/band_tests.rs
+++ b/crates/render/src/band_tests.rs
@@ -56,20 +56,58 @@ fn view(scale: f64) -> View {
View { origin: (-10.0, -10.0), scale, width: (440.0 * scale) as u32, height: (240.0 * scale) as u32 }
}
-/// Per row: the mean channel difference and how many pixels differ by more than 2. A band artifact
-/// differs along a whole row; edge antialiasing may differ in a few pixels per edge (a curve
-/// clipped at a band's edge rasterises a hair differently, most where it runs nearly level).
+/// A pixel whose largest channel differs by at most this much between two renders is edge
+/// antialiasing noise (a curve clipped at a band's edge rasterises a hair differently, most where
+/// it runs level, as at the top of an ellipse), not a band artifact.
+const NOISE: u8 = 24;
+
+/// One row of two renders: the mean channel difference over the pixels that are not noise (see
+/// `NOISE`), and how many pixels differ by more than 2 at all. A band artifact differs along a
+/// whole row, by more than noise.
+fn row_stats(x: &[u8], y: &[u8]) -> (f64, usize) {
+ let mut sum = 0.0;
+ let mut off = 0;
+ for (p, q) in x.chunks(4).zip(y.chunks(4)) {
+ let worst = p.iter().zip(q).map(|(c, d)| c.abs_diff(*d)).max().unwrap_or(0);
+ if worst > 2 {
+ off += 1;
+ }
+ if worst > NOISE {
+ sum += p.iter().zip(q).map(|(c, d)| f64::from(c.abs_diff(*d))).sum::();
+ }
+ }
+ (sum / x.len().max(1) as f64, off)
+}
+
fn row_diffs(a: &Pixmap, b: &Pixmap) -> Vec<(f64, usize)> {
let row = a.width() as usize * 4;
- a.data()
- .chunks(row)
- .zip(b.data().chunks(row))
- .map(|(x, y)| {
- let sum: f64 = x.iter().zip(y).map(|(p, q)| f64::from(p.abs_diff(*q))).sum();
- let off = x.chunks(4).zip(y.chunks(4)).filter(|(p, q)| p.iter().zip(*q).any(|(c, d)| c.abs_diff(*d) > 2)).count();
- (sum / row as f64, off)
- })
- .collect()
+ a.data().chunks(row).zip(b.data().chunks(row)).map(|(x, y)| row_stats(x, y)).collect()
+}
+
+/// Whether a row passes: the original mean limit over non-noise pixels, and at most a twentieth
+/// of the row differing at all.
+fn row_ok(mean: f64, off: usize, width: usize) -> bool {
+ mean < 0.25 && off * 20 <= width
+}
+
+#[test]
+fn a_real_band_artifact_fails_but_edge_noise_passes() {
+ let width = 880; // scale 2
+ let flat = vec![100u8; width * 4];
+ // 44 pixels differing by 60 per channel: under the old mean allowance, but a real artifact.
+ let mut artifact = flat.clone();
+ for px in artifact.chunks_mut(4).take(44) {
+ px.iter_mut().for_each(|c| *c += 60);
+ }
+ let (mean, off) = row_stats(&flat, &artifact);
+ assert!(!row_ok(mean, off, width), "{mean} {off}");
+ // 8 pixels differing by 16 (the ellipse top's antialiasing): forgiven.
+ let mut noise = flat.clone();
+ for px in noise.chunks_mut(4).take(8) {
+ px.iter_mut().for_each(|c| *c += 16);
+ }
+ let (mean, off) = row_stats(&flat, &noise);
+ assert!(row_ok(mean, off, width), "{mean} {off}");
}
#[test]
@@ -82,11 +120,7 @@ fn bands_match_one_piece_rendering() {
let banded = render_bands(&d, p, &v, &Options::default(), bands).unwrap();
assert_eq!((banded.width(), banded.height()), (one.width(), one.height()));
for (y, (mean, off)) in row_diffs(&one, &banded).into_iter().enumerate() {
- assert!(
- // Each pixel antialiased differently (the level top of an ellipse, where a cubic ends at its extreme) may add up to 0.05 to the row's mean.
- mean < 0.25 + 0.05 * off as f64 && off * 20 <= v.width as usize,
- "scale {scale}, {bands} bands: row {y} differs ({off} pixels, {mean:.2} on average)"
- );
+ assert!(row_ok(mean, off, v.width as usize), "scale {scale}, {bands} bands: row {y} differs ({off} pixels, {mean:.2} on average)");
}
}
}
From 97f086e091d712548a3415bceb344284b62302b8 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Sat, 7 Nov 2026 16:36:59 -0800
Subject: [PATCH 007/103] Docs: the Display P3 export sentence names RGB PDF
and says CMYK PDF ends as DeviceCMYK
The colour profile paragraph in docs/mcp.md said SVG and PDF export
Display P3 files converted to sRGB, without the RGB qualifier the export
tool text now carries. It now says SVG and RGB PDF convert to sRGB, and
PDF with `cmyk` converts to sRGB the same way and then to DeviceCMYK.
The comment above the export match in file.rs says the same.
---
crates/engine/src/cmd/file.rs | 3 ++-
docs/mcp.md | 4 ++--
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index e52960d43..ca83292ec 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -372,7 +372,8 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal
&& !setting.include_bounding_box
&& !ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)));
match setting.format {
- // SVG and PDF colours are sRGB: a Display P3 file exports converted (out-of-gamut colours clip).
+ // SVG and RGB PDF colors are sRGB: a Display P3 file exports converted (out-of-gamut colours clip).
+ // PDF with `cmyk` converts from those sRGB colors to DeviceCMYK.
ExportFormat::Svg => {
let opts = figcraft_svg::SvgOptions {
include_ids: setting.svg_id_attribute,
diff --git a/docs/mcp.md b/docs/mcp.md
index 11945a76c..5b4efa3f7 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -255,8 +255,8 @@ Or in a project's `.mcp.json`:
profile into the file's space: an embedded ICC profile (PNG, JPG, WebP, TIFF; RGB or grey), a PNG's
`sRGB` / `gAMA` / `cHRM` chunks, else sRGB (also for CMYK or damaged profiles); `image.place` reports
the profile's name as `imageProfile` (`null`: none usable, taken as sRGB). PNG exports are tagged (`sRGB` chunk, or a Display P3 ICC profile plus
- `cICP`), JPG embeds the ICC profile for sRGB and for Display P3, WebP embeds it for Display P3 only (sRGB WebP is untagged), and SVG and PDF export Display P3 files
- converted to sRGB (out-of-gamut colours clip). The app's canvas shows sRGB (P3 colours outside sRGB clip on screen):
+ `cICP`), JPG embeds the ICC profile for sRGB and for Display P3, WebP embeds it for Display P3 only (sRGB WebP is untagged), and SVG and RGB PDF export Display P3 files
+ converted to sRGB (out-of-gamut colours clip). PDF with `cmyk` converts them to sRGB the same way and then to DeviceCMYK. The app's canvas shows sRGB (P3 colours outside sRGB clip on screen):
`view.proofSrgb {on?}` (View ▸ Soft-proof sRGB) stripes those pixels so `screenshot {window:
true}` shows where they are (after the next canvas render), and the colour picker says Outside
sRGB for such a colour.
From 0c1fb11c4800d98090c00b4e531cec81e12507b0 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 19:37:16 -0500
Subject: [PATCH 008/103] Render band test: the comments say what row_stats
divides by and what the artifact case measures (#2080)
row_stats sums the differences of the pixels above noise and divides by the whole row's channel count; the doc now says so. The artifact case changes only the colour channels, so its mean is 2.25, which the earlier allowance let through, and its comment now says it fails on the mean while its count stays within a twentieth of the row.
Co-Authored-By: Claude Opus 5.5
Model-Effort: Opus 5.5 drove and wrote at high; no brief
---
crates/render/src/band_tests.rs | 18 +++++++++++-------
1 file changed, 11 insertions(+), 7 deletions(-)
diff --git a/crates/render/src/band_tests.rs b/crates/render/src/band_tests.rs
index 4ea9fb1f6..9fcf29f20 100644
--- a/crates/render/src/band_tests.rs
+++ b/crates/render/src/band_tests.rs
@@ -61,9 +61,10 @@ fn view(scale: f64) -> View {
/// it runs level, as at the top of an ellipse), not a band artifact.
const NOISE: u8 = 24;
-/// One row of two renders: the mean channel difference over the pixels that are not noise (see
-/// `NOISE`), and how many pixels differ by more than 2 at all. A band artifact differs along a
-/// whole row, by more than noise.
+/// One row of two renders: the channel differences of the pixels above noise (see `NOISE`) summed
+/// and divided by the whole row's channel count, so noise pixels add nothing but still count in
+/// the denominator; and how many pixels differ by more than 2 at all. A band artifact differs along
+/// a whole row, by more than noise.
fn row_stats(x: &[u8], y: &[u8]) -> (f64, usize) {
let mut sum = 0.0;
let mut off = 0;
@@ -84,8 +85,8 @@ fn row_diffs(a: &Pixmap, b: &Pixmap) -> Vec<(f64, usize)> {
a.data().chunks(row).zip(b.data().chunks(row)).map(|(x, y)| row_stats(x, y)).collect()
}
-/// Whether a row passes: the original mean limit over non-noise pixels, and at most a twentieth
-/// of the row differing at all.
+/// Whether a row passes: the original 0.25 limit on the mean from `row_stats`, and at most a
+/// twentieth of the row differing at all.
fn row_ok(mean: f64, off: usize, width: usize) -> bool {
mean < 0.25 && off * 20 <= width
}
@@ -94,10 +95,13 @@ fn row_ok(mean: f64, off: usize, width: usize) -> bool {
fn a_real_band_artifact_fails_but_edge_noise_passes() {
let width = 880; // scale 2
let flat = vec![100u8; width * 4];
- // 44 pixels differing by 60 per channel: under the old mean allowance, but a real artifact.
+ // 44 pixels whose colour channels differ by 60 (alpha unchanged): the mean is
+ // 44 * 3 * 60 / (880 * 4) = 2.25, which the earlier `0.25 + 0.05 * off` allowance (2.45 here)
+ // let through; the count, 44, is within a twentieth of the row. A real artifact, so it fails
+ // on the mean now.
let mut artifact = flat.clone();
for px in artifact.chunks_mut(4).take(44) {
- px.iter_mut().for_each(|c| *c += 60);
+ px.iter_mut().take(3).for_each(|c| *c += 60);
}
let (mean, off) = row_stats(&flat, &artifact);
assert!(!row_ok(mean, off, width), "{mean} {off}");
From f5a52ea528e3e597e753f458d4fde92422b4583c Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 21:20:56 -0500
Subject: [PATCH 009/103] Components: several layers selected, the component
button offers Create multiple components (#2088)
With two or more layers selected, the Design panel header's component button opens a menu with Create component and Create multiple components, as Figma's does; one layer keeps the plain button. Headless UI tests cover both cases (three frames: menu order, names kept, all selected, one undo; one frame: plain click), and an engine test pins createMultiple's names, positions and single undo step.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
---
.../engine/src/components_behaviour_tests.rs | 33 +++++
.../ui-egui/src/create_multiple_menu_tests.rs | 115 ++++++++++++++++++
crates/ui-egui/src/lib.rs | 2 +
crates/ui-egui/src/panels/right.rs | 13 ++
docs/components-parity.md | 5 +-
5 files changed, 166 insertions(+), 2 deletions(-)
create mode 100644 crates/ui-egui/src/create_multiple_menu_tests.rs
diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs
index 3458b0b22..198151768 100644
--- a/crates/engine/src/components_behaviour_tests.rs
+++ b/crates/engine/src/components_behaviour_tests.rs
@@ -7,6 +7,7 @@
use serde_json::{Value, json};
use crate::Session;
+use crate::automation::params::id_of;
use crate::components::tests::{chars, copy_of, ex, fill, healthy, idv, kids, node};
use figcraft_doc::*;
@@ -1000,3 +1001,35 @@ fn deleting_a_layer_inside_an_instance_hides_it_instead() {
s.execute("edit.delete", &json!({"ids": [k.inst]})).unwrap();
assert!(s.doc().unwrap().node(k.inst).is_none());
}
+
+/// Create multiple components (#2088): each selected layer becomes its own component in place,
+/// keeping its name and position; all stay selected and one undo reverts them all.
+#[test]
+fn create_multiple_components_keeps_names_and_places_and_is_one_undo_step() {
+ let mut s = Session::new();
+ let mut frames = Vec::new();
+ for (i, name) in ["Card", "Badge", "Tile"].into_iter().enumerate() {
+ let x = 150.0 * i as f64;
+ let v = s.execute("frame.create", &json!({"x": x, "y": 20, "width": 100, "height": 60})).unwrap();
+ let f = id_of(&v["id"]).unwrap();
+ s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap();
+ frames.push(f);
+ }
+ let before = undo_len(&s);
+ let out = s.execute("component.createMultiple", &json!({"ids": frames})).unwrap();
+ assert_eq!(undo_len(&s), before + 1, "one undo step");
+ let made: Vec = out["ids"].as_array().unwrap().iter().filter_map(id_of).collect();
+ assert_eq!(made.len(), 3);
+ assert_eq!(s.selection(), made, "all of them stay selected");
+ let doc = s.doc().unwrap();
+ for (i, (c, name)) in made.iter().zip(["Card", "Badge", "Tile"]).enumerate() {
+ let n = doc.node(*c).unwrap();
+ assert!(matches!(n.kind, NodeKind::Component { .. }), "{name} is a component");
+ assert_eq!(n.name, name);
+ let b = doc.world_bounds(*c).unwrap();
+ assert_eq!((b.x0, b.y0), (150.0 * i as f64, 20.0), "{name} stays in place");
+ }
+ s.undo().unwrap();
+ let doc = s.doc().unwrap();
+ assert!(frames.iter().all(|f| matches!(doc.node(*f).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo restores the frames");
+}
diff --git a/crates/ui-egui/src/create_multiple_menu_tests.rs b/crates/ui-egui/src/create_multiple_menu_tests.rs
new file mode 100644
index 000000000..a8c2b3a56
--- /dev/null
+++ b/crates/ui-egui/src/create_multiple_menu_tests.rs
@@ -0,0 +1,115 @@
+//! With two or more layers selected the Design panel's component button opens a menu (Create
+//! component, Create multiple components); one layer keeps the plain click (#2088): drawn headless.
+
+use egui::accesskit::Node;
+use egui::{Event, PointerButton, Pos2, Rect, pos2};
+use figcraft_doc::{NodeId, NodeKind};
+use figcraft_engine::Session;
+use serde_json::json;
+
+use crate::layers_drag_tests::Ui;
+
+const NAMES: [&str; 3] = ["Card", "Badge", "Tile"];
+
+fn id(v: serde_json::Value) -> NodeId {
+ serde_json::from_value(v["id"].clone()).unwrap()
+}
+
+/// Frames named `NAMES[..n]`, all selected, headless.
+fn frames(n: usize) -> (Ui, Vec) {
+ let mut s = Session::new();
+ let mut ids = Vec::new();
+ for (i, name) in NAMES.into_iter().take(n).enumerate() {
+ let f = id(s.execute("frame.create", &json!({"x": 150 * i, "y": 0, "width": 100, "height": 60})).unwrap());
+ s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap();
+ ids.push(f);
+ }
+ s.execute("select.set", &json!({"ids": ids})).unwrap();
+ let mut u = Ui::new(s);
+ u.frame(vec![]);
+ (u, ids)
+}
+
+fn nodes(u: &mut Ui) -> Vec {
+ u.ctx.enable_accesskit();
+ u.frame(vec![]);
+ u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect()
+}
+
+fn bounds(n: &Node) -> Rect {
+ let b = n.bounds().unwrap();
+ Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))
+}
+
+fn click(u: &mut Ui, at: Pos2) {
+ for pressed in [true, false] {
+ u.frame(vec![
+ Event::PointerMoved(at),
+ Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() },
+ ]);
+ }
+}
+
+fn settle(u: &mut Ui) {
+ for _ in 0..10 {
+ u.frame(vec![]);
+ }
+}
+
+/// The header's component button (its tooltip may carry the shortcut).
+fn click_button(u: &mut Ui) {
+ let all = nodes(u);
+ let button = all.iter().find(|n| n.label().is_some_and(|l| l.starts_with("Create component"))).expect("the Design panel's component button");
+ click(u, bounds(button).center());
+ settle(u);
+}
+
+fn undo_len(u: &Ui) -> usize {
+ u.app.session.ds().unwrap().history.undo.len()
+}
+
+#[test]
+fn several_layers_open_a_menu_and_create_multiple_components_in_one_undo_step() {
+ let (mut u, ids) = frames(3);
+ let before = undo_len(&u);
+ click_button(&mut u);
+ assert_eq!(undo_len(&u), before, "opening the menu changes nothing");
+ let all = nodes(&mut u);
+ // The menu's rows share an x with Create multiple components: exactly these two, in this order.
+ let multiple = all.iter().find(|n| n.label() == Some("Create multiple components")).expect("the menu's second entry");
+ let mb = bounds(multiple);
+ let mut rows: Vec<(f32, String)> = all
+ .iter()
+ .filter(|n| n.label().is_some() && (bounds(n).min.x - mb.min.x).abs() < 1.0 && (bounds(n).min.y - mb.min.y).abs() < 60.0)
+ .map(|n| (bounds(n).min.y, n.label().unwrap_or("").to_owned()))
+ .collect();
+ rows.sort_by(|a, b| a.0.total_cmp(&b.0));
+ let labels: Vec<&str> = rows.iter().map(|(_, l)| l.as_str()).collect();
+ assert_eq!(labels, ["Create component", "Create multiple components"], "the menu's entries");
+ click(&mut u, bounds(multiple).center());
+ settle(&mut u);
+ let sel = u.app.session.selection();
+ assert_eq!(sel.len(), 3);
+ let doc = u.app.session.doc().unwrap();
+ let names: Vec<&str> = sel.iter().map(|i| doc.node(*i).unwrap().name.as_str()).collect();
+ assert_eq!(names, NAMES);
+ assert!(sel.iter().all(|i| matches!(doc.node(*i).unwrap().kind, NodeKind::Component { .. })));
+ assert_eq!(undo_len(&u), before + 1);
+ u.app.session.undo().unwrap();
+ let doc = u.app.session.doc().unwrap();
+ assert!(ids.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo reverts all three");
+}
+
+#[test]
+fn one_layer_keeps_the_plain_button() {
+ let (mut u, ids) = frames(1);
+ click_button(&mut u);
+ let all = nodes(&mut u);
+ assert!(all.iter().all(|n| n.label() != Some("Create multiple components")), "no menu opens");
+ let sel = u.app.session.selection();
+ assert_eq!(sel.len(), 1);
+ let doc = u.app.session.doc().unwrap();
+ assert!(matches!(doc.node(sel[0]).unwrap().kind, NodeKind::Component { .. }));
+ assert_eq!(doc.node(sel[0]).unwrap().name, NAMES[0]);
+ let _ = ids;
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 11c96da74..b47f05fbb 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -88,6 +88,8 @@ mod context_menu_tests;
#[cfg(test)]
mod corner_individual_tests;
#[cfg(test)]
+mod create_multiple_menu_tests;
+#[cfg(test)]
mod crop_image_tests;
#[cfg(test)]
mod cursors_tests;
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index bba57c778..b408491a2 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -530,6 +530,19 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
ui.close();
}
});
+ } else if cmd == "component.create" && sel.len() > 1 {
+ // Several layers: the button opens a menu, as Figma's does (#2088). One layer keeps the plain click.
+ // TODO: "Create component set" is the third entry, to add once its naming is measured.
+ menu_popup(&bresp).width(220.0).show(|ui| {
+ let multiple = crate::i18n::command("component.createMultiple", "Create multiple components");
+ for (c, label) in [("component.create", tr("Create component")), ("component.createMultiple", &multiple)] {
+ let key = crate::shortcuts::shortcut(c).unwrap_or("");
+ if menu_icon_item(ui, None, None, label, key) {
+ app.run(c, json!({}));
+ ui.close();
+ }
+ }
+ });
} else if bresp.clicked() {
app.run(cmd, json!({}));
}
diff --git a/docs/components-parity.md b/docs/components-parity.md
index d6b1ee5e6..3ecdae292 100644
--- a/docs/components-parity.md
+++ b/docs/components-parity.md
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); 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)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); 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
@@ -12,7 +12,7 @@ accounts).
| Feature | Status | Notes |
|---|---|---|
-| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main |
+| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main; with two or more layers selected the Design panel header's component button opens a menu with Create component and Create multiple components (each layer its own component in place, one undo step), one layer keeps the plain click (#2088); Create component set is not in the menu yet |
| Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances |
| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push |
| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out (Delete on a layer inside an instance hides it instead of removing it, as the help center says it does); the instance itself resizes and its layers follow the main's constraints |
@@ -79,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component and Create multiple components, as Figma's does; one layer keeps the plain button. Create component set is left out until its naming is measured. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`. Grade unchanged. |
| 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |
| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. |
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. |
From 524ad8b1ddfd576ad86dd13d7c1b71f4deaad3e1 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 21:38:30 -0500
Subject: [PATCH 010/103] Components: Create component set in the component
button's menu (#2088)
The third entry of the menu several selected layers get: each layer becomes a component and they are wrapped in a component set in place, 20 px around, named as Figma names them (measured): "Property 1=" in left-to-right order, prop=value names kept, and layers that all share a name give a set of that name with "Default" variants. Engine tests cover the three measured cases, geometry, selection, one undo and the refusal of instances; the UI test checks the three menu entries in order and that the third makes one set.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
---
crates/engine/src/cmd/component.rs | 9 +-
crates/engine/src/cmd/component/variants.rs | 55 ++++++++-
.../engine/src/components_behaviour_tests.rs | 106 ++++++++++++++++++
.../ui-egui/src/create_multiple_menu_tests.rs | 27 ++++-
crates/ui-egui/src/panels/right.rs | 6 +-
docs/components-parity.md | 4 +-
6 files changed, 197 insertions(+), 10 deletions(-)
diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs
index e0b2e02f4..06e6bf166 100644
--- a/crates/engine/src/cmd/component.rs
+++ b/crates/engine/src/cmd/component.rs
@@ -24,7 +24,7 @@ use instance::{detach, make_slot, push_changes, remove_slot, reset_instance, res
pub(crate) use instance::{swap_at, swap_instance};
use props::{add_prop, bind_prop, delete_prop, edit_prop, set_props};
pub(crate) use props::{apply_prop_bindings, check_prop_binding, describe_props};
-use variants::{add_variant, combine};
+use variants::{add_variant, combine, create_set};
pub use variants::{can_add_variant, can_combine_variants};
/// `component.hideWhenPublishing`: the leading `.` of the names of this file's components and
@@ -72,6 +72,13 @@ pub fn specs() -> Vec {
s.set_selection(out.iter().filter_map(id_of).collect());
Ok(json!({"ids": out}))
}),
+ spec!(
+ "component.createSet",
+ "Create component set",
+ "{ids? (layers, not components, sets or instances)} (each becomes a component, wrapped in place in a component set: variants \"Property 1=\" left to right, 20 px around)",
+ true,
+ create_set
+ ),
spec!(
"component.combineVariants",
"Combine as variants",
diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs
index 5f9e218ac..53e00ca6d 100644
--- a/crates/engine/src/cmd/component/variants.rs
+++ b/crates/engine/src/cmd/component/variants.rs
@@ -1,6 +1,6 @@
use super::{
- BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, inside_instance, json, owning_instance,
- req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted,
+ BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, create, id_of, inside_instance, json,
+ owning_instance, req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted,
};
/// Wrap components in a new component set (named after the first) placed around them.
@@ -94,6 +94,57 @@ pub(super) fn combine(s: &mut Session, p: &Value) -> Result {
Ok(json!({"id": set}))
}
+/// `component.createSet`: each target becomes a component (as `component.createMultiple` makes
+/// them) and all are wrapped in place in a component set, 20 px around, named as Figma names them:
+/// variants "Property 1=" in left-to-right order (a name holding `=` is kept); layers
+/// that all share a name give a set of that name with "Property 1=Default" variants; otherwise the
+/// set is "Component N".
+pub(super) fn create_set(s: &mut Session, p: &Value) -> Result {
+ let cmd = "component.createSet";
+ let mut seen = BTreeSet::new();
+ let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect();
+ let doc = s.doc()?;
+ if ids.iter().any(|i| inside_instance(doc, *i)) {
+ return Err(EngineError::Disabled(cmd.into(), "layers inside an instance can't become components".into()));
+ }
+ if ids.iter().any(|i| doc.is_remote(*i)) {
+ return Err(EngineError::Disabled(cmd.into(), crate::library::READ_ONLY.into()));
+ }
+ if ids.iter().any(|i| {
+ doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
+ }) {
+ return Err(EngineError::Disabled(cmd.into(), "select layers that aren't components, component sets or instances".into()));
+ }
+ let mut made = Vec::new();
+ for i in roots(doc, &ids) {
+ let r = create(s, &json!({"ids": [i]}))?;
+ made.push(id_of(&r["id"]).ok_or_else(|| bad(cmd, "component creation failed"))?);
+ }
+ let doc = s.doc_mut()?;
+ // Left to right by world x, ties by y: the variants' order, and the values' order.
+ let mut by_x: Vec<(NodeId, f64, f64)> = made.iter().map(|i| doc.world_bounds(*i).map_or((*i, 0.0, 0.0), |b| (*i, b.x0, b.y0))).collect();
+ by_x.sort_by(|a, b| a.1.total_cmp(&b.1).then(a.2.total_cmp(&b.2)));
+ let names: Vec = by_x.iter().map(|(i, ..)| doc.node(*i).map(|n| n.name.clone()).unwrap_or_default()).collect();
+ let same = names.windows(2).all(|w| w.first() == w.get(1)) && names.first().is_some_and(|n| !n.is_empty() && !n.contains('='));
+ let set_name = match names.first() {
+ Some(n) if same => n.clone(),
+ _ => doc.next_name("Component"),
+ };
+ for ((i, ..), name) in by_x.iter().zip(&names) {
+ let node = doc.get_mut(*i)?;
+ if same {
+ node.name = "Property 1=Default".into();
+ } else if !name.contains('=') {
+ node.name = format!("Property 1={name}");
+ }
+ }
+ let ordered = z_sorted(doc, &made);
+ let set = wrap_in_set(cmd, doc, &ordered)?;
+ doc.get_mut(set)?.name = set_name;
+ s.set_selection(vec![set]);
+ Ok(json!({"id": set}))
+}
+
/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom).
pub(super) fn fit_set(doc: &mut Document, set: NodeId) {
let Some(kids) = doc.node(set).map(|n| n.children.clone()) else { return };
diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs
index 198151768..f65049de5 100644
--- a/crates/engine/src/components_behaviour_tests.rs
+++ b/crates/engine/src/components_behaviour_tests.rs
@@ -1033,3 +1033,109 @@ fn create_multiple_components_keeps_names_and_places_and_is_one_undo_step() {
let doc = s.doc().unwrap();
assert!(frames.iter().all(|f| matches!(doc.node(*f).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo restores the frames");
}
+
+/// Frames named `names` at x = 150 * i (mirrored when `reverse`), y 20, 100 x 60.
+fn named_frames(s: &mut Session, names: &[&str], reverse: bool) -> Vec {
+ let n = names.len();
+ names
+ .iter()
+ .enumerate()
+ .map(|(i, name)| {
+ let x = 150.0 * if reverse { n - 1 - i } else { i } as f64;
+ let v = s.execute("frame.create", &json!({"x": x, "y": 20, "width": 100, "height": 60})).unwrap();
+ let f = id_of(&v["id"]).unwrap();
+ s.execute("object.rename", &json!({"ids": [f], "name": name})).unwrap();
+ f
+ })
+ .collect()
+}
+
+/// The set's name and its children's names in layer order.
+fn set_names(s: &Session, set: NodeId) -> (String, Vec) {
+ let doc = s.doc().unwrap();
+ let n = doc.node(set).unwrap();
+ (n.name.clone(), n.children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect())
+}
+
+/// The set's variant names ordered by world x.
+fn names_by_x(s: &Session, set: NodeId) -> Vec {
+ let doc = s.doc().unwrap();
+ let mut v: Vec<(f64, String)> =
+ doc.node(set).unwrap().children.iter().map(|c| (doc.world_bounds(*c).unwrap().x0, doc.node(*c).unwrap().name.clone())).collect();
+ v.sort_by(|a, b| a.0.total_cmp(&b.0));
+ v.into_iter().map(|(_, n)| n).collect()
+}
+
+/// Create component set (#2088, measured in Figma): variants "Property 1=" in left-to-right
+/// order, the set "Component 1", wrapped in place 20 px around, selected, one undo step.
+#[test]
+fn create_set_names_variants_by_layer_name_and_wraps_in_place() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["Primary", "Secondary", "Ghost"], false);
+ let before = undo_len(&s);
+ let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
+ assert_eq!(undo_len(&s), before + 1, "one undo step");
+ let set = id_of(&out["id"]).unwrap();
+ assert_eq!(s.selection(), vec![set], "the set is selected");
+ assert_eq!(set_names(&s, set).0, "Component 1");
+ assert_eq!(names_by_x(&s, set), ["Property 1=Primary", "Property 1=Secondary", "Property 1=Ghost"]);
+ let doc = s.doc().unwrap();
+ for (i, c) in doc.node(set).unwrap().children.iter().enumerate() {
+ assert!(matches!(doc.node(*c).unwrap().kind, NodeKind::Component { .. }));
+ let b = doc.world_bounds(*c).unwrap();
+ assert_eq!((b.x0, b.y0), (150.0 * i as f64, 20.0), "frames stay put");
+ }
+ let sb = doc.world_bounds(set).unwrap();
+ assert_eq!((sb.x0, sb.y0, sb.x1, sb.y1), (-20.0, 0.0, 420.0, 100.0), "20 px on every side");
+ s.undo().unwrap();
+ let doc = s.doc().unwrap();
+ assert!(doc.node(set).is_none());
+ assert!(frames.iter().all(|f| matches!(doc.node(*f).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo restores the frames");
+}
+
+#[test]
+fn create_set_orders_left_to_right_whatever_the_selection_order() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["A", "B", "C"], true);
+ let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
+ let set = id_of(&out["id"]).unwrap();
+ assert_eq!(names_by_x(&s, set), ["Property 1=C", "Property 1=B", "Property 1=A"]);
+ let (_, kids) = set_names(&s, set);
+ assert_eq!(kids.len(), 3);
+}
+
+#[test]
+fn create_set_keeps_names_already_in_prop_value_form() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["Size=Small", "Size=Large", "Size=Medium"], false);
+ let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
+ let set = id_of(&out["id"]).unwrap();
+ assert_eq!(set_names(&s, set).0, "Component 1");
+ assert_eq!(names_by_x(&s, set), ["Size=Small", "Size=Large", "Size=Medium"]);
+}
+
+#[test]
+fn create_set_with_one_shared_name_names_the_set_and_makes_default_variants() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["Card", "Card", "Card"], false);
+ let out = s.execute("component.createSet", &json!({"ids": frames})).unwrap();
+ let set = id_of(&out["id"]).unwrap();
+ let (name, kids) = set_names(&s, set);
+ assert_eq!(name, "Card");
+ assert_eq!(kids, ["Property 1=Default", "Property 1=Default", "Property 1=Default"]);
+}
+
+#[test]
+fn create_set_refuses_instances_and_leaves_the_document_alone() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["A", "B"], false);
+ let c = s.execute("component.create", &json!({"ids": [frames[0]]})).unwrap();
+ let comp = id_of(&c["id"]).unwrap();
+ let inst = s.execute("instance.create", &json!({"component": comp})).unwrap();
+ let inst = id_of(&inst["id"]).unwrap();
+ let before = undo_len(&s);
+ let e = s.execute("component.createSet", &json!({"ids": [inst, frames[1]]})).unwrap_err();
+ assert!(matches!(e, crate::EngineError::Disabled(..)), "{e:?}");
+ assert_eq!(undo_len(&s), before);
+ assert!(matches!(s.doc().unwrap().node(frames[1]).unwrap().kind, NodeKind::Frame));
+}
diff --git a/crates/ui-egui/src/create_multiple_menu_tests.rs b/crates/ui-egui/src/create_multiple_menu_tests.rs
index a8c2b3a56..409b67e54 100644
--- a/crates/ui-egui/src/create_multiple_menu_tests.rs
+++ b/crates/ui-egui/src/create_multiple_menu_tests.rs
@@ -1,5 +1,5 @@
//! With two or more layers selected the Design panel's component button opens a menu (Create
-//! component, Create multiple components); one layer keeps the plain click (#2088): drawn headless.
+//! component, Create multiple components, Create component set); one layer keeps the plain click (#2088): drawn headless.
use egui::accesskit::Node;
use egui::{Event, PointerButton, Pos2, Rect, pos2};
@@ -80,12 +80,12 @@ fn several_layers_open_a_menu_and_create_multiple_components_in_one_undo_step()
let mb = bounds(multiple);
let mut rows: Vec<(f32, String)> = all
.iter()
- .filter(|n| n.label().is_some() && (bounds(n).min.x - mb.min.x).abs() < 1.0 && (bounds(n).min.y - mb.min.y).abs() < 60.0)
+ .filter(|n| n.label().is_some() && (bounds(n).min.x - mb.min.x).abs() < 1.0 && (bounds(n).min.y - mb.min.y).abs() < 90.0)
.map(|n| (bounds(n).min.y, n.label().unwrap_or("").to_owned()))
.collect();
rows.sort_by(|a, b| a.0.total_cmp(&b.0));
let labels: Vec<&str> = rows.iter().map(|(_, l)| l.as_str()).collect();
- assert_eq!(labels, ["Create component", "Create multiple components"], "the menu's entries");
+ assert_eq!(labels, ["Create component", "Create multiple components", "Create component set"], "the menu's entries");
click(&mut u, bounds(multiple).center());
settle(&mut u);
let sel = u.app.session.selection();
@@ -113,3 +113,24 @@ fn one_layer_keeps_the_plain_button() {
assert_eq!(doc.node(sel[0]).unwrap().name, NAMES[0]);
let _ = ids;
}
+
+#[test]
+fn the_third_entry_creates_one_component_set() {
+ let (mut u, ids) = frames(3);
+ let before = undo_len(&u);
+ click_button(&mut u);
+ let all = nodes(&mut u);
+ let entry = all.iter().find(|n| n.label() == Some("Create component set")).expect("the menu's third entry");
+ click(&mut u, bounds(entry).center());
+ settle(&mut u);
+ let sel = u.app.session.selection();
+ assert_eq!(sel.len(), 1, "the set is selected");
+ let doc = u.app.session.doc().unwrap();
+ let set = doc.node(sel[0]).unwrap();
+ assert!(matches!(set.kind, NodeKind::ComponentSet { .. }));
+ assert_eq!(set.children.len(), 3);
+ assert_eq!(undo_len(&u), before + 1);
+ u.app.session.undo().unwrap();
+ let doc = u.app.session.doc().unwrap();
+ assert!(ids.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Frame))), "one undo reverts it all");
+}
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index b408491a2..55b36ebf5 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -532,10 +532,12 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
});
} else if cmd == "component.create" && sel.len() > 1 {
// Several layers: the button opens a menu, as Figma's does (#2088). One layer keeps the plain click.
- // TODO: "Create component set" is the third entry, to add once its naming is measured.
menu_popup(&bresp).width(220.0).show(|ui| {
let multiple = crate::i18n::command("component.createMultiple", "Create multiple components");
- for (c, label) in [("component.create", tr("Create component")), ("component.createMultiple", &multiple)] {
+ let set = crate::i18n::command("component.createSet", "Create component set");
+ for (c, label) in
+ [("component.create", tr("Create component")), ("component.createMultiple", &multiple), ("component.createSet", &set)]
+ {
let key = crate::shortcuts::shortcut(c).unwrap_or("");
if menu_icon_item(ui, None, None, label, key) {
app.run(c, json!({}));
diff --git a/docs/components-parity.md b/docs/components-parity.md
index 3ecdae292..a28ca6d0e 100644
--- a/docs/components-parity.md
+++ b/docs/components-parity.md
@@ -12,7 +12,7 @@ accounts).
| Feature | Status | Notes |
|---|---|---|
-| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main; with two or more layers selected the Design panel header's component button opens a menu with Create component and Create multiple components (each layer its own component in place, one undo step), one layer keeps the plain click (#2088); Create component set is not in the menu yet |
+| Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main; with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components (each layer its own component in place, one undo step) and Create component set (each layer a component, wrapped in place in a set 20 px around: variants `Property 1=` left to right, `prop=value` names kept, layers that all share a name give a set of that name with `Property 1=Default` variants; measured in Figma), one layer keeps the plain click (#2088) |
| Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances |
| What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push |
| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out (Delete on a layer inside an instance hides it instead of removing it, as the help center says it does); the instance itself resizes and its layers follow the main's constraints |
@@ -79,7 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
-| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component and Create multiple components, as Figma's does; one layer keeps the plain button. Create component set is left out until its naming is measured. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`. Grade unchanged. |
+| 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components and Create component set (`component.createSet`, naming measured in Figma), as Figma's does; one layer keeps the plain button. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`, `components_behaviour_tests::create_set_*`. Grade unchanged. |
| 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |
| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. |
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. |
From 62d761a7bc867152d7d6b73ae85c5cb09d0964ed Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 22:06:35 -0500
Subject: [PATCH 011/103] M6: .fig import draws a scaled instance's layers at
its scale factor (#2091)
An instance scaled with the Scale tool in Figma stores `symbolData.uniformScaleFactor`; the importer never read it, so the layers of a 128 px instance of a 256 px component came in at 256 px with the main's radii, strokes and spacing. Layers of an instance now take the main's lengths times the factor (composed through nested instances), the same values the Scale tool scales, unless an override or derived row gives the value, which is already at the instance's scale. Eight tests: the reproducer, the round trip, a derived size row not scaled twice, override rows, auto layout gap/padding/text, nested composition, factor 1 or none, and junk factors ignored.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at high
---
crates/format/src/fig.rs | 2 +
crates/format/src/fig/assets_tests.rs | 16 ++
crates/format/src/fig/map.rs | 89 ++++++-
crates/format/src/fig/map/component.rs | 23 +-
crates/format/src/fig/map/layer.rs | 45 ++--
crates/format/src/fig/map/layout.rs | 22 +-
crates/format/src/fig/map/paint.rs | 30 ++-
crates/format/src/fig/map/text.rs | 43 ++--
crates/format/src/fig/scale_factor_tests.rs | 256 ++++++++++++++++++++
crates/format/src/fig/tests.rs | 38 ++-
docs/file-format-parity.md | 5 +-
11 files changed, 501 insertions(+), 68 deletions(-)
create mode 100644 crates/format/src/fig/scale_factor_tests.rs
diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs
index f6d27111f..2ac794a44 100644
--- a/crates/format/src/fig.rs
+++ b/crates/format/src/fig.rs
@@ -38,6 +38,8 @@ mod paste_style_tests;
#[cfg(test)]
mod prop_value_tests;
#[cfg(test)]
+mod scale_factor_tests;
+#[cfg(test)]
mod slot_tests;
#[cfg(test)]
mod source_identity_tests;
diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs
index 36c70a687..a69f5ccfa 100644
--- a/crates/format/src/fig/assets_tests.rs
+++ b/crates/format/src/fig/assets_tests.rs
@@ -1035,3 +1035,19 @@ fn local_samples_keep_their_variables_and_styles() {
assert_eq!(styles(&a), styles(&b), "{}", p.display());
}
}
+
+/// #2091 item 1, round trip: the Scale tool's result (radius 10 -> 15) must survive export and import.
+#[test]
+fn a_scaled_instance_keeps_its_radius_through_a_round_trip() {
+ let (mut doc, insts) = sized();
+ let main_box = doc.nodes().find(|n| n.name == "Box" && n.parent.is_some_and(|p| doc.node(p).unwrap().name == "Card")).unwrap().id;
+ doc.node_mut(main_box).unwrap().corners.radii = [10.0; 4];
+ let kid = doc.node(insts[2]).unwrap().children[0];
+ doc.node_mut(kid).unwrap().corners.radii = [15.0; 4];
+ let (bytes, _) = export(&doc, None).unwrap();
+ let (back, _) = import(&bytes, "x").unwrap();
+ let inst = back.nodes().find(|n| n.name == "Scaled").unwrap();
+ let b = inst.children.iter().map(|c| back.node(*c).unwrap()).find(|n| n.name == "Box").unwrap();
+ eprintln!("scaled box back: {}x{} radii {:?}", b.width, b.height, b.corners.radii);
+ assert_eq!(b.corners.radii, [15.0; 4]);
+}
diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs
index 927c233a2..173439c5c 100644
--- a/crates/format/src/fig/map.rs
+++ b/crates/format/src/fig/map.rs
@@ -39,6 +39,10 @@ const MAX_DEPTH: usize = 256;
const MAX_COMMANDS: usize = 200_000;
const MAX_GRID_TRACKS: usize = 1_000;
pub(super) const MAX_GRID_TRACK_VALUE: f64 = 1e6;
+/// Smallest and largest scale factor of a scaled instance (`symbolData.uniformScaleFactor`, times
+/// those of the instances around it) that is followed: a junk factor changes nothing.
+const MIN_SCALE_FACTOR: f64 = 1e-3;
+const MAX_SCALE_FACTOR: f64 = 1e3;
/// Id of "no style".
const NO_STYLE: &str = "4294967295:4294967295";
/// Longest asset `key` or `version` kept for resolving references given as assets.
@@ -147,6 +151,22 @@ fn copy_num(o: &mut Obj, n: &View, from: &str, to: &str) {
}
}
+/// [`copy_num`] for a length: the layers of a scaled instance give it at the instance's scale.
+fn copy_len(o: &mut Obj, n: &View, from: &str, to: &str) {
+ if let Some(x) = n.len(from) {
+ o.insert(to.into(), json!(x));
+ }
+}
+
+/// `x` at scale `k`; `None` when the result is not finite.
+fn scaled(x: f64, k: f64) -> Option {
+ if k == 1.0 {
+ return Some(x);
+ }
+ let r = x * k;
+ r.is_finite().then_some(r)
+}
+
fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) {
if let Some(s) = n.str(from) {
o.insert(to.into(), json!(s));
@@ -157,26 +177,65 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) {
#[derive(Clone)]
struct View<'a> {
layers: Vec<&'a Kv<'a>>,
+ /// The factor each layer's lengths take (`scales[i]` is for `layers[i]`): the layers of a main
+ /// inside a scaled instance have the instance's, the override and derived rows of the instance
+ /// 1, being at the instance's scale already (#2091).
+ scales: Vec,
/// Size and transform worked out from the constraints, over the layers' own.
fitted: Option<((f64, f64), Affine)>,
}
impl<'a> View<'a> {
fn of(n: &'a Kv<'a>) -> Self {
- Self { layers: vec![n], fitted: None }
+ Self::of_scaled(n, 1.0)
+ }
+
+ /// A node of a main inside an instance scaled by `k`: its lengths read `k` times as long.
+ fn of_scaled(n: &'a Kv<'a>, k: f64) -> Self {
+ Self { layers: vec![n], scales: vec![k], fitted: None }
+ }
+
+ /// This view with the layers of `upper` over it (`upper`'s fitted size and place).
+ fn over(&self, upper: &Self) -> Self {
+ let mut v = self.clone();
+ v.layers.extend(upper.layers.iter().copied());
+ v.scales.extend(upper.scales.iter().copied());
+ v.fitted = upper.fitted;
+ v
+ }
+
+ /// The factor of the lowest layer (the layer's own main), for values a style gives.
+ fn base_scale(&self) -> f64 {
+ self.scales.first().copied().unwrap_or(1.0)
}
fn size(&self) -> Option<(f64, f64)> {
- self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size))
+ self.fitted.map(|f| f.0).or_else(|| {
+ let (v, k) = self.find("size")?;
+ let (x, y) = size(v)?;
+ Some((scaled(x, k)?, scaled(y, k)?))
+ })
}
fn transform(&self) -> Option {
- self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix))
+ self.fitted.map(|f| f.1).or_else(|| {
+ let (v, k) = self.find("transform")?;
+ let m = matrix(v)?;
+ if k == 1.0 {
+ return Some(m);
+ }
+ let [a, b, c, d, e, f] = m.as_coeffs();
+ let m = Affine::new([a, b, c, d, scaled(e, k)?, scaled(f, k)?]);
+ Some(m)
+ })
}
fn with(&self, top: impl IntoIterator>) -> Self {
let mut v = self.clone();
- v.layers.extend(top);
+ for layer in top {
+ v.layers.push(layer);
+ v.scales.push(1.0);
+ }
v
}
@@ -188,7 +247,11 @@ impl<'a> View<'a> {
if let Some(s) = style {
let linked_in = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some());
let at = linked_in(key).or_else(|| linked_in(legacy)).map_or(1, |i| i + 1);
- v.layers.insert(at.min(v.layers.len()), s);
+ let at = at.min(v.layers.len());
+ // The style is at the scale of the layer that links it.
+ let k = at.checked_sub(1).and_then(|i| v.scales.get(i)).copied().unwrap_or(1.0);
+ v.layers.insert(at, s);
+ v.scales.insert(at, k);
}
v
}
@@ -197,6 +260,22 @@ impl<'a> View<'a> {
self.layers.iter().rev().find_map(|l| l.get(k))
}
+ /// [`Self::get`], and the factor of the layer that has it.
+ fn find(&self, k: &str) -> Option<(&'a Kv<'a>, f64)> {
+ self.layers.iter().zip(&self.scales).rev().find_map(|(l, s)| l.get(k).map(|v| (v, *s)))
+ }
+
+ /// A length: the number `k` holds at the scale of the layer that has it.
+ fn len(&self, k: &str) -> Option {
+ let (v, s) = self.find(k)?;
+ scaled(v.f64()?, s)
+ }
+
+ /// [`Self::list`], and the factor of the layer that has it.
+ fn list_scaled(&self, k: &str) -> (&'a [Kv<'a>], f64) {
+ self.find(k).map_or((&[], 1.0), |(v, s)| (v.list(), s))
+ }
+
fn num(&self, k: &str) -> Option {
self.get(k).and_then(Kv::f64)
}
diff --git a/crates/format/src/fig/map/component.rs b/crates/format/src/fig/map/component.rs
index e17ecf8ce..9c2788ff2 100644
--- a/crates/format/src/fig/map/component.rs
+++ b/crates/format/src/fig/map/component.rs
@@ -1,6 +1,6 @@
use super::layer::own_text;
use super::text::char_styles;
-use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Rc, Value, View, guid, json};
+use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, MAX_SCALE_FACTOR, MIN_SCALE_FACTOR, Mapper, Obj, Rc, Value, View, guid, json};
/// An override row names a layer of the main by its current GUID or by the `overrideKey` the
/// layer had when the row was written: the key becomes the current GUID, unless the layer is
@@ -91,6 +91,10 @@ pub(super) struct Inst<'a> {
pub(super) slot_content: bool,
/// Mains being expanded (an instance inside its own main stops there).
symbols: Vec,
+ /// The factor the lengths of the expanded layers take: the instance's `uniformScaleFactor`
+ /// times those of the instances around it (#2091). Override and derived rows are already at
+ /// the instance's scale and take none.
+ pub(super) scale: f64,
}
impl<'a> Mapper<'a> {
@@ -278,7 +282,7 @@ impl<'a> Mapper<'a> {
o.remove("_figLineStarts");
if let Some(tv) = prop_text_data(val) {
let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64);
- char_styles(o, tv, size, &mut |w| self.warn(w));
+ char_styles(o, tv, 1.0, size, &mut |w| self.warn(w));
}
}
}
@@ -353,9 +357,12 @@ impl<'a> Mapper<'a> {
}
// A nested instance's derived layout is for the size it has in its main: resized here, the
// sizes and places in it no longer hold and its layers follow their constraints instead.
- let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0);
+ // A scaled size is the factor's product, rounded to a float: a looser match.
+ let tolerance = if outer.is_some_and(|i| i.scale != 1.0) { 1e-4 } else { 1e-6 };
+ let differs = |a: f64, b: f64| (a - b).abs() > tolerance * a.abs().max(b.abs()).max(1.0);
+ let outer_scale = outer.map_or(1.0, |i| i.scale);
let resized = outer.is_some()
- && match (self.nodes.get(id).and_then(|b| View::of(b).size()), v.size()) {
+ && match (self.nodes.get(id).and_then(|b| View::of_scaled(b, outer_scale).size()), v.size()) {
(Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1),
(a, b) => a.is_some() != b.is_some(),
};
@@ -431,6 +438,13 @@ impl<'a> Mapper<'a> {
let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default();
symbols.push(sym.clone());
let root = outer.map_or_else(|| rest_id.to_string(), |i| i.root.clone());
+ // The scale the Scale tool gave the instance; a junk factor changes nothing.
+ let factor = sd
+ .and_then(|s| s.get("uniformScaleFactor"))
+ .and_then(Kv::f64)
+ .filter(|k| (MIN_SCALE_FACTOR..=MAX_SCALE_FACTOR).contains(k))
+ .unwrap_or(1.0);
+ let scale = (outer_scale * factor).clamp(MIN_SCALE_FACTOR, MAX_SCALE_FACTOR);
Some((
Inst {
root,
@@ -440,6 +454,7 @@ impl<'a> Mapper<'a> {
slot_contents: Chain::over(outer.map(|i| &i.slot_contents), slot_contents),
slot_content: false,
symbols,
+ scale,
},
sym,
))
diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs
index dd01cf0af..d15cd2478 100644
--- a/crates/format/src/fig/map/layer.rs
+++ b/crates/format/src/fig/map/layer.rs
@@ -3,8 +3,8 @@ use super::connector::{ConnectorShape, connector_has_saved_label};
use super::layout::{layout_grid, tracks};
use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke};
use super::{
- Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, generated_record, guid, json, matrix,
- network, num, rows, size,
+ Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_len, copy_num, copy_str, generated_record, guid, json,
+ matrix, network, num, rows, scaled, size,
};
/// How the layers of a resized container follow it when the file gives no derived layout for
@@ -126,14 +126,17 @@ impl<'a> Mapper<'a> {
(Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(),
_ => &[],
};
- let mut view = View::of(base).with(entries.iter().map(|e| e.row));
+ // Inside a scaled instance the main's lengths are the factor times as long; the rows of
+ // the instance are at its scale already (#2091).
+ let scale = inst.map_or(1.0, |i| i.scale);
+ let mut view = View::of_scaled(base, scale).with(entries.iter().map(|e| e.row));
// A layer of a resized instance the file gives no derived layout for follows its
// constraints from the main's geometry, then takes the size and place overridden on it
// (auto layout places the others, as in the editor).
let derived = entries.iter().any(|e| e.entry == Entry::Derived && (e.row.get("size").is_some() || e.row.get("transform").is_some()));
let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID"))
&& view.str("stackPositioning") != Some("ABSOLUTE");
- let main = View::of(base);
+ let main = View::of_scaled(base, scale);
let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(k));
if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) {
let t = main.transform().unwrap_or_default();
@@ -280,12 +283,18 @@ impl<'a> Mapper<'a> {
// The saved vector is flattened into the connector node when no child slot remains.
} else if ty == "INSTANCE" {
if let Some((sub, sym)) = self.instance(o, view, id, inst, rest_id) {
- let main = self.nodes.get(&sym).map(|m| View::of(m));
- let fit = Fit::frame(main.as_ref().and_then(View::size), view.size());
+ let main = self.nodes.get(&sym).map(|m| View::of_scaled(m, sub.scale));
+ // A scaled main's size is the factor's product, rounded to a float: it matches an
+ // instance of that size without a fit that would round the layers again.
+ let near = |a: f64, b: f64| (a - b).abs() <= 1e-4 * a.abs().max(b.abs()).max(1.0);
+ let fit = match (main.as_ref().and_then(View::size), view.size()) {
+ (Some(a), Some(b)) if sub.scale != 1.0 && near(a.0, b.0) && near(a.1, b.1) => None,
+ (a, b) => Fit::frame(a, b),
+ };
// An instance that records no auto layout of its own has its main's.
let view = match main {
Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => {
- let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };
+ let layered = m.over(view);
self.auto_layout(o, &layered);
layered
}
@@ -309,8 +318,8 @@ impl<'a> Mapper<'a> {
// size to its own.
let fit = match (inst, ty) {
(None, _) => None,
- (Some(_), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of(base).size(), view.size()),
- (Some(_), _) => Fit::frame(View::of(base).size(), view.size()),
+ (Some(i), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of_scaled(base, i.scale).size(), view.size()),
+ (Some(i), _) => Fit::frame(View::of_scaled(base, i.scale).size(), view.size()),
};
for c in self.children(id) {
kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included));
@@ -509,14 +518,16 @@ impl<'a> Mapper<'a> {
.unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()));
o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect()));
let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles);
- let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default());
+ let (effects, effects_scale) =
+ effect_style.map_or_else(|| v.list_scaled("effects"), |s| (s.get("effects").map(Kv::list).unwrap_or_default(), v.base_scale()));
if !effects.is_empty() {
- o.insert("effects".into(), Value::Array(effects.iter().filter_map(effect).collect()));
+ o.insert("effects".into(), Value::Array(effects.iter().filter_map(|e| effect(e, effects_scale)).collect()));
}
let grid_style = self.style(v, "styleIdForGrid", "inheritGridStyleID", "grid", &mut styles);
- let grids = grid_style.map_or_else(|| v.list("layoutGrids"), |s| s.get("layoutGrids").map(Kv::list).unwrap_or_default());
+ let (grids, grids_scale) =
+ grid_style.map_or_else(|| v.list_scaled("layoutGrids"), |s| (s.get("layoutGrids").map(Kv::list).unwrap_or_default(), v.base_scale()));
if !grids.is_empty() {
- o.insert("layoutGrids".into(), Value::Array(grids.iter().map(layout_grid).collect()));
+ o.insert("layoutGrids".into(), Value::Array(grids.iter().map(|g| layout_grid(g, grids_scale)).collect()));
}
if ty == "TEXT" {
self.style(v, "styleIdForText", "inheritTextStyleID", "text", &mut styles);
@@ -525,10 +536,10 @@ impl<'a> Mapper<'a> {
o.insert("styles".into(), Value::Object(styles));
}
stroke(o, v);
- copy_num(o, v, "cornerRadius", "cornerRadius");
+ copy_len(o, v, "cornerRadius", "cornerRadius");
copy_num(o, v, "cornerSmoothing", "cornerSmoothing");
if v.flag("rectangleCornerRadiiIndependent") == Some(true) {
- let r = |k: &str| v.num(k).unwrap_or(0.0);
+ let r = |k: &str| v.len(k).unwrap_or(0.0);
o.insert(
"rectangleCornerRadii".into(),
json!([
@@ -834,9 +845,9 @@ fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) {
o.insert("layoutPositioning".into(), json!("ABSOLUTE"));
}
for (k, w, h) in [("minSize", "minWidth", "minHeight"), ("maxSize", "maxWidth", "maxHeight")] {
- if let Some(val) = v.get(k).and_then(|s| s.get("value")) {
+ if let Some((val, scale)) = v.find(k).and_then(|(s, scale)| Some((s.get("value")?, scale))) {
for (axis, to) in [("x", w), ("y", h)] {
- if let Some(x) = num(val, axis).filter(|x| *x > 0.0 && *x < 1e9) {
+ if let Some(x) = num(val, axis).and_then(|x| scaled(x, scale)).filter(|x| *x > 0.0 && *x < 1e9) {
o.insert(to.into(), json!(x));
}
}
diff --git a/crates/format/src/fig/map/layout.rs b/crates/format/src/fig/map/layout.rs
index 7900c4539..28fb801a2 100644
--- a/crates/format/src/fig/map/layout.rs
+++ b/crates/format/src/fig/map/layout.rs
@@ -1,23 +1,23 @@
use super::paint::{bound, color};
-use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_num, guid, json, num};
+use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_len, guid, json, num, scaled};
impl<'a> Mapper<'a> {
pub(super) fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) {
let Some(mode) = v.str("stackMode").filter(|m| matches!(*m, "HORIZONTAL" | "VERTICAL" | "GRID")) else { return };
o.insert("layoutMode".into(), json!(mode));
- copy_num(o, v, "stackSpacing", "itemSpacing");
- copy_num(o, v, "stackCounterSpacing", "counterAxisSpacing");
+ copy_len(o, v, "stackSpacing", "itemSpacing");
+ copy_len(o, v, "stackCounterSpacing", "counterAxisSpacing");
if v.str("stackWrap") == Some("WRAP") {
o.insert("layoutWrap".into(), json!("WRAP"));
}
if v.str("stackCounterAlignContent") == Some("SPACE_BETWEEN") {
o.insert("counterAxisAlignContent".into(), json!("SPACE_BETWEEN"));
}
- let (h, vert) = (v.num("stackHorizontalPadding").unwrap_or(0.0), v.num("stackVerticalPadding").unwrap_or(0.0));
+ let (h, vert) = (v.len("stackHorizontalPadding").unwrap_or(0.0), v.len("stackVerticalPadding").unwrap_or(0.0));
o.insert("paddingLeft".into(), json!(h));
o.insert("paddingTop".into(), json!(vert));
- o.insert("paddingRight".into(), json!(v.num("stackPaddingRight").unwrap_or(h)));
- o.insert("paddingBottom".into(), json!(v.num("stackPaddingBottom").unwrap_or(vert)));
+ o.insert("paddingRight".into(), json!(v.len("stackPaddingRight").unwrap_or(h)));
+ o.insert("paddingBottom".into(), json!(v.len("stackPaddingBottom").unwrap_or(vert)));
let main = match v.str("stackPrimaryAlignItems").or_else(|| v.str("stackJustify")) {
Some("CENTER") => "CENTER",
Some("MAX") => "MAX",
@@ -48,8 +48,8 @@ impl<'a> Mapper<'a> {
o.insert("itemReverseZIndex".into(), json!(b));
}
if mode == "GRID" {
- copy_num(o, v, "gridColumnGap", "gridColumnGap");
- copy_num(o, v, "gridRowGap", "gridRowGap");
+ copy_len(o, v, "gridColumnGap", "gridColumnGap");
+ copy_len(o, v, "gridRowGap", "gridRowGap");
o.insert("gridColumnCount".into(), json!(tracks(v, "gridColumns").len()));
o.insert("gridRowCount".into(), json!(tracks(v, "gridRows").len()));
let track_data = [("columnTracks", "gridColumns", "gridColumnsSizing"), ("rowTracks", "gridRows", "gridRowsSizing")]
@@ -162,7 +162,8 @@ fn grid_track_sizing(size: Option<&Kv<'_>>) -> Option {
}
}
-pub(super) fn layout_grid(g: &Kv) -> Value {
+/// A layout grid; `k`: the factor its lengths take (a scaled instance's layer).
+pub(super) fn layout_grid(g: &Kv, k: f64) -> Value {
let pattern = match (g.get("pattern").and_then(Kv::str), g.get("axis").and_then(Kv::str)) {
(Some("GRID"), _) => "GRID",
(_, Some("Y")) => "ROWS",
@@ -170,7 +171,8 @@ pub(super) fn layout_grid(g: &Kv) -> Value {
};
let mut o = json!({"pattern": pattern, "alignment": g.get("type").and_then(Kv::str).unwrap_or("STRETCH")});
for (from, to) in [("sectionSize", "sectionSize"), ("numSections", "count"), ("gutterSize", "gutterSize"), ("offset", "offset")] {
- if let Some(x) = num(g, from) {
+ let length = from != "numSections";
+ if let Some(x) = num(g, from).and_then(|x| scaled(x, if length { k } else { 1.0 })) {
o[to] = json!(x);
}
}
diff --git a/crates/format/src/fig/map/paint.rs b/crates/format/src/fig/map/paint.rs
index cf96c2bf0..b563c11b0 100644
--- a/crates/format/src/fig/map/paint.rs
+++ b/crates/format/src/fig/map/paint.rs
@@ -1,6 +1,6 @@
use super::layer::cap_name;
use super::variables::alias;
-use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_num, copy_str, guid, json, matrix, num, rows, vec2};
+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_len, copy_str, guid, json, matrix, num, rows, scaled, vec2};
fn filter_number(value: &Kv) -> Option {
let number = match value {
@@ -55,7 +55,7 @@ impl<'a> Mapper<'a> {
}
pub(super) fn stroke(o: &mut Obj, v: &View) {
- copy_num(o, v, "strokeWeight", "strokeWeight");
+ copy_len(o, v, "strokeWeight", "strokeWeight");
if let Some(a) = v.str("strokeAlign").filter(|a| *a != "OFFSET") {
o.insert("strokeAlign".into(), json!(a));
}
@@ -84,7 +84,8 @@ pub(super) fn stroke(o: &mut Obj, v: &View) {
if !profile.is_empty() {
o.insert("strokeWidthProfile".into(), Value::Array(profile));
}
- let dashes: Vec = v.list("dashPattern").iter().filter_map(Kv::f64).collect();
+ let (dash_list, dash_scale) = v.list_scaled("dashPattern");
+ let dashes: Vec = dash_list.iter().filter_map(Kv::f64).filter_map(|d| scaled(d, dash_scale)).collect();
if !dashes.is_empty() {
o.insert("strokeDashes".into(), json!(dashes));
}
@@ -94,7 +95,7 @@ pub(super) fn stroke(o: &mut Obj, v: &View) {
}
if v.flag("borderStrokeWeightsIndependent") == Some(true) {
// A side that isn't written has no border.
- let side = |k: &str| v.num(k).unwrap_or(0.0);
+ let side = |k: &str| v.len(k).unwrap_or(0.0);
o.insert(
"individualStrokeWeights".into(),
json!({"top": side("borderTopWeight"), "right": side("borderRightWeight"), "bottom": side("borderBottomWeight"), "left": side("borderLeftWeight")}),
@@ -254,7 +255,8 @@ pub(super) fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option {
(!found.is_empty()).then_some(Value::Object(found))
}
-pub(super) fn effect(e: &Kv) -> Option {
+/// A layer effect; `k`: the factor its lengths take (a scaled instance's layer).
+pub(super) fn effect(e: &Kv, k: f64) -> Option {
let ty = match e.get("type").and_then(Kv::str)? {
"FOREGROUND_BLUR" => "LAYER_BLUR",
"GRAIN" => "TEXTURE",
@@ -262,9 +264,10 @@ pub(super) fn effect(e: &Kv) -> Option {
};
let mut o = Obj::new();
o.insert("type".into(), json!(ty));
- for k in ["radius", "spread", "startRadius", "density", "opacity"] {
- if let Some(x) = num(e, k) {
- o.insert(k.into(), json!(x));
+ for key in ["radius", "spread", "startRadius", "density", "opacity"] {
+ let length = matches!(key, "radius" | "spread" | "startRadius");
+ if let Some(x) = num(e, key).and_then(|x| scaled(x, if length { k } else { 1.0 })) {
+ o.insert(key.into(), json!(x));
}
}
for k in ["visible", "showShadowBehindNode", "clipToShape"] {
@@ -284,7 +287,7 @@ pub(super) fn effect(e: &Kv) -> Option {
}
for (from, to) in [("offset", "offset"), ("startOffset", "startOffset"), ("endOffset", "endOffset"), ("noiseSize", "noiseSizeVector")] {
if let Some(p) = e.get(from).and_then(vec2) {
- o.insert(to.into(), p);
+ o.insert(to.into(), scale_vec2(p, k));
}
}
if e.get("blurOpType").and_then(Kv::str) == Some("PROGRESSIVE") {
@@ -304,6 +307,15 @@ pub(super) fn effect(e: &Kv) -> Option {
Some(Value::Object(o))
}
+/// A `{"x", "y"}` vector at scale `k` (unchanged when that is not finite).
+fn scale_vec2(p: Value, k: f64) -> Value {
+ let axis = |name: &str| p.get(name).and_then(Value::as_f64).and_then(|x| scaled(x, k));
+ match (axis("x"), axis("y")) {
+ (Some(x), Some(y)) => json!({"x": x, "y": y}),
+ _ => p,
+ }
+}
+
pub(super) fn export_setting(e: &Kv) -> Value {
let c = e.get("constraint");
let ty = match c.and_then(|c| c.get("type")).and_then(Kv::str) {
diff --git a/crates/format/src/fig/map/text.rs b/crates/format/src/fig/map/text.rs
index 0249b1086..86d9d24bb 100644
--- a/crates/format/src/fig/map/text.rs
+++ b/crates/format/src/fig/map/text.rs
@@ -1,12 +1,13 @@
use super::paint::paint;
-use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num};
+use super::{Kv, Mapper, Obj, Value, View, copy_len, copy_str, json, network, num, scaled};
/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through.
-fn text_num(v: &View, key: &str) -> Option {
- v.layers.iter().rev().find_map(|layer| {
+/// `length`: the setting is a length, at the scale of the layer that has it.
+fn text_num(v: &View, key: &str, length: bool) -> Option {
+ v.layers.iter().zip(&v.scales).rev().find_map(|(layer, k)| {
layer.get(key).and_then(|value| match value {
Kv::Bool(_) => None,
- _ => value.f64(),
+ _ => scaled(value.f64()?, if length { *k } else { 1.0 }),
})
})
}
@@ -16,7 +17,7 @@ fn text_flag(v: &View, key: &str) -> Option {
}
fn insert_text_options(style: &mut Obj, v: &View) {
- if let Some(value) = text_num(v, "listSpacing") {
+ if let Some(value) = text_num(v, "listSpacing", true) {
style.insert("listSpacing".into(), json!(value));
}
for k in ["hangingList", "hangingPunctuation"] {
@@ -79,7 +80,8 @@ impl<'a> Mapper<'a> {
let style_node = self.style_link(v, key, legacy).and_then(|s| self.nodes.get(&s).copied());
// Overrides above the one that links the style beat it; it beats the copies below.
let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")), key, legacy);
- let td = v.get("textData");
+ let found = v.find("textData");
+ let (td, td_scale) = (found.map(|f| f.0), found.map_or(1.0, |f| f.1));
let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or("");
o.insert("characters".into(), json!(chars));
// Where the file's own layout wrapped the lines (not a REST field).
@@ -105,19 +107,19 @@ impl<'a> Mapper<'a> {
}
// Paragraph settings belong to the effective text layer, including explicit zero clears.
for k in ["paragraphSpacing", "paragraphIndent"] {
- copy_num(&mut style, v, k, k);
+ copy_len(&mut style, v, k, k);
}
insert_text_options(&mut style, v);
if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) {
style.insert("maxLines".into(), json!(n));
}
- let size = sv.num("fontSize");
+ let size = sv.len("fontSize");
if let Some(b) = o.get_mut("style").and_then(|s| s.get_mut("boundVariables")).map(Value::take) {
style.insert("boundVariables".into(), b);
}
o.insert("style".into(), Value::Object(style));
let Some(td) = td else { return };
- char_styles(o, td, size, &mut |w| self.warn(w));
+ char_styles(o, td, td_scale, size, &mut |w| self.warn(w));
let raw = td.get("lines").map(Kv::list).unwrap_or_default();
let lines: Vec<&str> = raw
.iter()
@@ -238,8 +240,9 @@ impl<'a> Mapper<'a> {
}
/// Per-character styles of text data `td` (`characterStyleIDs` + `styleOverrideTable`) as REST
-/// `characterStyleOverrides` + `styleOverrideTable`; `size`: the base font size.
-pub(super) fn char_styles(o: &mut Obj, td: &Kv, size: Option, warn: &mut dyn FnMut(String)) {
+/// `characterStyleOverrides` + `styleOverrideTable`; `scale`: the factor the lengths of `td` take
+/// (a scaled instance's layer); `size`: the base font size.
+pub(super) fn char_styles(o: &mut Obj, td: &Kv, scale: f64, size: Option, warn: &mut dyn FnMut(String)) {
let ids: Vec = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect();
if ids.iter().all(|i| *i == 0) {
return;
@@ -247,7 +250,7 @@ pub(super) fn char_styles(o: &mut Obj, td: &Kv, size: Option, warn: &mut dy
let mut table = Obj::new();
for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() {
let Some(sid) = num(e, "styleID") else { continue };
- let mut s = text_style(&View::of(e), size);
+ let mut s = text_style(&View::of_scaled(e, scale), size);
if let Some(fills) = e.get("fillPaints") {
s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(|p| paint(p, warn)).collect()));
}
@@ -269,9 +272,9 @@ fn text_style(v: &View, size: Option) -> Obj {
}
}
}
- copy_num(&mut s, v, "fontSize", "fontSize");
- copy_num(&mut s, v, "paragraphSpacing", "paragraphSpacing");
- copy_num(&mut s, v, "paragraphIndent", "paragraphIndent");
+ copy_len(&mut s, v, "fontSize", "fontSize");
+ copy_len(&mut s, v, "paragraphSpacing", "paragraphSpacing");
+ copy_len(&mut s, v, "paragraphIndent", "paragraphIndent");
insert_text_options(&mut s, v);
copy_str(&mut s, v, "textCase", "textCase");
copy_str(&mut s, v, "textDecoration", "textDecoration");
@@ -279,11 +282,11 @@ fn text_style(v: &View, size: Option) -> Obj {
s.insert("textDecorationSkipInk".into(), json!(skip_ink));
}
copy_str(&mut s, v, "leadingTrim", "leadingTrim");
- let font_size = v.num("fontSize").or(size).unwrap_or(12.0);
- if let Some(ls) = v.get("letterSpacing") {
+ let font_size = v.len("fontSize").or(size).unwrap_or(12.0);
+ if let Some((ls, k)) = v.find("letterSpacing") {
let val = num(ls, "value").unwrap_or(0.0);
let px = match ls.get("units").and_then(Kv::str) {
- Some("PIXELS") => val,
+ Some("PIXELS") => scaled(val, k).unwrap_or(val),
Some("PERCENT") => {
// Private (read by `rest/text.rs`): REST spacing is in pixels; keep the percent.
s.insert("_letterSpacingPercent".into(), json!(val));
@@ -293,12 +296,12 @@ fn text_style(v: &View, size: Option) -> Obj {
};
s.insert("letterSpacing".into(), json!(px));
}
- if let Some(lh) = v.get("lineHeight") {
+ if let Some((lh, k)) = v.find("lineHeight") {
let val = num(lh, "value").unwrap_or(100.0);
match lh.get("units").and_then(Kv::str) {
Some("PIXELS") => {
s.insert("lineHeightUnit".into(), json!("PIXELS"));
- s.insert("lineHeightPx".into(), json!(val));
+ s.insert("lineHeightPx".into(), json!(scaled(val, k).unwrap_or(val)));
}
// shortcut: percent units count from the font's own line height (100 % is "auto"), which
// the document can only say for 100 %; other values read as % of the font size. Upgrade
diff --git a/crates/format/src/fig/scale_factor_tests.rs b/crates/format/src/fig/scale_factor_tests.rs
new file mode 100644
index 000000000..79aa7f949
--- /dev/null
+++ b/crates/format/src/fig/scale_factor_tests.rs
@@ -0,0 +1,256 @@
+//! An instance scaled with the Scale tool stores `symbolData.uniformScaleFactor` (#2091 item 1):
+//! its layers draw the main's lengths times the factor (composed through nested instances),
+//! unless an override or derived row gives the value, which is at the instance's scale already.
+//! Fixtures built in code with the parent module's helpers.
+
+use figcraft_doc::{Document, EffectKind, LetterSpacing, LineHeight, Node, NodeKind};
+
+use super::kiwi::{Kv, Schema};
+use super::tests::{arr, at, canvas, e, f, g, node, o, s, xy};
+use super::*;
+
+const SCHEMA: &str = "
+enum NodeType NONE DOCUMENT CANVAS FRAME ROUNDED_RECTANGLE TEXT SYMBOL INSTANCE
+enum StackMode NONE HORIZONTAL VERTICAL
+enum EffectType DROP_SHADOW
+enum NumberUnits RAW PIXELS PERCENT
+struct GUID uint sessionID, uint localID
+struct Vector float x, float y
+struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
+message Number float value, NumberUnits units
+message ParentIndex GUID guid, string position
+message FontName string family, string style, string postscript
+message TextData string characters
+message Effect EffectType type, float radius, float spread, Vector offset
+message GUIDPath GUID[] guids
+message SymbolData GUID symbolID, NodeChange[] symbolOverrides, float uniformScaleFactor
+message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float cornerRadius, float strokeWeight, float[] dashPattern, float fontSize, FontName fontName, TextData textData, SymbolData symbolData, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, float stackCounterSpacing, Effect[] effects, Number lineHeight, Number letterSpacing, NodeChange[] derivedSymbolData, GUIDPath guidPath
+message Blob byte[] bytes
+message Message NodeChange[] nodeChanges, Blob[] blobs
+";
+
+fn schema() -> Schema {
+ Schema::parse(SCHEMA).unwrap()
+}
+
+fn import_nodes(nodes: Vec>) -> Document {
+ import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap().0
+}
+
+fn page() -> Vec> {
+ vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]
+}
+
+/// A copy of `n` with field `key` set to `value`.
+fn set(n: Kv<'static>, key: &'static str, value: Kv<'static>) -> Kv<'static> {
+ let Kv::Object(mut fields) = n else { panic!("not a node") };
+ fields.retain(|(k, _)| *k != key);
+ fields.push((key, value));
+ Kv::Object(fields)
+}
+
+/// An instance of main `main` with `factor` (none: the field is missing) and extra fields.
+fn instance(
+ id: u64,
+ parent: (u64, &str),
+ name: &str,
+ main: u64,
+ size: (f64, f64),
+ factor: Option,
+ mut more: Vec<(&'static str, Kv<'static>)>,
+) -> Kv<'static> {
+ let mut symbol = vec![("symbolID", g(main))];
+ if let Some(k) = factor {
+ symbol.push(("uniformScaleFactor", f(k)));
+ }
+ let mut fields = vec![("size", xy(size.0, size.1)), ("transform", at(300.0, 0.0)), ("symbolData", o(symbol))];
+ fields.append(&mut more);
+ node(id, Some(parent), "INSTANCE", name, fields)
+}
+
+/// A 256 px component "Icon" holding a 200 px rectangle at (20, 40) with radius 64 and a stroke,
+/// dashes and a shadow.
+fn icon() -> Vec> {
+ let shadow = o(vec![("type", e("DROP_SHADOW")), ("radius", f(20.0)), ("spread", f(4.0)), ("offset", xy(10.0, 6.0))]);
+ vec![
+ node(5, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0))]),
+ node(
+ 6,
+ Some((5, "!")),
+ "ROUNDED_RECTANGLE",
+ "Mark",
+ vec![
+ ("size", xy(200.0, 200.0)),
+ ("transform", at(20.0, 40.0)),
+ ("cornerRadius", f(64.0)),
+ ("strokeWeight", f(8.0)),
+ ("dashPattern", arr(vec![f(16.0), f(8.0)])),
+ ("effects", arr(vec![shadow])),
+ ],
+ ),
+ ]
+}
+
+fn find<'d>(doc: &'d Document, name: &str) -> &'d Node {
+ doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"))
+}
+
+/// The first child of the layer named `name`.
+fn first_child<'d>(doc: &'d Document, name: &str) -> &'d Node {
+ doc.node(find(doc, name).children[0]).unwrap()
+}
+
+fn mark(doc: &Document, name: &str) -> ([f64; 2], [f64; 2], [f64; 4]) {
+ let m = first_child(doc, name);
+ let t = m.transform.translation();
+ ([m.width, m.height], [t.x, t.y], m.corners.radii)
+}
+
+#[test]
+fn a_scaled_instance_scales_the_lengths_of_a_layer_it_gives_no_row_for() {
+ let mut nodes = page();
+ nodes.extend(icon());
+ nodes.push(instance(9, (1, "\""), "Half", 5, (128.0, 128.0), Some(0.5), vec![]));
+ let doc = import_nodes(nodes);
+ let (size, place, radii) = mark(&doc, "Half");
+ assert_eq!((size, place, radii), ([100.0, 100.0], [10.0, 20.0], [32.0; 4]));
+ let m = first_child(&doc, "Half");
+ assert_eq!(m.stroke.weight, 4.0);
+ assert_eq!(m.stroke.dashes, [8.0, 4.0]);
+ let EffectKind::DropShadow(sh) = &m.effects[0].kind else { panic!("not a drop shadow") };
+ assert_eq!((sh.radius, sh.spread, sh.offset), (10.0, 2.0, (5.0, 3.0)));
+ // The instance itself is at the size the file gives it.
+ let inst = find(&doc, "Half");
+ assert_eq!([inst.width, inst.height], [128.0, 128.0]);
+ // The main is untouched.
+ assert_eq!(first_child(&doc, "Icon").corners.radii, [64.0; 4]);
+}
+
+#[test]
+fn a_value_a_derived_row_gives_is_not_scaled_twice() {
+ let mut nodes = page();
+ nodes.extend(icon());
+ let row = o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(100.0, 100.0))]);
+ nodes.push(instance(9, (1, "\""), "Half", 5, (128.0, 128.0), Some(0.5), vec![("derivedSymbolData", arr(vec![row]))]));
+ let doc = import_nodes(nodes);
+ let (size, place, radii) = mark(&doc, "Half");
+ // The derived size is taken as written; the radius and place, which no row gives, scale.
+ assert_eq!((size, place, radii), ([100.0, 100.0], [10.0, 20.0], [32.0; 4]));
+}
+
+#[test]
+fn an_override_row_is_not_scaled_either() {
+ let mut nodes = page();
+ nodes.extend(icon());
+ let row = o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("cornerRadius", f(7.0)), ("strokeWeight", f(3.0))]);
+ let symbol = o(vec![("symbolID", g(5)), ("uniformScaleFactor", f(0.5)), ("symbolOverrides", arr(vec![row]))]);
+ nodes.push(node(9, Some((1, "\"")), "INSTANCE", "Half", vec![("size", xy(128.0, 128.0)), ("transform", at(300.0, 0.0)), ("symbolData", symbol)]));
+ let doc = import_nodes(nodes);
+ let m = first_child(&doc, "Half");
+ assert_eq!(m.corners.radii, [7.0; 4]);
+ assert_eq!(m.stroke.weight, 3.0);
+ // What the override leaves alone still scales.
+ assert_eq!(m.stroke.dashes, [8.0, 4.0]);
+}
+
+#[test]
+fn a_scaled_auto_layout_instance_scales_its_gap_padding_and_text() {
+ let mut nodes = page();
+ nodes.push(node(
+ 5,
+ Some((1, "!")),
+ "SYMBOL",
+ "Row",
+ vec![
+ ("size", xy(200.0, 60.0)),
+ ("transform", at(0.0, 0.0)),
+ ("stackMode", e("HORIZONTAL")),
+ ("stackSpacing", f(20.0)),
+ ("stackCounterSpacing", f(6.0)),
+ ("stackHorizontalPadding", f(10.0)),
+ ("stackVerticalPadding", f(10.0)),
+ ],
+ ));
+ let font = o(vec![("family", s("Inter")), ("style", s("Regular")), ("postscript", s(""))]);
+ let px = |v: f64| o(vec![("value", f(v)), ("units", e("PIXELS"))]);
+ nodes.push(node(
+ 6,
+ Some((5, "!")),
+ "TEXT",
+ "Label",
+ vec![
+ ("size", xy(40.0, 20.0)),
+ ("transform", at(10.0, 10.0)),
+ ("fontSize", f(16.0)),
+ ("fontName", font),
+ ("lineHeight", px(24.0)),
+ ("letterSpacing", px(2.0)),
+ ("textData", o(vec![("characters", s("Hi"))])),
+ ],
+ ));
+ nodes.push(instance(9, (1, "\""), "RowHalf", 5, (100.0, 30.0), Some(0.5), vec![]));
+ let doc = import_nodes(nodes);
+ let row = find(&doc, "RowHalf");
+ let layout = row.layout.as_ref().expect("the instance has its main's auto layout");
+ assert_eq!((layout.gap, layout.cross_gap, layout.padding), (10.0, 3.0, [5.0; 4]));
+ let NodeKind::Text(t) = &first_child(&doc, "RowHalf").kind else { panic!("not text") };
+ assert_eq!(t.style.font_size, 8.0);
+ assert_eq!(t.style.line_height, LineHeight::Pixels(12.0));
+ assert_eq!(t.style.letter_spacing, LetterSpacing::Pixels(1.0));
+ // The main keeps its own.
+ let NodeKind::Text(main) = &first_child(&doc, "Row").kind else { panic!("not text") };
+ assert_eq!(main.style.font_size, 16.0);
+}
+
+#[test]
+fn nested_scaled_instances_compose_their_factors() {
+ let mut nodes = page();
+ // Inner: 200 px with a 200 px rectangle of radius 40.
+ nodes.push(node(5, Some((1, "!")), "SYMBOL", "Inner", vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0))]));
+ nodes.push(node(
+ 6,
+ Some((5, "!")),
+ "ROUNDED_RECTANGLE",
+ "Dot",
+ vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0)), ("cornerRadius", f(40.0))],
+ ));
+ // Outer: 200 px, holding Inner scaled to 100 px (factor 0.5) at (20, 20).
+ nodes.push(node(7, Some((1, "\"")), "SYMBOL", "Outer", vec![("size", xy(200.0, 200.0)), ("transform", at(300.0, 0.0))]));
+ nodes.push(set(instance(8, (7, "!"), "Kid", 5, (100.0, 100.0), Some(0.5), vec![]), "transform", at(20.0, 20.0)));
+ // The outer one scaled to 100 px (factor 0.5).
+ nodes.push(instance(9, (1, "#"), "Half", 7, (100.0, 100.0), Some(0.5), vec![]));
+ let doc = import_nodes(nodes);
+ let kid = first_child(&doc, "Half");
+ // The nested instance itself is the outer factor times its stored size and place.
+ assert_eq!(([kid.width, kid.height], [kid.transform.translation().x, kid.transform.translation().y]), ([50.0, 50.0], [10.0, 10.0]));
+ // Its layers take both factors: 0.5 x 0.5 of the main's.
+ let dot = doc.node(kid.children[0]).unwrap();
+ assert_eq!(([dot.width, dot.height], dot.corners.radii), ([50.0, 50.0], [10.0; 4]));
+}
+
+#[test]
+fn a_factor_of_one_or_none_changes_nothing() {
+ let mut nodes = page();
+ nodes.extend(icon());
+ nodes.push(instance(9, (1, "\""), "One", 5, (256.0, 256.0), Some(1.0), vec![]));
+ nodes.push(instance(10, (1, "#"), "None", 5, (256.0, 256.0), None, vec![]));
+ let doc = import_nodes(nodes);
+ for name in ["One", "None"] {
+ let (size, place, radii) = mark(&doc, name);
+ assert_eq!((size, place, radii), ([200.0, 200.0], [20.0, 40.0], [64.0; 4]), "{name}");
+ assert_eq!(first_child(&doc, name).stroke.weight, 8.0, "{name}");
+ }
+}
+
+#[test]
+fn a_junk_factor_is_ignored() {
+ // The file encoder refuses NaN and infinities; the range check turns those away too.
+ for factor in [-0.5, 0.0, 1e30, 3e38, 1e-30] {
+ let mut nodes = page();
+ nodes.extend(icon());
+ nodes.push(instance(9, (1, "\""), "Junk", 5, (256.0, 256.0), Some(factor), vec![]));
+ let doc = import_nodes(nodes);
+ let (size, place, radii) = mark(&doc, "Junk");
+ assert_eq!((size, place, radii), ([200.0, 200.0], [20.0, 40.0], [64.0; 4]), "factor {factor}");
+ }
+}
diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs
index b468093d7..a8abf56fe 100644
--- a/crates/format/src/fig/tests.rs
+++ b/crates/format/src/fig/tests.rs
@@ -61,7 +61,7 @@ message FontName string family, string style, string postscript
message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable
message Path WindingRule windingRule, uint commandsBlob
message GUIDPath GUID[] guids
-message SymbolData GUID symbolID, NodeChange[] symbolOverrides
+message SymbolData GUID symbolID, NodeChange[] symbolOverrides, float uniformScaleFactor
message StyleId GUID guid, AssetRef assetRef
message PropValue bool boolValue, TextData textValue
message PropDef GUID id, string name, PropValue initialValue, PropType type
@@ -1825,3 +1825,39 @@ fn same_version_library_copies_resolve_deterministically_and_leave_no_table() {
}
assert_eq!(picked, ["1:6", "1:6"]);
}
+
+/// #2091 item 1: an instance scaled with the Scale tool (K) has `symbolData.uniformScaleFactor`.
+#[test]
+fn a_scaled_instance_draws_its_layers_at_the_factor() {
+ let nodes = vec![
+ node(0, None, "DOCUMENT", "D", vec![]),
+ node(1, Some((0, "!")), "CANVAS", "P", vec![]),
+ node(5, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0))]),
+ node(
+ 6,
+ Some((5, "!")),
+ "ROUNDED_RECTANGLE",
+ "Mark",
+ vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0)), ("cornerRadius", f(64.0))],
+ ),
+ node(
+ 9,
+ Some((1, "\"")),
+ "INSTANCE",
+ "Half",
+ vec![
+ ("size", xy(128.0, 128.0)),
+ ("transform", at(300.0, 0.0)),
+ ("symbolData", o(vec![("symbolID", g(5)), ("uniformScaleFactor", f(0.5))])),
+ ],
+ ),
+ ];
+ let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
+ let inst = named(&doc, "Half");
+ let mark = doc.node(inst.children[0]).unwrap();
+ let t = mark.transform.translation();
+ eprintln!("instance {}x{}; child {}x{} at ({}, {}) radii {:?}", inst.width, inst.height, mark.width, mark.height, t.x, t.y, mark.corners.radii);
+ assert_eq!([mark.width, mark.height], [128.0, 128.0]);
+ assert_eq!(mark.corners.radii, [32.0; 4]);
+ assert_eq!([t.x, t.y], [0.0, 0.0]);
+}
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index bce90b008..5a7e27d5c 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File format parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (`.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -17,7 +17,7 @@ and prototype interactions are still unchecked there.
| Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours |
|---|---|---|---|---|---|
-| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join); an elbowed one saved with its path (`connectorControlPoints`: a point and an axis per run between the first and the last) keeps that path when it leaves and arrives by the ends' sides (#1910), and a curved one's saved `connectorBezierControlPoints` are not read; a connector routed here takes its line's paint, weight, cap and dashes from the file's generated line record, as one with a saved snapshot does (#1807). A connector's label without a saved snapshot is kept as text centred on its line, without its background. Not read: table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 |
+| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides; an instance scaled with the Scale tool, which stores `symbolData.uniformScaleFactor`, draws its layers at the main's lengths times that factor, composed through nested instances: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent, except where an override or derived row gives the value, which is already at the instance's scale; the vector outline paths a layer carries (a network's points follow the layer's size), per-point corner radii, fixed grid tracks, image tile scales and glass effects are not scaled by the factor, #2091), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join); an elbowed one saved with its path (`connectorControlPoints`: a point and an axis per run between the first and the last) keeps that path when it leaves and arrives by the ends' sides (#1910), and a curved one's saved `connectorBezierControlPoints` are not read; a connector routed here takes its line's paint, weight, cap and dashes from the file's generated line record, as one with a saved snapshot does (#1807). A connector's label without a saved snapshot is kept as text centred on its line, without its background. Not read: table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 |
| `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA |
| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%); the rules for master backgrounds, symbol clipping, mask opacity, bitmap fills, image overrides, stretching stacks, blur radii, colour adjustments, gradient interpolation and text growth checked against Sketch's renders of files built for each case (black-box, synthetic) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session). Known limitations: a plain group (not a frame) that fills a stack keeps its layers' size where Sketch stretches them; a font Sketch can't find draws in the installed family's own weight (Sketch falls back to a regular face); text in the system SF Pro comes out a few percent narrower than Sketch's; macOS's reserved system fonts (PingFang) aren't found; brightness past about +0.4 is approximated (image filters can't add); OKLab gradients become sRGB stops (FigCraft gradients mix in sRGB) |
| `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with generated sticky and shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors (an elbowed connector keeps the path it was saved with, #1910), with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — |
@@ -189,6 +189,7 @@ source paint coordinates.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-07 | minor | `.fig` import, #2091 item 1: an instance scaled with the Scale tool stores `symbolData.uniformScaleFactor`, which the import never read, so the layers of a 128 px instance of a 256 px component came in at 256 px with the main's radii, strokes and spacing. The layers of an instance now take the main's lengths times the factor (composed through nested instances, so 0.5 inside 0.5 is 0.25 for the inner layers), the lengths the Scale tool scales: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent. A value an override or derived row gives is taken as written, already at the instance's scale; the instance's own size is the file's; a missing, non-finite, non-positive or out-of-range factor (outside 0.001 to 1000) changes nothing. Not scaled: vector outline paths (a network's points follow the layer's size), per-point corner radii, fixed grid tracks, image tile scales and glass effects. Tests: `fig::tests::a_scaled_instance_draws_its_layers_at_the_factor` and `fig::scale_factor_tests`. Numbers unchanged |
| 2026-11-03 | patch | M6, #1909: `.jam` import: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER` (the end aims at the layer's middle) left the side its stale `relativePosition` named, the left one in the reporter's file, so the connectors looped behind their shape or crossed it. It now leaves the side facing the other end, as a board connector drawn here does (`Side::Auto`), for straight, elbowed and curved connectors alike; a named magnet (`TOP`, `RIGHT`, `BOTTOM`, `LEFT`) is unchanged, and an end without a magnet still takes the edge its point lies on. The Plugin API lists the magnets (`ConnectorEndpoint.magnet`: "Which side of the node it attaches to"; NONE, AUTO, TOP, LEFT, BOTTOM, RIGHT, CENTER; "Straight connectors may use only CENTER or NONE magnets"). Test: `connectors_with_an_automatic_side_leave_the_side_facing_the_target`. Numbers unchanged. |
| 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). |
| 2026-11-04 | minor | `.fig` and `.jam` import, #1910: an elbowed connector saved with its path keeps it. FigJam stores `connectorControlPoints`, one per run between the first and the last, each a point on its run (in the connector's parent's coordinates) with the run's axis; the first run leaves the start's side and the last arrives at the end's. They become the `ConnectorPath::Runs` a board connector reshaped by hand has (`figcraft_doc::route_along`), so a connector drawn around a layer in FigJam stays around it instead of being routed through it. Points that don't make such a path (off both axes, two runs in a row the same way, a first or last run sideways to its side or going back into its object, more runs than a hand could make) leave the connector to the router as before, and only connectors routed here count in the "follow FigCraft's own routing" note. A curved connector's `connectorBezierControlPoints` are still not read. Tests: `tail_tests::an_elbow_connector_saved_with_its_path_keeps_it`, `tail_tests::an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here`. |
From 34c44e6063e4eb70f801bf3b8bffc6d6398faf6c Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 22:15:22 -0500
Subject: [PATCH 012/103] Text: a font loaded with text.loadFont wins over the
built-in and installed faces of the same family and style (#2092)
FigCraft chose the first face added for a family and style, so the embedded Inter 4.x kept Regular, SemiBold and Bold after Inter 3.19 was loaded and only Medium refitted. A loaded font is the counterpart of a font uploaded to the organization, which Figma's help center ranks first ("Manage conflicting fonts"). The test builds its faces from any embedded or installed font (skipped when there is none) and checks a loaded face beats a built-in one, the latest load beats earlier ones, and other styles resolve as before.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
---
crates/text/src/fontdb.rs | 29 +++++++++++++++++++++---
crates/text/src/fontdb_tests.rs | 39 +++++++++++++++++++++++++++++++++
docs/file-format-parity.md | 2 ++
3 files changed, 67 insertions(+), 3 deletions(-)
diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs
index 3c82cd261..5159a68d1 100644
--- a/crates/text/src/fontdb.rs
+++ b/crates/text/src/fontdb.rs
@@ -92,6 +92,10 @@ pub struct Face {
pub italic: bool,
pub bytes: FontBytes,
pub index: u32,
+ /// 0 for a built-in or installed face; else the order it was loaded in with `text.loadFont`
+ /// ([`FontDb::add_loaded`]). A loaded face wins over any other of its family and style, the
+ /// latest load over earlier ones (#2092).
+ pub loaded: u32,
pub upem: f64,
/// Ascent and descent in font units, both positive.
pub ascent: f64,
@@ -207,6 +211,7 @@ impl Face {
family,
style,
index,
+ loaded: 0,
upem,
ascent,
descent,
@@ -620,6 +625,8 @@ pub struct FontDb {
loaded_files: std::collections::BTreeSet<(std::path::PathBuf, u32)>,
/// Font files added as bytes (hash and length → families), so loading one again adds nothing.
loaded_bytes: std::collections::HashMap<(u64, usize), Vec>,
+ /// Loads so far ([`Face::loaded`]).
+ load_seq: u32,
system_scanned: bool,
/// Browser fonts text asked for and not yet taken by the browser layer.
wanted: Vec,
@@ -716,8 +723,20 @@ impl FontDb {
face
}
+ /// [`add`](Self::add) a face loaded explicitly (`text.loadFont`): it wins over the other faces
+ /// of its family and style, and over those loaded before it.
+ pub fn add_loaded(&mut self, mut face: Face) -> Arc {
+ self.load_seq += 1;
+ face.loaded = self.load_seq;
+ self.add(face)
+ }
+
/// Add font file bytes (all faces of a collection). Returns the faces added.
pub fn add_bytes(&mut self, bytes: Vec) -> Vec> {
+ self.add_bytes_with(bytes, false)
+ }
+
+ fn add_bytes_with(&mut self, bytes: Vec, loaded: bool) -> Vec> {
let shared = Arc::new(bytes);
let count = match FileRef::new(shared.as_slice()) {
// A collection's header may name any number of faces, each a few bytes pointing at
@@ -727,7 +746,9 @@ impl FontDb {
Ok(FileRef::Font(_)) => 1,
Err(_) => 0,
};
- (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None)).map(|f| self.add(f)).collect()
+ (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None))
+ .map(|f| if loaded { self.add_loaded(f) } else { self.add(f) })
+ .collect()
}
/// The family and style of the face whose PostScript name is `ps` (ignoring case), loaded or
@@ -1035,7 +1056,9 @@ impl FontDb {
/// The loaded face of `family` nearest `style`.
fn best(&self, family: &str, style: &str) -> Option> {
let (_, idx) = self.families.get(&self.key(family))?;
- let faces: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
+ let mut faces: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect();
+ // A loaded font comes first (the latest load first): of faces as near, the first wins.
+ faces.sort_by_key(|f| std::cmp::Reverse(f.loaded));
let want = norm_style(style);
if let Some(f) = faces.iter().find(|f| norm_style(&f.style) == want).or_else(|| faces.iter().find(|f| has_style(f, style))) {
return Some((*f).clone());
@@ -1595,7 +1618,7 @@ pub fn load_font_bytes(bytes: Vec) -> Vec {
if let Some(families) = db.loaded_bytes.get(&key) {
return families.clone();
}
- let faces = db.add_bytes(bytes);
+ let faces = db.add_bytes_with(bytes, true);
let mut families: Vec = faces.iter().map(|f| f.family.clone()).collect();
families.dedup();
if !families.is_empty() {
diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs
index 38e04c790..a3dd15396 100644
--- a/crates/text/src/fontdb_tests.rs
+++ b/crates/text/src/fontdb_tests.rs
@@ -382,6 +382,45 @@ fn font_bytes_load_into_the_db() {
}
}
+/// #2092: a font loaded with `text.loadFont` wins over the built-in and installed faces of its family
+/// and style, the latest load over earlier ones; other styles resolve as before.
+#[test]
+fn a_loaded_face_wins_over_the_built_in_one_of_its_family_and_style() {
+ // Any embedded or installed font stands in for the faces (none without craft-fonts on CI).
+ let stand_in = CRAFT_FONTS.first().map(|f| f.bytes.to_vec()).or_else(|| {
+ let mut installed = BTreeMap::new();
+ for dir in system_font_dirs() {
+ scan_dir(&dir, 0, &mut installed);
+ }
+ installed.values().flat_map(|(_, v)| v).find_map(|p| std::fs::read(&p.path).ok())
+ });
+ let Some(bytes) = stand_in.map(Arc::new) else {
+ eprintln!("skipping: no font to stand in");
+ return;
+ };
+ let face = |style: &str, name: &str| {
+ let mut f = Face::from_bytes(FontBytes::Shared(bytes.clone()), 0, Some("Brand Sans"), Some(style)).unwrap();
+ f.instances.clear();
+ f.postscript = Some(name.into());
+ f
+ };
+ let name = |db: &FontDb, style: &str| db.best("Brand Sans", style).and_then(|f| f.postscript.clone());
+ let mut db = FontDb::default();
+ db.add(face("Regular", "Built-in-Regular"));
+ db.add(face("Bold", "Built-in-Bold"));
+ assert_eq!(name(&db, "Regular").as_deref(), Some("Built-in-Regular"));
+ db.add_loaded(face("Regular", "Loaded-Regular"));
+ assert_eq!(name(&db, "Regular").as_deref(), Some("Loaded-Regular"));
+ // Bold wasn't loaded: the built-in one stays. Nor does the loaded Regular stand in for it.
+ assert_eq!(name(&db, "Bold").as_deref(), Some("Built-in-Bold"));
+ // The latest load wins.
+ db.add_loaded(face("Regular", "Loaded-Regular-2"));
+ assert_eq!(name(&db, "Regular").as_deref(), Some("Loaded-Regular-2"));
+ // A face added after a load, not loaded, doesn't displace it.
+ db.add(face("Regular", "Installed-Regular"));
+ assert_eq!(name(&db, "Regular").as_deref(), Some("Loaded-Regular-2"));
+}
+
/// Apple's system font answers to the names designs and CSS use for it (SF Pro, SF UI Text,
/// system-ui, …), unless a font of that name is installed.
#[test]
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index bce90b008..0aaf249ca 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -181,6 +181,7 @@ source paint coordinates.
| | Figma | FigCraft |
|---|---|---|
| Local fonts | yes (font helper / desktop app) | yes (system scan) |
+| Conflicting fonts | an uploaded organization font comes first, then fonts installed on the device, then Figma's web fonts (help center, "Manage conflicting fonts") | a font loaded with `text.loadFont` wins over the built-in and installed faces of the same family and style, the latest load over earlier ones (#2092); installed versus built-in order is unchanged, and browser local fonts count as installed |
| 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 | 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. A family the font store has (Google Fonts, Noto) downloads from the window once the user allows downloading fonts (`font.download`, #754): pinned, checksum-verified files into the Craft apps' shared font folder, after which the text draws in it. A family is found however a file names it: by the legacy name of one of its weights ("Family Medium") or by the typographic name of a family installed one family per weight, and spelt with other case, spaces or hyphens (#1292). No substitute picker |
@@ -189,6 +190,7 @@ source paint coordinates.
| Date | Change | Summary |
|---|---|---|
+| 2026-11-07 | patch | Fonts, #2092: a font loaded with `text.loadFont` is the counterpart of a font uploaded to the organization, which Figma's help center ("Manage conflicting fonts") ranks first, ahead of installed fonts and the web fonts. FigCraft chose the first face added for a family and style, so the built-in Inter 4.x kept Regular, SemiBold and Bold after Inter 3.19 was loaded and only Medium refitted. A loaded face now wins over the other faces of its family and style, the latest load over earlier ones; style matching and fallbacks are unchanged, and `refitted` lists the text whose size changes. Installed versus built-in order is not changed here. Test: `fontdb::tests::a_loaded_face_wins_over_the_built_in_one_of_its_family_and_style`. Numbers unchanged. |
| 2026-11-03 | patch | M6, #1909: `.jam` import: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER` (the end aims at the layer's middle) left the side its stale `relativePosition` named, the left one in the reporter's file, so the connectors looped behind their shape or crossed it. It now leaves the side facing the other end, as a board connector drawn here does (`Side::Auto`), for straight, elbowed and curved connectors alike; a named magnet (`TOP`, `RIGHT`, `BOTTOM`, `LEFT`) is unchanged, and an end without a magnet still takes the edge its point lies on. The Plugin API lists the magnets (`ConnectorEndpoint.magnet`: "Which side of the node it attaches to"; NONE, AUTO, TOP, LEFT, BOTTOM, RIGHT, CENTER; "Straight connectors may use only CENTER or NONE magnets"). Test: `connectors_with_an_automatic_side_leave_the_side_facing_the_target`. Numbers unchanged. |
| 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). |
| 2026-11-04 | minor | `.fig` and `.jam` import, #1910: an elbowed connector saved with its path keeps it. FigJam stores `connectorControlPoints`, one per run between the first and the last, each a point on its run (in the connector's parent's coordinates) with the run's axis; the first run leaves the start's side and the last arrives at the end's. They become the `ConnectorPath::Runs` a board connector reshaped by hand has (`figcraft_doc::route_along`), so a connector drawn around a layer in FigJam stays around it instead of being routed through it. Points that don't make such a path (off both axes, two runs in a row the same way, a first or last run sideways to its side or going back into its object, more runs than a hand could make) leave the connector to the router as before, and only connectors routed here count in the "follow FigCraft's own routing" note. A curved connector's `connectorBezierControlPoints` are still not read. Tests: `tail_tests::an_elbow_connector_saved_with_its_path_keeps_it`, `tail_tests::an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here`. |
From a375ac06893ed41af6916508c18bb5896e1e527b Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 22:15:41 -0500
Subject: [PATCH 013/103] Format (#2092)
Co-Authored-By: Claude Sonnet 5.5
---
crates/text/src/fontdb.rs | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs
index 5159a68d1..65df051e7 100644
--- a/crates/text/src/fontdb.rs
+++ b/crates/text/src/fontdb.rs
@@ -746,7 +746,8 @@ impl FontDb {
Ok(FileRef::Font(_)) => 1,
Err(_) => 0,
};
- (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None))
+ (0..count)
+ .filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None))
.map(|f| if loaded { self.add_loaded(f) } else { self.add(f) })
.collect()
}
From c7c5a4f87d036f0282a539ee88c1af80501c2844 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 22:23:39 -0500
Subject: [PATCH 014/103] .fig import: a nested instance's own rows take its
scale; blur positions and swapped styles are not rescaled (#2100 review)
- A nested instance's override and derived rows (and its text property values) are written in the enclosing main's space: OverrideRow carries the factor of the layer that supplied it and View::with takes (row, scale) pairs. Test: a_nested_instances_own_rows_are_at_its_enclosing_mains_scale.
- Progressive blur startOffset and endOffset are unit-box fractions, not lengths: only offset and noiseSize scale. Test: a_progressive_blurs_positions_are_not_lengths.
- An effect or grid style takes the factor of the layer that links it (1 when an instance row swaps it), not the main's. Test: an_effect_style_takes_the_scale_of_the_layer_that_links_it.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Fable 5.1 reviewed; Sonnet 5.5 wrote at high
---
crates/format/src/fig/map.rs | 13 ++-
crates/format/src/fig/map/component.rs | 29 +++--
crates/format/src/fig/map/connector.rs | 6 +-
crates/format/src/fig/map/layer.rs | 37 ++++--
crates/format/src/fig/map/paint.rs | 8 +-
crates/format/src/fig/scale_factor_tests.rs | 120 +++++++++++++++++++-
6 files changed, 185 insertions(+), 28 deletions(-)
diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs
index 173439c5c..fcaa15eda 100644
--- a/crates/format/src/fig/map.rs
+++ b/crates/format/src/fig/map.rs
@@ -230,15 +230,22 @@ impl<'a> View<'a> {
})
}
- fn with(&self, top: impl IntoIterator>) -> Self {
+ /// This view with `top` over it, each layer with the factor its lengths take.
+ fn with(&self, top: impl IntoIterator, f64)>) -> Self {
let mut v = self.clone();
- for layer in top {
+ for (layer, scale) in top {
v.layers.push(layer);
- v.scales.push(1.0);
+ v.scales.push(scale);
}
v
}
+ /// The factor of the layer that links a style in `key` (or `legacy`), the lowest layer's when
+ /// none does: what the linked style's lengths take.
+ fn link_scale(&self, key: &str, legacy: &str) -> f64 {
+ self.find(key).or_else(|| self.find(legacy)).map_or_else(|| self.base_scale(), |f| f.1)
+ }
+
/// This view with `style` just above the layer that links it in `key` (or `legacy`): the style
/// beats that layer's and lower layers' copies of its fields (stale when the style changed
/// since, or written for the style linked before), and override rows above the link beat it.
diff --git a/crates/format/src/fig/map/component.rs b/crates/format/src/fig/map/component.rs
index 9c2788ff2..76e8b861a 100644
--- a/crates/format/src/fig/map/component.rs
+++ b/crates/format/src/fig/map/component.rs
@@ -41,6 +41,10 @@ pub(super) struct OverrideRow<'a> {
level: usize,
/// Position among the rows of the instance that wrote it; later rows win.
ordinal: usize,
+ /// The factor the row's lengths take: 1 for the rows of an instance in the document, the
+ /// factor of the layer of the enclosing main that holds the rows for a nested instance's own
+ /// (they are written in that main's space, #2091).
+ pub(super) scale: f64,
}
/// The nested level an override row restates: its `overrideLevel`, capped; 0 when it has none.
@@ -84,7 +88,9 @@ pub(super) struct Inst<'a> {
pub(super) root: String,
pub(super) path: Vec,
pub(super) overrides: Rc>,
- props: Rc>>,
+ /// Property values with the factor their lengths take (the layer of the writing instance's
+ /// node that holds the assignment, as for override rows).
+ props: Rc, f64)>>,
/// Slot property GUID → private slot-content node GUID.
pub(super) slot_contents: Rc>,
/// Layers imported from a slot's independent content subtree.
@@ -264,7 +270,7 @@ impl<'a> Mapper<'a> {
{
refs.insert(field.into(), json!(name));
}
- let Some(val) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue };
+ let Some(&(val, val_scale)) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue };
match field {
"visible" => {
if let Some(b) = val.get("boolValue").and_then(Kv::bool) {
@@ -282,7 +288,7 @@ impl<'a> Mapper<'a> {
o.remove("_figLineStarts");
if let Some(tv) = prop_text_data(val) {
let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64);
- char_styles(o, tv, 1.0, size, &mut |w| self.warn(w));
+ char_styles(o, tv, val_scale, size, &mut |w| self.warn(w));
}
}
}
@@ -302,7 +308,7 @@ impl<'a> Mapper<'a> {
// An instance swap property value picks the main.
if let Some(i) = outer
&& let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID")
- && let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|p| prop_guid(p))
+ && let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|(p, _)| prop_guid(p))
{
sym = Some(g);
}
@@ -367,10 +373,15 @@ impl<'a> Mapper<'a> {
(a, b) => a.is_some() != b.is_some(),
};
let mut local: Overrides<'a> = BTreeMap::new();
- let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")];
+ // The lists are at the scale of the layer of this node that holds them.
+ let scale_of = |k: &str| v.find(k).map_or(1.0, |f| f.1);
+ let lists = [
+ (sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), scale_of("symbolData")),
+ (v.list("derivedSymbolData"), scale_of("derivedSymbolData")),
+ ];
let source_depth = outer.map_or(0, |i| i.symbols.len());
let mut ordinal = 0;
- for (layer, list) in lists.into_iter().enumerate() {
+ for (layer, (list, row_scale)) in lists.into_iter().enumerate() {
for ov in list {
let row_ordinal = ordinal;
ordinal += 1;
@@ -392,6 +403,7 @@ impl<'a> Mapper<'a> {
source_depth,
level: override_level(ov),
ordinal: row_ordinal,
+ scale: row_scale,
});
}
}
@@ -405,11 +417,12 @@ impl<'a> Mapper<'a> {
rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth + row.level), std::cmp::Reverse(row.source_depth), row.ordinal));
}
// This instance's own property values and slot contents; the outer instances' win.
- let mut all_props: BTreeMap> = BTreeMap::new();
+ let assignment_scale = v.find("componentPropAssignments").map_or(1.0, |f| f.1);
+ let mut all_props: BTreeMap, f64)> = BTreeMap::new();
let mut slot_contents: BTreeMap = BTreeMap::new();
for a in assignments {
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) {
- all_props.entry(self.prop_root(&d)).or_insert(val);
+ all_props.entry(self.prop_root(&d)).or_insert((val, assignment_scale));
}
let Some(def) = a.get("defID").and_then(guid) else { continue };
if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") {
diff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs
index 95d988248..22514e5b6 100644
--- a/crates/format/src/fig/map/connector.rs
+++ b/crates/format/src/fig/map/connector.rs
@@ -285,7 +285,7 @@ impl<'a> Mapper<'a> {
if let Some(name) = v.str("name") {
vector.insert("name".into(), json!(if nested { format!("{name} vector") } else { name.to_string() }));
}
- let line_view = View::of(line).with([line_style]);
+ let line_view = View::of(line).with([(line_style, 1.0)]);
self.common(&mut vector, &line_view, "VECTOR", space, None);
self.geometry(&mut vector, &line_view);
for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] {
@@ -328,7 +328,7 @@ impl<'a> Mapper<'a> {
}
return children;
};
- let text_view = View::of(text_frame).with([text_style]);
+ let text_view = View::of(text_frame).with([(text_style, 1.0)]);
let Some(characters) = text_view.get("textData").and_then(|d| d.get("characters")).and_then(Kv::str).filter(|s| !s.trim().is_empty()) else {
return children;
};
@@ -359,7 +359,7 @@ impl<'a> Mapper<'a> {
self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view);
return children;
};
- let background_view = View::of(background).with([background_style]);
+ let background_view = View::of(background).with([(background_style, 1.0)]);
if !background_view.list("fillPaints").is_empty() {
if self.count >= self.budget() {
self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes));
diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs
index d15cd2478..6750b0005 100644
--- a/crates/format/src/fig/map/layer.rs
+++ b/crates/format/src/fig/map/layer.rs
@@ -129,7 +129,7 @@ impl<'a> Mapper<'a> {
// Inside a scaled instance the main's lengths are the factor times as long; the rows of
// the instance are at its scale already (#2091).
let scale = inst.map_or(1.0, |i| i.scale);
- let mut view = View::of_scaled(base, scale).with(entries.iter().map(|e| e.row));
+ let mut view = View::of_scaled(base, scale).with(entries.iter().map(|e| (e.row, e.scale)));
// A layer of a resized instance the file gives no derived layout for follows its
// constraints from the main's geometry, then takes the size and place overridden on it
// (auto layout places the others, as in the editor).
@@ -137,15 +137,28 @@ impl<'a> Mapper<'a> {
let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID"))
&& view.str("stackPositioning") != Some("ABSOLUTE");
let main = View::of_scaled(base, scale);
- let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(k));
+ // The size and place a row gives, at the scale of the row (a nested instance's own rows
+ // are at its enclosing main's scale).
+ let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(k).map(|r| (r, e.scale)));
+ let set_size = || {
+ let (r, k) = set("size")?;
+ let (x, y) = size(r)?;
+ Some((scaled(x, k)?, scaled(y, k)?))
+ };
+ let set_transform = || {
+ let (r, k) = set("transform")?;
+ let m = matrix(r)?;
+ let [a, b, c, d, e, f] = m.as_coeffs();
+ Some(Affine::new([a, b, c, d, scaled(e, k)?, scaled(f, k)?]))
+ };
if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) {
let t = main.transform().unwrap_or_default();
let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint"));
- view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft)));
+ view.fitted = Some((set_size().unwrap_or(fw), set_transform().unwrap_or(ft)));
} else if entries.iter().any(|e| e.entry == Entry::Stale && (e.row.get("size").is_some() || e.row.get("transform").is_some()))
- && let Some(s) = set("size").and_then(size).or_else(|| main.size())
+ && let Some(s) = set_size().or_else(|| main.size())
{
- view.fitted = Some((s, set("transform").and_then(matrix).or_else(|| main.transform()).unwrap_or_default()));
+ view.fitted = Some((s, set_transform().or_else(|| main.transform()).unwrap_or_default()));
}
let rest_id = match (inst, &path) {
(Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")),
@@ -370,7 +383,7 @@ impl<'a> Mapper<'a> {
}
continue;
};
- let child = View::of(style).with([bounds]);
+ let child = View::of(style).with([(bounds, 1.0)]);
let chars = own_text(&child);
if local_id != 0 && chars.is_none() {
continue;
@@ -518,14 +531,18 @@ impl<'a> Mapper<'a> {
.unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()));
o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect()));
let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles);
- let (effects, effects_scale) =
- effect_style.map_or_else(|| v.list_scaled("effects"), |s| (s.get("effects").map(Kv::list).unwrap_or_default(), v.base_scale()));
+ let (effects, effects_scale) = effect_style.map_or_else(
+ || v.list_scaled("effects"),
+ |s| (s.get("effects").map(Kv::list).unwrap_or_default(), v.link_scale("styleIdForEffect", "inheritEffectStyleID")),
+ );
if !effects.is_empty() {
o.insert("effects".into(), Value::Array(effects.iter().filter_map(|e| effect(e, effects_scale)).collect()));
}
let grid_style = self.style(v, "styleIdForGrid", "inheritGridStyleID", "grid", &mut styles);
- let (grids, grids_scale) =
- grid_style.map_or_else(|| v.list_scaled("layoutGrids"), |s| (s.get("layoutGrids").map(Kv::list).unwrap_or_default(), v.base_scale()));
+ let (grids, grids_scale) = grid_style.map_or_else(
+ || v.list_scaled("layoutGrids"),
+ |s| (s.get("layoutGrids").map(Kv::list).unwrap_or_default(), v.link_scale("styleIdForGrid", "inheritGridStyleID")),
+ );
if !grids.is_empty() {
o.insert("layoutGrids".into(), Value::Array(grids.iter().map(|g| layout_grid(g, grids_scale)).collect()));
}
diff --git a/crates/format/src/fig/map/paint.rs b/crates/format/src/fig/map/paint.rs
index b563c11b0..f672c1b6c 100644
--- a/crates/format/src/fig/map/paint.rs
+++ b/crates/format/src/fig/map/paint.rs
@@ -285,11 +285,17 @@ pub(super) fn effect(e: &Kv, k: f64) -> Option {
o.insert(k.into(), color(c));
}
}
- for (from, to) in [("offset", "offset"), ("startOffset", "startOffset"), ("endOffset", "endOffset"), ("noiseSize", "noiseSizeVector")] {
+ // `startOffset` and `endOffset` are positions in the layer's unit box, not lengths: they stay.
+ for (from, to) in [("offset", "offset"), ("noiseSize", "noiseSizeVector")] {
if let Some(p) = e.get(from).and_then(vec2) {
o.insert(to.into(), scale_vec2(p, k));
}
}
+ for key in ["startOffset", "endOffset"] {
+ if let Some(p) = e.get(key).and_then(vec2) {
+ o.insert(key.into(), p);
+ }
+ }
if e.get("blurOpType").and_then(Kv::str) == Some("PROGRESSIVE") {
o.insert("blurType".into(), json!("PROGRESSIVE"));
}
diff --git a/crates/format/src/fig/scale_factor_tests.rs b/crates/format/src/fig/scale_factor_tests.rs
index 79aa7f949..34709a17f 100644
--- a/crates/format/src/fig/scale_factor_tests.rs
+++ b/crates/format/src/fig/scale_factor_tests.rs
@@ -12,7 +12,9 @@ use super::*;
const SCHEMA: &str = "
enum NodeType NONE DOCUMENT CANVAS FRAME ROUNDED_RECTANGLE TEXT SYMBOL INSTANCE
enum StackMode NONE HORIZONTAL VERTICAL
-enum EffectType DROP_SHADOW
+enum EffectType DROP_SHADOW FOREGROUND_BLUR
+enum BlurOpType NORMAL PROGRESSIVE
+enum StyleType NONE FILL TEXT EFFECT GRID
enum NumberUnits RAW PIXELS PERCENT
struct GUID uint sessionID, uint localID
struct Vector float x, float y
@@ -21,10 +23,11 @@ message Number float value, NumberUnits units
message ParentIndex GUID guid, string position
message FontName string family, string style, string postscript
message TextData string characters
-message Effect EffectType type, float radius, float spread, Vector offset
+message Effect EffectType type, float radius, float spread, Vector offset, float startRadius, Vector startOffset, Vector endOffset, BlurOpType blurOpType
+message StyleId GUID guid
message GUIDPath GUID[] guids
message SymbolData GUID symbolID, NodeChange[] symbolOverrides, float uniformScaleFactor
-message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float cornerRadius, float strokeWeight, float[] dashPattern, float fontSize, FontName fontName, TextData textData, SymbolData symbolData, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, float stackCounterSpacing, Effect[] effects, Number lineHeight, Number letterSpacing, NodeChange[] derivedSymbolData, GUIDPath guidPath
+message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float cornerRadius, float strokeWeight, float[] dashPattern, float fontSize, FontName fontName, TextData textData, SymbolData symbolData, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, float stackCounterSpacing, Effect[] effects, Number lineHeight, Number letterSpacing, NodeChange[] derivedSymbolData, GUIDPath guidPath, StyleType styleType, StyleId styleIdForEffect
message Blob byte[] bytes
message Message NodeChange[] nodeChanges, Blob[] blobs
";
@@ -254,3 +257,114 @@ fn a_junk_factor_is_ignored() {
assert_eq!((size, place, radii), ([200.0, 200.0], [20.0, 40.0], [64.0; 4]), "factor {factor}");
}
}
+
+#[test]
+fn a_nested_instances_own_rows_are_at_its_enclosing_mains_scale() {
+ let mut nodes = page();
+ // Inner: 200 px with a 200 px rectangle "Dot" of radius 40.
+ nodes.push(node(5, Some((1, "!")), "SYMBOL", "Inner", vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0))]));
+ nodes.push(node(
+ 6,
+ Some((5, "!")),
+ "ROUNDED_RECTANGLE",
+ "Dot",
+ vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0)), ("cornerRadius", f(40.0))],
+ ));
+ // Outer: 200 px, holding Kid, an instance of Inner at 100 px (factor 0.5). Kid has the derived
+ // row Figma writes for every nested instance and an override of its own.
+ nodes.push(node(7, Some((1, "\"")), "SYMBOL", "Outer", vec![("size", xy(200.0, 200.0)), ("transform", at(300.0, 0.0))]));
+ let dot = |more: Vec<(&'static str, Kv<'static>)>| {
+ let mut fields = vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))]))];
+ fields.extend(more);
+ o(fields)
+ };
+ let symbol = o(vec![("symbolID", g(5)), ("uniformScaleFactor", f(0.5)), ("symbolOverrides", arr(vec![dot(vec![("cornerRadius", f(7.0))])]))]);
+ let kid = node(
+ 8,
+ Some((7, "!")),
+ "INSTANCE",
+ "Kid",
+ vec![
+ ("size", xy(100.0, 100.0)),
+ ("transform", at(20.0, 20.0)),
+ ("symbolData", symbol),
+ ("derivedSymbolData", arr(vec![dot(vec![("size", xy(100.0, 100.0))])])),
+ ],
+ );
+ nodes.push(kid);
+ // Half: Outer at factor 0.5.
+ nodes.push(instance(9, (1, "#"), "Half", 7, (100.0, 100.0), Some(0.5), vec![]));
+ // Whole: Outer at its own size, for the unscaled reading.
+ nodes.push(instance(10, (1, "$"), "Whole", 7, (200.0, 200.0), None, vec![]));
+ let doc = import_nodes(nodes);
+ let dot_of = |name: &str| {
+ let kid = first_child(&doc, name);
+ doc.node(kid.children[0]).unwrap()
+ };
+ // Both rows are in Outer's space: Half draws them at half.
+ let half = dot_of("Half");
+ assert_eq!(([half.width, half.height], half.corners.radii), ([50.0, 50.0], [3.5; 4]));
+ let whole = dot_of("Whole");
+ assert_eq!(([whole.width, whole.height], whole.corners.radii), ([100.0, 100.0], [7.0; 4]));
+}
+
+#[test]
+fn a_progressive_blurs_positions_are_not_lengths() {
+ let mut nodes = page();
+ let blur = o(vec![
+ ("type", e("FOREGROUND_BLUR")),
+ ("radius", f(8.0)),
+ ("blurOpType", e("PROGRESSIVE")),
+ ("startRadius", f(2.0)),
+ ("startOffset", xy(0.5, 0.0)),
+ ("endOffset", xy(0.5, 1.0)),
+ ]);
+ nodes.push(node(5, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0))]));
+ nodes.push(node(
+ 6,
+ Some((5, "!")),
+ "ROUNDED_RECTANGLE",
+ "Mark",
+ vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0)), ("effects", arr(vec![blur]))],
+ ));
+ nodes.push(instance(9, (1, "\""), "Half", 5, (128.0, 128.0), Some(0.5), vec![]));
+ let doc = import_nodes(nodes);
+ let EffectKind::LayerBlur(b) = &first_child(&doc, "Half").effects[0].kind else { panic!("not a layer blur") };
+ // The radii are lengths and scale; the ramp's ends are fractions of the layer and do not.
+ assert_eq!((b.radius, b.start_radius, b.start, b.end), (4.0, 1.0, (0.5, 0.0), (0.5, 1.0)));
+}
+
+#[test]
+fn an_effect_style_takes_the_scale_of_the_layer_that_links_it() {
+ let mut nodes = page();
+ let shadow = |radius: f64| o(vec![("type", e("DROP_SHADOW")), ("radius", f(radius))]);
+ let link = |id: u64| o(vec![("guid", g(id))]);
+ nodes.push(node(30, Some((1, "~")), "ROUNDED_RECTANGLE", "Soft", vec![("styleType", e("EFFECT")), ("effects", arr(vec![shadow(10.0)]))]));
+ nodes.push(node(31, Some((1, "}")), "ROUNDED_RECTANGLE", "Hard", vec![("styleType", e("EFFECT")), ("effects", arr(vec![shadow(10.0)]))]));
+ nodes.push(node(5, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0))]));
+ nodes.push(node(
+ 6,
+ Some((5, "!")),
+ "ROUNDED_RECTANGLE",
+ "Mark",
+ vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0)), ("styleIdForEffect", link(30))],
+ ));
+ // Kept: the main's own layer links the style, so the instance's factor applies.
+ nodes.push(instance(9, (1, "\""), "Kept", 5, (128.0, 128.0), Some(0.5), vec![]));
+ // Swapped: a row of the instance links another style, which is at the instance's scale.
+ let row = o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("styleIdForEffect", link(31))]);
+ let symbol = o(vec![("symbolID", g(5)), ("uniformScaleFactor", f(0.5)), ("symbolOverrides", arr(vec![row]))]);
+ nodes.push(node(
+ 10,
+ Some((1, "#")),
+ "INSTANCE",
+ "Swapped",
+ vec![("size", xy(128.0, 128.0)), ("transform", at(600.0, 0.0)), ("symbolData", symbol)],
+ ));
+ let doc = import_nodes(nodes);
+ let radius = |name: &str| {
+ let EffectKind::DropShadow(sh) = &first_child(&doc, name).effects[0].kind else { panic!("not a drop shadow") };
+ sh.radius
+ };
+ assert_eq!((radius("Kept"), radius("Swapped")), (5.0, 10.0));
+}
From 253e0c3df3951f4a6e6f4f85e6d2ae0a08dfd309 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 22:49:04 -0500
Subject: [PATCH 015/103] Components: Create component set refuses nested
layers and is translated (#2095 review)
component.createSet now refuses a selection where one layer is inside another, as component.combineVariants does, before changing the document (the nested layer was silently dropped from the set). Adds the component.createSet label to the 22 locale catalogs that list component.createMultiple.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
---
crates/engine/src/cmd/component/variants.rs | 3 +++
crates/engine/src/components_behaviour_tests.rs | 14 ++++++++++++++
crates/ui-egui/locales/ar.toml | 1 +
crates/ui-egui/locales/bs.toml | 1 +
crates/ui-egui/locales/cs.toml | 1 +
crates/ui-egui/locales/de.toml | 1 +
crates/ui-egui/locales/es.toml | 1 +
crates/ui-egui/locales/fi.toml | 1 +
crates/ui-egui/locales/fr.toml | 1 +
crates/ui-egui/locales/id.toml | 1 +
crates/ui-egui/locales/it.toml | 1 +
crates/ui-egui/locales/ja.toml | 1 +
crates/ui-egui/locales/ka.toml | 1 +
crates/ui-egui/locales/ko.toml | 1 +
crates/ui-egui/locales/nl.toml | 1 +
crates/ui-egui/locales/pl.toml | 1 +
crates/ui-egui/locales/pt-BR.toml | 1 +
crates/ui-egui/locales/ru.toml | 1 +
crates/ui-egui/locales/sv.toml | 1 +
crates/ui-egui/locales/th.toml | 1 +
crates/ui-egui/locales/tr.toml | 1 +
crates/ui-egui/locales/vi.toml | 1 +
crates/ui-egui/locales/zh-Hans.toml | 1 +
crates/ui-egui/locales/zh-Hant.toml | 1 +
24 files changed, 39 insertions(+)
diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs
index 53e00ca6d..cfba5accf 100644
--- a/crates/engine/src/cmd/component/variants.rs
+++ b/crates/engine/src/cmd/component/variants.rs
@@ -115,6 +115,9 @@ pub(super) fn create_set(s: &mut Session, p: &Value) -> Result {
}) {
return Err(EngineError::Disabled(cmd.into(), "select layers that aren't components, component sets or instances".into()));
}
+ if roots(doc, &ids).len() != ids.len() {
+ return Err(EngineError::Disabled(cmd.into(), "a layer can't be in a set with one inside it".into()));
+ }
let mut made = Vec::new();
for i in roots(doc, &ids) {
let r = create(s, &json!({"ids": [i]}))?;
diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs
index f65049de5..510fb2e74 100644
--- a/crates/engine/src/components_behaviour_tests.rs
+++ b/crates/engine/src/components_behaviour_tests.rs
@@ -1139,3 +1139,17 @@ fn create_set_refuses_instances_and_leaves_the_document_alone() {
assert_eq!(undo_len(&s), before);
assert!(matches!(s.doc().unwrap().node(frames[1]).unwrap().kind, NodeKind::Frame));
}
+
+#[test]
+fn create_set_refuses_a_target_inside_another_target_and_leaves_the_document_alone() {
+ let mut s = Session::new();
+ let frames = named_frames(&mut s, &["Outer", "Inner"], false);
+ s.execute("node.reparent", &json!({"ids": [frames[1]], "parent": frames[0]})).unwrap();
+ let before = undo_len(&s);
+ let e = s.execute("component.createSet", &json!({"ids": [frames[0], frames[1]]})).unwrap_err();
+ assert!(matches!(e, crate::EngineError::Disabled(..)), "{e:?}");
+ assert_eq!(undo_len(&s), before);
+ let doc = s.doc().unwrap();
+ assert!(matches!(doc.node(frames[0]).unwrap().kind, NodeKind::Frame));
+ assert!(matches!(doc.node(frames[1]).unwrap().kind, NodeKind::Frame));
+}
diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml
index da4474e79..cbbe8e07d 100644
--- a/crates/ui-egui/locales/ar.toml
+++ b/crates/ui-egui/locales/ar.toml
@@ -2009,6 +2009,7 @@ status = "draft — needs native review"
"component.combineVariants" = "جمع كتنويعات"
"component.create" = "إنشاء مكوّن"
"component.createMultiple" = "إنشاء مكونات متعددة"
+"component.createSet" = "إنشاء مجموعة مكونات"
"component.deleteProperty" = "حذف خاصية المكوّن"
"component.detach" = "فصل النسخة المثيلة"
"component.editProperty" = "تحرير خاصية المكوّن"
diff --git a/crates/ui-egui/locales/bs.toml b/crates/ui-egui/locales/bs.toml
index 36d71664c..e39053f08 100644
--- a/crates/ui-egui/locales/bs.toml
+++ b/crates/ui-egui/locales/bs.toml
@@ -2038,6 +2038,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Kombinuj kao varijante"
"component.create" = "Kreiraj komponentu"
"component.createMultiple" = "Kreiraj više komponenti"
+"component.createSet" = "Kreiraj skup komponenti"
"component.deleteProperty" = "Izbriši svojstvo komponente"
"component.detach" = "Odvoji instancu"
"component.editProperty" = "Uredi svojstvo komponente"
diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml
index d401654ac..de7f9cc89 100644
--- a/crates/ui-egui/locales/cs.toml
+++ b/crates/ui-egui/locales/cs.toml
@@ -1913,6 +1913,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Sloučit jako varianty"
"component.create" = "Vytvořit komponentu"
"component.createMultiple" = "Vytvořit více komponent"
+"component.createSet" = "Vytvořit sadu komponent"
"component.deleteProperty" = "Smazat vlastnost komponenty"
"component.detach" = "Odpojit instanci"
"component.editProperty" = "Upravit vlastnost komponenty"
diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml
index 6fd1838d6..6256fc1e0 100644
--- a/crates/ui-egui/locales/de.toml
+++ b/crates/ui-egui/locales/de.toml
@@ -2079,6 +2079,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Als Varianten kombinieren"
"component.create" = "Komponente erstellen"
"component.createMultiple" = "Mehrere Komponenten erstellen"
+"component.createSet" = "Komponentensatz erstellen"
"component.deleteProperty" = "Komponenteneigenschaft löschen"
"component.detach" = "Instanz lösen"
"component.editProperty" = "Komponenteneigenschaft bearbeiten"
diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml
index 2baa2b31e..6c2d171c3 100644
--- a/crates/ui-egui/locales/es.toml
+++ b/crates/ui-egui/locales/es.toml
@@ -2002,6 +2002,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Combinar como variantes"
"component.create" = "Crear componente"
"component.createMultiple" = "Crear varios componentes"
+"component.createSet" = "Crear conjunto de componentes"
"component.deleteProperty" = "Eliminar propiedad de componente"
"component.detach" = "Separar instancia"
"component.editProperty" = "Editar propiedad de componente"
diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml
index 2f5db319a..a1b33417f 100644
--- a/crates/ui-egui/locales/fi.toml
+++ b/crates/ui-egui/locales/fi.toml
@@ -2086,6 +2086,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Yhdistä varianteiksi"
"component.create" = "Luo komponentti"
"component.createMultiple" = "Luo useita komponentteja"
+"component.createSet" = "Luo komponenttijoukko"
"component.deleteProperty" = "Poista komponentin ominaisuus"
"component.detach" = "Irrota ilmentymä"
"component.editProperty" = "Muokkaa komponentin ominaisuutta"
diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml
index d47ad9597..e5feebc9b 100644
--- a/crates/ui-egui/locales/fr.toml
+++ b/crates/ui-egui/locales/fr.toml
@@ -1958,6 +1958,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Combiner en variantes"
"component.create" = "Créer un composant"
"component.createMultiple" = "Créer plusieurs composants"
+"component.createSet" = "Créer un ensemble de composants"
"component.deleteProperty" = "Supprimer la propriété de composant"
"component.detach" = "Détacher l’instance"
"component.editProperty" = "Modifier la propriété de composant"
diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml
index cbebaf1c4..3bb5d027c 100644
--- a/crates/ui-egui/locales/id.toml
+++ b/crates/ui-egui/locales/id.toml
@@ -1917,6 +1917,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Gabungkan sebagai variant"
"component.create" = "Buat component"
"component.createMultiple" = "Buat beberapa component"
+"component.createSet" = "Buat set component"
"component.deleteProperty" = "Hapus properti component"
"component.detach" = "Lepaskan instance"
"component.editProperty" = "Edit properti component"
diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml
index 364aeedf8..f6b6dfe0d 100644
--- a/crates/ui-egui/locales/it.toml
+++ b/crates/ui-egui/locales/it.toml
@@ -1861,6 +1861,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Combina come varianti"
"component.create" = "Crea componente"
"component.createMultiple" = "Crea più componenti"
+"component.createSet" = "Crea set di componenti"
"component.deleteProperty" = "Elimina proprietà del componente"
"component.detach" = "Scollega istanza"
"component.editProperty" = "Modifica proprietà del componente"
diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml
index 5a8918a20..c53b03e25 100644
--- a/crates/ui-egui/locales/ja.toml
+++ b/crates/ui-egui/locales/ja.toml
@@ -1938,6 +1938,7 @@ status = "draft — needs native review"
"component.combineVariants" = "バリアントとして結合"
"component.create" = "コンポーネントを作成"
"component.createMultiple" = "複数のコンポーネントを作成"
+"component.createSet" = "コンポーネントセットを作成"
"component.deleteProperty" = "コンポーネントプロパティを削除"
"component.detach" = "インスタンスを切り離す"
"component.editProperty" = "コンポーネントプロパティを編集"
diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml
index 2af58f385..fa681b9fd 100644
--- a/crates/ui-egui/locales/ka.toml
+++ b/crates/ui-egui/locales/ka.toml
@@ -2013,6 +2013,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"component.combineVariants" = "ვარიანტებად გაერთიანება"
"component.create" = "კომპონენტის შექმნა"
"component.createMultiple" = "რამდენიმე კომპონენტის შექმნა"
+"component.createSet" = "კომპონენტების ნაკრების შექმნა"
"component.deleteProperty" = "კომპონენტის თვისების წაშლა"
"component.detach" = "ინსტანსის გამოყოფა"
"component.editProperty" = "კომპონენტის თვისების რედაქტირება"
diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml
index ebea678d1..6b5a39b60 100644
--- a/crates/ui-egui/locales/ko.toml
+++ b/crates/ui-egui/locales/ko.toml
@@ -1978,6 +1978,7 @@ status = "draft — needs native review"
"component.combineVariants" = "배리언트로 결합"
"component.create" = "컴포넌트 만들기"
"component.createMultiple" = "여러 컴포넌트 만들기"
+"component.createSet" = "컴포넌트 세트 만들기"
"component.deleteProperty" = "컴포넌트 속성 삭제"
"component.detach" = "인스턴스 분리"
"component.editProperty" = "컴포넌트 속성 편집"
diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml
index e43d40b5e..a1d39c7fd 100644
--- a/crates/ui-egui/locales/nl.toml
+++ b/crates/ui-egui/locales/nl.toml
@@ -1956,6 +1956,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Combineren als varianten"
"component.create" = "Component maken"
"component.createMultiple" = "Meerdere componenten maken"
+"component.createSet" = "Componentenset maken"
"component.deleteProperty" = "Componenteigenschap verwijderen"
"component.detach" = "Instance loskoppelen"
"component.editProperty" = "Componenteigenschap bewerken"
diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml
index 68fe136d7..c479cd1be 100644
--- a/crates/ui-egui/locales/pl.toml
+++ b/crates/ui-egui/locales/pl.toml
@@ -1969,6 +1969,7 @@ name = "Polski"
"component.combineVariants" = "Połącz jako warianty"
"component.create" = "Utwórz komponent"
"component.createMultiple" = "Utwórz wiele komponentów"
+"component.createSet" = "Utwórz zestaw komponentów"
"component.deleteProperty" = "Usuń właściwość komponentu"
"component.detach" = "Odłącz instancję"
"component.editProperty" = "Edytuj właściwość komponentu"
diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml
index c2ccba835..efcde47fe 100644
--- a/crates/ui-egui/locales/pt-BR.toml
+++ b/crates/ui-egui/locales/pt-BR.toml
@@ -1975,6 +1975,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Combinar como variantes"
"component.create" = "Criar componente"
"component.createMultiple" = "Criar vários componentes"
+"component.createSet" = "Criar conjunto de componentes"
"component.deleteProperty" = "Excluir propriedade de componente"
"component.detach" = "Desvincular instância"
"component.editProperty" = "Editar propriedade de componente"
diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml
index 01e1d86ac..97980eaed 100644
--- a/crates/ui-egui/locales/ru.toml
+++ b/crates/ui-egui/locales/ru.toml
@@ -1969,6 +1969,7 @@ name = "Русский"
"component.combineVariants" = "Объединить в варианты"
"component.create" = "Создать компонент"
"component.createMultiple" = "Создать несколько компонентов"
+"component.createSet" = "Создать набор компонентов"
"component.deleteProperty" = "Удалить свойство компонента"
"component.detach" = "Отсоединить экземпляр"
"component.editProperty" = "Изменить свойство компонента"
diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml
index a94feb2a9..0a97701f8 100644
--- a/crates/ui-egui/locales/sv.toml
+++ b/crates/ui-egui/locales/sv.toml
@@ -2076,6 +2076,7 @@ name = "Svenska"
"component.combineVariants" = "Kombinera som varianter"
"component.create" = "Skapa komponent"
"component.createMultiple" = "Skapa flera komponenter"
+"component.createSet" = "Skapa komponentuppsättning"
"component.deleteProperty" = "Radera komponentegenskap"
"component.detach" = "Koppla bort instans"
"component.editProperty" = "Redigera komponentegenskap"
diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml
index 81b581ce0..d5e98915f 100644
--- a/crates/ui-egui/locales/th.toml
+++ b/crates/ui-egui/locales/th.toml
@@ -1934,6 +1934,7 @@ status = "draft — needs native review"
"component.combineVariants" = "รวมเป็นแวเรียนต์"
"component.create" = "สร้างคอมโพเนนต์"
"component.createMultiple" = "สร้างหลายคอมโพเนนต์"
+"component.createSet" = "สร้างชุดคอมโพเนนต์"
"component.deleteProperty" = "ลบพร็อพเพอร์ตีของคอมโพเนนต์"
"component.detach" = "ถอดอินสแตนซ์"
"component.editProperty" = "แก้ไขพร็อพเพอร์ตีของคอมโพเนนต์"
diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml
index b07d25462..25bf777ce 100644
--- a/crates/ui-egui/locales/tr.toml
+++ b/crates/ui-egui/locales/tr.toml
@@ -1917,6 +1917,7 @@ name = "Türkçe"
"component.combineVariants" = "Varyant olarak birleştir"
"component.create" = "Bileşen oluştur"
"component.createMultiple" = "Birden çok bileşen oluştur"
+"component.createSet" = "Bileşen kümesi oluştur"
"component.deleteProperty" = "Bileşen özelliğini sil"
"component.detach" = "Örneği ayır"
"component.editProperty" = "Bileşen özelliğini düzenle"
diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml
index 9a4d600fe..98420ba48 100644
--- a/crates/ui-egui/locales/vi.toml
+++ b/crates/ui-egui/locales/vi.toml
@@ -1862,6 +1862,7 @@ status = "draft — needs native review"
"component.combineVariants" = "Kết hợp thành các biến thể"
"component.create" = "Tạo thành phần"
"component.createMultiple" = "Tạo nhiều thành phần"
+"component.createSet" = "Tạo bộ thành phần"
"component.deleteProperty" = "Xóa thuộc tính thành phần"
"component.detach" = "Tách bản thể"
"component.editProperty" = "Chỉnh sửa thuộc tính thành phần"
diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml
index b48345acc..7d6bfb0b7 100644
--- a/crates/ui-egui/locales/zh-Hans.toml
+++ b/crates/ui-egui/locales/zh-Hans.toml
@@ -1871,6 +1871,7 @@ status = "draft — needs native review"
"component.combineVariants" = "合并为变体"
"component.create" = "创建组件"
"component.createMultiple" = "创建多个组件"
+"component.createSet" = "创建组件集"
"component.deleteProperty" = "删除组件属性"
"component.detach" = "分离实例"
"component.editProperty" = "编辑组件属性"
diff --git a/crates/ui-egui/locales/zh-Hant.toml b/crates/ui-egui/locales/zh-Hant.toml
index f14698b00..59b9064de 100644
--- a/crates/ui-egui/locales/zh-Hant.toml
+++ b/crates/ui-egui/locales/zh-Hant.toml
@@ -2089,6 +2089,7 @@ status = "draft — needs native review"
"component.combineVariants" = "合併為變體"
"component.create" = "建立元件"
"component.createMultiple" = "建立多個元件"
+"component.createSet" = "建立元件集"
"component.deleteProperty" = "刪除元件屬性"
"component.detach" = "分離實例"
"component.editProperty" = "編輯元件屬性"
From 9303b73991ef793dd59ea743b6c109eb2598afda Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 22:58:07 -0500
Subject: [PATCH 016/103] .fig import: the round-trip test asserts the scaled
size; text on a path takes the instance's size (#2100 review)
The scaled-instance round-trip test now asserts the scaled width and height instead of printing them. text_path read the layer's raw size, so a text on a path inside a scaled instance kept the main's path length; it now uses the scaled or fitted size.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
---
crates/format/src/fig/assets_tests.rs | 5 ++-
crates/format/src/fig/map/text.rs | 6 +--
crates/format/src/fig/text_path_tests.rs | 57 +++++++++++++++++++++++-
3 files changed, 60 insertions(+), 8 deletions(-)
diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs
index a69f5ccfa..e4b28e3cb 100644
--- a/crates/format/src/fig/assets_tests.rs
+++ b/crates/format/src/fig/assets_tests.rs
@@ -1048,6 +1048,9 @@ fn a_scaled_instance_keeps_its_radius_through_a_round_trip() {
let (back, _) = import(&bytes, "x").unwrap();
let inst = back.nodes().find(|n| n.name == "Scaled").unwrap();
let b = inst.children.iter().map(|c| back.node(*c).unwrap()).find(|n| n.name == "Box").unwrap();
- eprintln!("scaled box back: {}x{} radii {:?}", b.width, b.height, b.corners.radii);
+ // The 40 px box at the instance's 1.5 scale. The exporter writes every scaled value of a layer
+ // in a derived row, so there is no main-layer value left unwritten here to round-trip; the
+ // scaling of values with no row stays covered by `scale_factor_tests`.
+ assert_eq!((b.width, b.height), (60.0, 60.0));
assert_eq!(b.corners.radii, [15.0; 4]);
}
diff --git a/crates/format/src/fig/map/text.rs b/crates/format/src/fig/map/text.rs
index 86d9d24bb..d41b85185 100644
--- a/crates/format/src/fig/map/text.rs
+++ b/crates/format/src/fig/map/text.rs
@@ -186,11 +186,7 @@ impl<'a> Mapper<'a> {
fail(self, "has invalid normalized size");
return;
};
- let Some(size) = v.get("size") else {
- fail(self, "has invalid dimensions");
- return;
- };
- let (Some(nx), Some(ny), Some(w), Some(h)) = (num(norm, "x"), num(norm, "y"), num(size, "x"), num(size, "y")) else {
+ let (Some(nx), Some(ny), Some((w, h))) = (num(norm, "x"), num(norm, "y"), v.size()) else {
fail(self, "has invalid dimensions");
return;
};
diff --git a/crates/format/src/fig/text_path_tests.rs b/crates/format/src/fig/text_path_tests.rs
index c94c6aa3d..8f7b46f43 100644
--- a/crates/format/src/fig/text_path_tests.rs
+++ b/crates/format/src/fig/text_path_tests.rs
@@ -3,7 +3,7 @@
use super::kiwi::Kv;
const SCHEMA: &str = "
-enum NodeType DOCUMENT CANVAS TEXT TEXT_PATH
+enum NodeType DOCUMENT CANVAS TEXT TEXT_PATH SYMBOL INSTANCE
enum StyleType NONE TEXT
enum TextDecoration NONE UNDERLINE STRIKETHROUGH
struct GUID uint sessionID, uint localID
@@ -17,7 +17,8 @@ message TextData string characters, uint[] characterStyleIDs, TextOverride[] sty
message TextPathStart float tValue, bool forward
message VectorData uint vectorNetworkBlob, Vector normalizedSize
message Blob byte[] bytes
-message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, TextData textData, float fontSize, FontName fontName, TextPathStart textPathStart, VectorData vectorData, StyleType styleType, StyleId styleIdForText, TextDecoration textDecoration
+message SymbolData GUID symbolID, float uniformScaleFactor
+message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, TextData textData, float fontSize, FontName fontName, TextPathStart textPathStart, VectorData vectorData, StyleType styleType, StyleId styleIdForText, TextDecoration textDecoration, SymbolData symbolData
message Message NodeChange[] nodeChanges, Blob[] blobs
";
use figcraft_doc::{NodeKind, TextPath};
@@ -209,3 +210,55 @@ fn linked_text_style_decoration_warns_from_effective_mapped_style() {
assert_eq!(json["document"]["children"][0]["children"][0]["style"]["textDecoration"], "UNDERLINE");
assert!(warnings.iter().any(|w| w == "text path underline or strikethrough decoration is not rendered"), "{warnings:?}");
}
+
+/// A component holding a text on a path (path 100 long) and a half-scale instance of it.
+fn scaled_instance_message() -> Kv<'static> {
+ let identity = || o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(0.0)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(0.0))]);
+ let nodes = vec![
+ node(0, None, "DOCUMENT", vec![]),
+ node(1, Some(0), "CANVAS", vec![]),
+ node(5, Some(1), "SYMBOL", vec![("name", s("Main")), ("size", point(100.0, 100.0)), ("transform", identity())]),
+ node(
+ 6,
+ Some(5),
+ "TEXT_PATH",
+ vec![
+ ("name", s("arc")),
+ ("size", point(100.0, 0.0)),
+ ("transform", identity()),
+ ("textData", o(vec![("characters", s("alpha"))])),
+ ("fontSize", f(24.0)),
+ ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])),
+ ("textPathStart", o(vec![("tValue", f(0.5)), ("forward", Kv::Bool(true))])),
+ ("vectorData", o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", point(100.0, 0.0))])),
+ ],
+ ),
+ node(
+ 9,
+ Some(1),
+ "INSTANCE",
+ vec![
+ ("name", s("Half")),
+ ("size", point(50.0, 50.0)),
+ ("transform", identity()),
+ ("symbolData", o(vec![("symbolID", guid(5)), ("uniformScaleFactor", f(0.5))])),
+ ],
+ ),
+ ];
+ o(vec![("nodeChanges", a(nodes)), ("blobs", a(vec![o(vec![("bytes", Kv::Bytes(blob()))])]))])
+}
+
+#[test]
+fn a_text_on_a_path_in_a_scaled_instance_has_its_path_scaled() {
+ let (json, warnings, _) = super::map::to_rest(&scaled_instance_message(), "Scaled path", crate::rest::MAX_NODES);
+ assert!(!warnings.iter().any(|w| w.contains("text path")), "{warnings:?}");
+ let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap();
+ let path_of = |parent: &str| {
+ let parent = doc.nodes().find(|n| n.name == parent).unwrap();
+ let node = doc.node(parent.children[0]).unwrap();
+ let NodeKind::Text(text) = &node.kind else { panic!("expected text") };
+ text.path.as_deref().unwrap().d.clone()
+ };
+ assert_eq!(path_of("Main"), "M0,0 L100,0");
+ assert_eq!(path_of("Half"), "M0,0 L50,0");
+}
From 0fec344f3cc1cc439808aed4781390f9ca76123c Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sat, 7 Nov 2026 23:38:58 -0500
Subject: [PATCH 017/103] M8: copy, cut and paste in vector edit mode act on
the selected points and segments (#1875)
Measured in Figma: in vector edit mode Copy takes only the selected segments (a lone point, the segments touching it) and a paste outside makes a new vector in place; Cut removes them, splitting the path in the same layer and staying in edit mode; Paste while editing adds the clipboard's segments or a copied layer's outline to the vector's network in place, selecting them. FigCraft copied the whole layer, Cut left nothing to paste, and Paste while editing did nothing. 16 new engine and UI tests cover copy, cut, paste, undo and junk parameters; the whole engine suite passes bar the known font test.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at high
---
crates/engine/src/canvas/vectoredit.rs | 2 +-
crates/engine/src/cmd/edit.rs | 21 +-
crates/engine/src/cmd/edit/clipboard.rs | 62 +++-
.../engine/src/cmd/edit/vector_clipboard.rs | 274 +++++++++++++++
.../src/cmd/edit/vector_clipboard_tests.rs | 324 ++++++++++++++++++
crates/ui-egui/src/menus.rs | 10 +-
crates/ui-egui/src/shortcuts.rs | 13 +-
crates/ui-egui/src/vector_keys_tests.rs | 12 +
docs/ui-parity.md | 5 +-
9 files changed, 701 insertions(+), 22 deletions(-)
create mode 100644 crates/engine/src/cmd/edit/vector_clipboard.rs
create mode 100644 crates/engine/src/cmd/edit/vector_clipboard_tests.rs
diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs
index 4f265e0f5..749e246c0 100644
--- a/crates/engine/src/canvas/vectoredit.rs
+++ b/crates/engine/src/canvas/vectoredit.rs
@@ -638,7 +638,7 @@ fn index(cmd: &str, p: &Value, key: &str) -> Result {
}
/// Delete the points and segments of `parts` (`heal` keeps paths through deleted points closed).
-fn delete_selection(s: &mut Session, parts: Vec, heal: bool) -> Result<()> {
+pub(crate) fn delete_selection(s: &mut Session, parts: Vec, heal: bool) -> Result<()> {
let doc = s.doc_mut()?;
for EditPart { id, vertices: verts, segments: segs } in parts {
edit(doc, id, |net| {
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index efcb0fc52..19a7d7011 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -17,6 +17,9 @@ mod clipboard;
mod props;
pub(crate) use props::{edit_region_fills, fill_regions};
mod select;
+mod vector_clipboard;
+#[cfg(test)]
+mod vector_clipboard_tests;
pub(crate) use clipboard::apply_copied_props;
pub use clipboard::{CLIP_CLOSE, CLIP_OPEN, copy_to_clipboard, design_clipboard_buffer, paste_into};
@@ -117,12 +120,24 @@ pub fn specs() -> Vec {
true,
duplicate
),
- spec!("edit.copy", "Copy", "{ids?}", false, copy),
- spec!("edit.cut", "Cut", "{ids?}", true, cut),
+ spec!(
+ "edit.copy",
+ "Copy",
+ "{ids?} in vector edit mode with points or segments selected (and no ids): just those, the segments touching a selected point, as a new vector with the edited layer's look",
+ false,
+ copy
+ ),
+ spec!(
+ "edit.cut",
+ "Cut",
+ "{ids?} in vector edit mode with points or segments selected (and no ids): copies them as Copy does, then removes them as Delete does; the path splits in the same layer and edit mode stays on",
+ true,
+ cut
+ ),
spec!(
"edit.paste",
"Paste",
- "{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
+ "{parent?, x?, y? (page coordinates of the pasted top-left)} in vector edit mode (and no parent): the copied vectors' segments and shapes' outlines join the edited vector's network in place, selected, and the layers that have no outline (text, frames, groups) are skipped (`skipped`); a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
true,
paste
),
diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs
index 150a3b577..da5089419 100644
--- a/crates/engine/src/cmd/edit/clipboard.rs
+++ b/crates/engine/src/cmd/edit/clipboard.rs
@@ -1,6 +1,6 @@
use super::{
- Affine, BTreeMap, BTreeSet, Document, EngineError, ImageData, Node, NodeId, NodeKind, Result, Session, Value, bad, delete, id, json, num, ok,
- req_targets, roots, string, targets, z_sorted,
+ Affine, BTreeMap, BTreeSet, Document, EngineError, ImageData, Node, NodeId, NodeKind, Result, Session, Value, bad, delete, get, id, json, num,
+ ok, req_targets, roots, string, targets, vector_clipboard, z_sorted,
};
/// The copied form of the selection.
@@ -12,6 +12,18 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into()));
}
let (nodes, images, bounds) = copied(&ds.doc, &layers);
+ store_clipboard(s, cmd, nodes, images, bounds)
+}
+
+/// Make `nodes` (the copied layers, each root's transform in page space) the clipboard's layers.
+pub(super) fn store_clipboard(
+ s: &mut Session,
+ cmd: &str,
+ nodes: Vec<(NodeId, Vec)>,
+ images: BTreeMap,
+ bounds: Option,
+) -> Result<()> {
+ let ds = s.ds()?;
let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid);
s.clipboard.source = Some(source);
s.clipboard.source_path = source_path;
@@ -43,21 +55,26 @@ fn copied(doc: &Document, ids: &[NodeId]) -> Copied {
if let Some(r) = sub.first_mut() {
r.transform = world;
}
- for n in &sub {
- for p in n.fills.iter().chain(n.strokes.iter()) {
- if let figcraft_color::PaintKind::Image(img) = &p.kind
- && let Some(d) = doc.images.get(&img.image)
- {
- images.insert(img.image.clone(), d.clone());
- }
- }
- }
+ collect_images(doc, &sub, &mut images);
nodes.push((*i, sub));
}
let bounds = doc.bounds_of(&ids);
(nodes, images, bounds)
}
+/// The images the paints of `nodes` use, taken from `doc` into `images`.
+pub(super) fn collect_images(doc: &Document, nodes: &[Node], images: &mut BTreeMap) {
+ for n in nodes {
+ for p in n.fills.iter().chain(n.strokes.iter()) {
+ if let figcraft_color::PaintKind::Image(img) = &p.kind
+ && let Some(d) = doc.images.get(&img.image)
+ {
+ images.insert(img.image.clone(), d.clone());
+ }
+ }
+ }
+}
+
/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them.
pub const CLIP_OPEN: &str = "(figma)";
@@ -167,13 +184,32 @@ fn layers_missing_images(doc: &figcraft_doc::Document, ids: &[NodeId]) -> Vec bool {
+ get(p, "ids").is_some() || get(p, "id").is_some()
+}
+
pub(super) fn copy(s: &mut Session, p: &Value) -> Result {
+ // In vector edit mode with points or segments selected, Copy takes just those (#1875).
+ if !names_layers(p)
+ && let Some(sel) = vector_clipboard::edit_selection(s)
+ && !sel.parts.is_empty()
+ {
+ return vector_clipboard::copy_selection(s, "edit.copy", sel);
+ }
let ids = req_targets("edit.copy", s, p)?;
copy_to_clipboard(s, "edit.copy", &ids)?;
Ok(json!({"copied": s.clipboard.nodes.len()}))
}
pub(super) fn cut(s: &mut Session, p: &Value) -> Result {
+ // In vector edit mode with points or segments selected, Cut takes just those and stays in edit mode (#1875).
+ if !names_layers(p)
+ && let Some(sel) = vector_clipboard::edit_selection(s)
+ && sel.any
+ {
+ return vector_clipboard::cut_selection(s, sel);
+ }
let ids = req_targets("edit.cut", s, p)?;
copy_to_clipboard(s, "edit.cut", &ids)?;
let doc = s.doc()?;
@@ -238,6 +274,10 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
}
pub(super) fn paste(s: &mut Session, p: &Value) -> Result {
+ // While editing a vector, the copied layers join its network (#1875); `parent` asks for layers.
+ if id(p, "parent").is_none() && s.ds()?.vector_edit.is_some() {
+ return vector_clipboard::paste_into_vector(s, p);
+ }
let page = s.page()?;
// Figma pastes into the selected frame (if one is selected), at the same position when it fits,
// else in the frame's centre; otherwise in place on the page (or the viewport centre).
diff --git a/crates/engine/src/cmd/edit/vector_clipboard.rs b/crates/engine/src/cmd/edit/vector_clipboard.rs
new file mode 100644
index 000000000..d9efea269
--- /dev/null
+++ b/crates/engine/src/cmd/edit/vector_clipboard.rs
@@ -0,0 +1,274 @@
+//! Copy, cut and paste in vector edit mode (#1875).
+//!
+//! With points or segments selected, Copy and Cut take just those: a temporary vector holding the
+//! selected segments (and the segments touching a selected point) goes on the ordinary layer
+//! clipboard, kept at its place on the page, so a paste outside edit mode is a plain layer paste
+//! in place. Cut then removes the selection the way Delete does and stays in edit mode. Paste
+//! while editing adds the clipboard's layers to the edited vector's network, in place: a vector
+//! with its segments, a shape or boolean with its outline.
+
+use figcraft_doc::{NodeKind, Region, VectorNetwork};
+
+use super::clipboard::{collect_images, store_clipboard};
+use super::{Affine, BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, Result, Session, Value, json, num};
+use crate::canvas::vectoredit::{self, EditPart};
+
+/// The most segments a vector network takes through a paste.
+const MAX_SEGMENTS: usize = 500_000;
+
+/// What the edited layers have selected.
+pub(super) struct EditSelection {
+ /// Whether any edited layer has a point or a segment selected.
+ pub any: bool,
+ /// Each edited layer with something to copy, and the sub-network that is.
+ pub parts: Vec<(NodeId, VectorNetwork)>,
+}
+
+/// The selection of the vector being edited (and the other layers edited with it), or `None` when
+/// not in vector edit mode.
+pub(super) fn edit_selection(s: &Session) -> Option {
+ let ds = s.ds().ok()?;
+ let ve = ds.vector_edit.as_ref()?;
+ let mut sel = EditSelection { any: false, parts: Vec::new() };
+ for part in ve.parts() {
+ if part.vertices.is_empty() && part.segments.is_empty() {
+ continue;
+ }
+ sel.any = true;
+ let Some(net) = vectoredit::network(&ds.doc, part.id) else { continue };
+ let active = part.id == ve.id;
+ // Points selected only through a selected region don't pull in the segments touching
+ // them: the region's own segments are the selection.
+ let vertices: Vec = part.vertices.iter().copied().filter(|v| !active || !ve.region_vertices.contains(v)).collect();
+ let mut segments = part.segments.clone();
+ if active {
+ segments.extend(ve.regions.iter().filter_map(|r| net.regions.get(*r)).flat_map(|r| r.loops.iter().flatten().copied()));
+ }
+ if let Some(sub) = sub_network(net, &vertices, &segments) {
+ sel.parts.push((part.id, sub));
+ }
+ }
+ Some(sel)
+}
+
+/// The part of `net` that is the segments `segments` and those touching a point of `vertices`,
+/// with the points they join; `None` when there are none (a lone point with no segment draws
+/// nothing). The regions whose every segment is kept stay regions.
+fn sub_network(net: &VectorNetwork, vertices: &[usize], segments: &[usize]) -> Option {
+ let picked: BTreeSet = vertices.iter().copied().collect();
+ let mut keep: BTreeSet = segments.iter().copied().filter(|i| *i < net.segments.len()).collect();
+ for (i, sg) in net.segments.iter().enumerate() {
+ if picked.contains(&sg.a) || picked.contains(&sg.b) {
+ keep.insert(i);
+ }
+ }
+ let used: BTreeSet = keep.iter().filter_map(|i| net.segments.get(*i)).flat_map(|sg| [sg.a, sg.b]).collect();
+ let vmap: BTreeMap = used.iter().enumerate().map(|(new, old)| (*old, new)).collect();
+ let smap: BTreeMap = keep.iter().enumerate().map(|(new, old)| (*old, new)).collect();
+ let mut out = VectorNetwork {
+ vertices: used.iter().filter_map(|v| net.vertices.get(*v).cloned()).collect(),
+ segments: keep
+ .iter()
+ .filter_map(|i| net.segments.get(*i))
+ .filter_map(|sg| {
+ let mut sg = *sg;
+ sg.a = *vmap.get(&sg.a)?;
+ sg.b = *vmap.get(&sg.b)?;
+ Some(sg)
+ })
+ .collect(),
+ regions: Vec::new(),
+ };
+ if out.segments.is_empty() || out.segments.len() != keep.len() || out.vertices.len() != used.len() {
+ return None;
+ }
+ for r in &net.regions {
+ let loops: Option>> = r.loops.iter().map(|l| l.iter().map(|i| smap.get(i).copied()).collect::
")), "slot Label HTML: {html}");
+}
+
+fn assert_local_slot_text(s: &mut Session, instance: NodeId, expected: &str) {
+ assert!(matches!(&node(s, instance).kind, NodeKind::Instance { props, .. } if props.get("Text") == Some(&json!(expected))));
+ let properties = props_of(s, instance);
+ assert_eq!(properties.iter().find(|p| p["name"] == "Text").unwrap()["value"], expected);
+}
+
+#[test]
+fn slot_text_properties_stay_local_in_default_and_own_content() {
+ for own in [false, true] {
+ let mut s = Session::new();
+ let (content, label, _base, composition, main_content) = slot_text_composition(&mut s, own);
+ let a = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
+ let b = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 800})));
+ let ac = copy_of(&s, a, main_content);
+ let bc = copy_of(&s, b, main_content);
+ ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Text": "Custom"}}));
+ assert_local_slot_text(&mut s, ac, "Custom");
+ assert_slot_text(&mut s, ac, "Custom");
+ assert_slot_text(&mut s, bc, "Default");
+ assert_slot_text(&mut s, main_content, "Default");
+ assert_eq!(node(&s, label).kind.text().unwrap().characters, "Default");
+ // Main property edits propagate without replacing another instance's local value.
+ ex(&mut s, "instance.setProperties", json!({"ids": [main_content], "properties": {"Text": "Main value"}}));
+ assert_slot_text(&mut s, ac, "Custom");
+ assert_slot_text(&mut s, bc, "Main value");
+ assert_slot_text(&mut s, main_content, "Main value");
+ ex(&mut s, "component.editProperty", json!({"id": content, "name": "Text", "default": "New default"}));
+ assert_slot_text(&mut s, ac, "Custom");
+ assert_slot_text(&mut s, bc, "Main value");
+ healthy(&s);
+ }
+}
+
+#[test]
+fn slot_text_properties_do_not_read_an_unbound_base_property() {
+ let mut s = Session::new();
+ let (_content, _label, base, composition, main_content) = slot_text_composition(&mut s, true);
+ ex(&mut s, "component.addProperty", json!({"id": base, "name": "Text", "type": "text", "default": "Wrong owner"}));
+ let i = idv(&ex(&mut s, "instance.create", json!({"component": composition, "x": 400})));
+ let nested = copy_of(&s, i, main_content);
+ assert_slot_text(&mut s, nested, "Default");
+ ex(&mut s, "instance.setProperties", json!({"ids": [nested], "properties": {"Text": "Custom"}}));
+ assert_local_slot_text(&mut s, nested, "Custom");
+ assert_slot_text(&mut s, nested, "Custom");
+ assert_slot_text(&mut s, main_content, "Default");
+ healthy(&s);
+}
+
+#[test]
+fn slot_text_properties_survive_inherited_own_content_and_persistence() {
+ let mut s = Session::new();
+ let (_content, _label, _base, composition, main_content) = slot_text_composition(&mut s, true);
+ let (outer, inherited) = slot_text_wrapper(&mut s, composition, "Outer composition");
+ let inherited_content = copy_of(&s, inherited, main_content);
+ let a = idv(&ex(&mut s, "instance.create", json!({"component": outer, "x": 400})));
+ let b = idv(&ex(&mut s, "instance.create", json!({"component": outer, "x": 800})));
+ let ac = copy_of(&s, a, inherited_content);
+ let bc = copy_of(&s, b, inherited_content);
+ ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Text": "Custom"}}));
+ assert_local_slot_text(&mut s, ac, "Custom");
+ let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
+ let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
+ let mut s = Session::with_document(back);
+ assert_local_slot_text(&mut s, ac, "Custom");
+ assert_slot_text(&mut s, ac, "Custom");
+ assert_slot_text(&mut s, bc, "Default");
+ assert_slot_text(&mut s, inherited_content, "Default");
+ assert_slot_text(&mut s, main_content, "Default");
+ // Explicit characters override the bound property, even after a main update.
+ let alabel = kids(&s, ac)[0];
+ ex(&mut s, "text.set", json!({"id": alabel, "characters": "Explicit"}));
+ ex(&mut s, "instance.setProperties", json!({"ids": [ac], "properties": {"Text": "Later"}}));
+ ex(&mut s, "instance.setProperties", json!({"ids": [main_content], "properties": {"Text": "Main value"}}));
+ assert_local_slot_text(&mut s, ac, "Later");
+ assert_slot_text(&mut s, ac, "Explicit");
+ assert_slot_text(&mut s, bc, "Main value");
+ ex(&mut s, "component.resetOverride", json!({"id": alabel, "group": "characters"}));
+ assert_slot_text(&mut s, ac, "Later");
+ s.undo().unwrap();
+ assert_slot_text(&mut s, ac, "Explicit");
+ healthy(&s);
+}
+
/// Random slot edits, main edits and resets keep every instance mirroring its main.
#[test]
fn random_slot_edits_keep_instances_consistent() {
From 0ff3a3682ea8b2e2df68fab3156c2c99d4b8c442 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Sun, 8 Nov 2026 19:22:04 +0000
Subject: [PATCH 034/103] Reject negative and fractional index in
file.openRecent
Found while testing crates/engine/src/cmd/file/open.rs:33 (open_recent, `.nth(i.max(0.0) as usize)`)
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/engine/src/cmd/file/open.rs | 5 ++++-
crates/engine/src/menu_gaps_tests.rs | 17 +++++++++++++++++
2 files changed, 21 insertions(+), 1 deletion(-)
diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs
index e4c3e0fb7..b6e154c35 100644
--- a/crates/engine/src/cmd/file/open.rs
+++ b/crates/engine/src/cmd/file/open.rs
@@ -31,7 +31,10 @@ pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result {
Some(path)
}
(None, Some(i)) => {
- Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?)
+ if !(i >= 0.0 && i.fract() == 0.0) {
+ return Err(bad(CMD, "no such recent file"));
+ }
+ Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?)
}
_ => None,
};
diff --git a/crates/engine/src/menu_gaps_tests.rs b/crates/engine/src/menu_gaps_tests.rs
index 057c5119a..fa32f5d8f 100644
--- a/crates/engine/src/menu_gaps_tests.rs
+++ b/crates/engine/src/menu_gaps_tests.rs
@@ -84,3 +84,20 @@ fn outlined_layers_draw_as_outlines_and_the_rest_normally() {
assert_eq!(outlined.pixel(20, 20).unwrap(), empty, "only its outline");
assert_eq!(outlined.pixel(80, 20).unwrap(), plain.pixel(80, 20).unwrap(), "the other layer draws normally");
}
+
+#[test]
+fn open_recent_rejects_negative_and_fractional_index() {
+ let dir = std::env::temp_dir().join(format!("figcraft-recent-bad-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let a = dir.join("a.figcraft").to_string_lossy().to_string();
+ let b = dir.join("b.figcraft").to_string_lossy().to_string();
+ let mut s = Session::new();
+ s.execute("file.save", &json!({"path": a})).unwrap();
+ s.execute("file.saveAs", &json!({"path": b})).unwrap();
+ let recent = s.prefs.recent_files.clone();
+ for i in [json!(-1), json!(1.5), json!(-0.5)] {
+ assert!(s.execute("file.openRecent", &json!({"index": i})).is_err(), "index {i} must be an error");
+ }
+ assert_eq!(s.prefs.recent_files, recent);
+ let _ = std::fs::remove_dir_all(&dir);
+}
From 892cda8e34f55e05d141dcb8c44c08735dacb938 Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Sun, 8 Nov 2026 13:32:01 -0800
Subject: [PATCH 035/103] Updates: the updater reports installing, ready,
installed and failed, and installs at exit
craft-update moves to a craft-libs branch that merges storytold/craft-libs#687 and #696.
- The check result window shows for every status but idle and stays
closed while the startup prompt is open, so the first start after an
update reports that it installed or why it failed. Restart to Update
calls Updater::restart_to_update.
- on_exit calls Updater::start_pending_update before the process ends,
which starts the install of an update waiting for the app to close.
---
Cargo.lock | 3 +-
Cargo.toml | 2 +-
apps/figcraft/src/main.rs | 7 ++-
crates/ui-egui/src/dialogs/updates_ui.rs | 55 ++++++++++++++++++++----
4 files changed, 55 insertions(+), 12 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 5fdb9e27b..672a5f8e1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1134,7 +1134,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
+source = "git+https://github.com/dotCooCoo/craft-libs?rev=c7c84230631cbc01ee928f0a0e0d838b1ed612ae#c7c84230631cbc01ee928f0a0e0d838b1ed612ae"
dependencies = [
"chrono",
"dunce",
@@ -7020,7 +7020,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
- "zopfli",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index c9b915a69..cf6ca6fea 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -43,7 +43,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
-craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
+craft-update = { git = "https://github.com/dotCooCoo/craft-libs", version = "=0.1.0", rev = "c7c84230631cbc01ee928f0a0e0d838b1ed612ae", features = ["egui"] }
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs
index 148165f6b..66b5f73ee 100644
--- a/apps/figcraft/src/main.rs
+++ b/apps/figcraft/src/main.rs
@@ -126,7 +126,12 @@ impl eframe::App for App {
fn on_exit(&mut self) {
// With the app out of reach (busy), its recovery copies stay for the next start.
let copies = recovery::lock(&self.recovery).take();
- with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
+ with_app(|fig| {
+ services::shutdown(fig, services::config_dir().as_deref(), copies);
+ // An update that waits for the app to close installs now: the app lives in a
+ // thread-local, and the process can end without dropping it.
+ fig.start_pending_update();
+ });
// Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
// throws on the content view AccessKit restores (craft-libs#3, #781).
#[cfg(target_os = "macos")]
diff --git a/crates/ui-egui/src/dialogs/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs
index f4481d064..0cd8dab79 100644
--- a/crates/ui-egui/src/dialogs/updates_ui.rs
+++ b/crates/ui-egui/src/dialogs/updates_ui.rs
@@ -5,7 +5,7 @@ use crate::FigApp;
#[cfg(target_arch = "wasm32")]
use crate::i18n::tr;
#[cfg(not(target_arch = "wasm32"))]
-use craft_update::updater::{Action, Updater};
+use craft_update::updater::{Action, Status, Updater};
#[cfg(not(target_arch = "wasm32"))]
use craft_update::{CheckResultDialog, CheckResultResponse};
@@ -21,6 +21,7 @@ pub(crate) struct Runtime {
#[cfg(not(target_arch = "wasm32"))]
repaint: Option,
/// The result dialog of a manual check is open.
+ #[cfg(target_arch = "wasm32")]
pub(crate) manual_window_open: bool,
/// The web build has no updater; its only result is that checks are unsupported.
#[cfg(target_arch = "wasm32")]
@@ -38,6 +39,7 @@ impl Default for Runtime {
check_requested: false,
#[cfg(not(target_arch = "wasm32"))]
repaint: None,
+ #[cfg(target_arch = "wasm32")]
manual_window_open: false,
#[cfg(target_arch = "wasm32")]
status_message: None,
@@ -51,13 +53,19 @@ impl FigApp {
pub fn set_startup_update_check(&mut self, on: bool) {
self.updates_rt.startup_check = on;
}
+
+ /// Start the install of an update that waits for the app to close. The desktop app calls this
+ /// when it exits, before it ends the process; later calls do nothing.
+ #[cfg(not(target_arch = "wasm32"))]
+ pub fn start_pending_update(&mut self) {
+ self.updates_rt.updater.start_pending_update();
+ }
}
pub(crate) fn check(app: &mut FigApp) {
#[cfg(not(target_arch = "wasm32"))]
{
app.updates_rt.check_requested = true;
- app.updates_rt.manual_window_open = true;
if let Some(ctx) = &app.updates_rt.repaint {
ctx.request_repaint();
}
@@ -85,10 +93,18 @@ pub(crate) fn tick(app: &mut FigApp, ctx: &egui::Context) {
let _ = (app, ctx);
}
+/// Whether the check result window shows: for every status but idle (a requested check, and the
+/// first start after an update, which reports that it installed or why it failed), and never over
+/// the startup prompt.
+#[cfg(not(target_arch = "wasm32"))]
+fn result_window_shows(status: &Status, prompt_open: bool) -> bool {
+ !prompt_open && !matches!(status, Status::Idle)
+}
+
pub(crate) fn show(app: &mut FigApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
- // The restart helper is scheduled before the app closes, so no update starts over unsaved work.
+ // The updater asks to close only when no document has unsaved changes; the install starts when the app exits.
let clean = !app.session.docs.iter().any(|d| d.is_dirty());
match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
@@ -97,14 +113,13 @@ pub(crate) fn show(app: &mut FigApp, ctx: &egui::Context) {
Action::None => {}
}
let rt = &mut app.updates_rt;
- if rt.manual_window_open {
+ if result_window_shows(rt.updater.status(), rt.updater.prompt_open()) {
match CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).show(ctx, rt.updater.status()) {
CheckResultResponse::None => {}
- CheckResultResponse::Close => {
- rt.manual_window_open = false;
- rt.updater.dismiss_status();
- }
+ CheckResultResponse::Close => rt.updater.dismiss_status(),
CheckResultResponse::CheckAgain => rt.check_requested = true,
+ // The next `show_if` asks the window to close once no document has unsaved changes.
+ CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(),
}
}
}
@@ -135,3 +150,27 @@ pub(crate) fn show(app: &mut FigApp, ctx: &egui::Context) {
}
}
}
+
+#[cfg(all(test, not(target_arch = "wasm32")))]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn result_window_shows_every_status_but_idle() {
+ let shown = [
+ Status::Checking,
+ Status::UpToDate,
+ Status::Failed("no network".into()),
+ Status::Installing,
+ Status::Ready,
+ Status::Managed,
+ Status::Updated("0.2.0".into()),
+ Status::InstallFailed("disk full".into()),
+ ];
+ for status in &shown {
+ assert!(result_window_shows(status, false), "{status:?}");
+ assert!(!result_window_shows(status, true), "{status:?} over the prompt");
+ }
+ assert!(!result_window_shows(&Status::Idle, false));
+ }
+}
From 92cf9849027aff7dc4a2aee82ecdc1bd1d7a0cd1 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Sun, 8 Nov 2026 17:38:13 -0500
Subject: [PATCH 036/103] Selection: Escape tests for a top-level layer,
several layers and the focused frame (#2107 review)
One Escape selects nothing for a top-level frame, a top-level layer, two layers in one group,
layers in different parents, and a nested layer with a top-level one. With the focused frame
itself selected, one Escape leaves the focus view.
Co-Authored-By: Claude Opus 5.5
Model-Effort: Opus 5.5 drove and wrote
---
crates/engine/src/devfocus_tests.rs | 6 ++++++
crates/engine/src/selection_behaviour_tests.rs | 14 ++++++++++++++
2 files changed, 20 insertions(+)
diff --git a/crates/engine/src/devfocus_tests.rs b/crates/engine/src/devfocus_tests.rs
index f36af7ee6..b7a4f9341 100644
--- a/crates/engine/src/devfocus_tests.rs
+++ b/crates/engine/src/devfocus_tests.rs
@@ -92,6 +92,12 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() {
assert_eq!(focused(&s), None, "Escape with nothing selected leaves");
click(&mut s, 350.0, 100.0);
assert_eq!(s.selection(), vec![b], "everything is hit again");
+ // With the focused frame itself selected, one Escape leaves the focus view.
+ ex(&mut s, "dev.focus", json!({"id": a}));
+ ex(&mut s, "select.set", json!({"ids": [a]}));
+ assert_eq!(focused(&s), Some(a));
+ s.escape().unwrap();
+ assert_eq!(focused(&s), None, "Escape with the focused frame selected leaves at once");
}
#[test]
diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs
index b3289c6c3..6498b8cb5 100644
--- a/crates/engine/src/selection_behaviour_tests.rs
+++ b/crates/engine/src/selection_behaviour_tests.rs
@@ -277,6 +277,20 @@ fn keyboard_selection() {
assert_eq!(s.selection(), Vec::::new(), "Esc from a nested layer selects nothing, not the parent");
s.escape().unwrap();
assert_eq!(s.selection(), Vec::::new());
+ // A top-level layer, and several layers, nested or not: one Esc selects nothing.
+ let cases: Vec<(Vec, &str)> = vec![
+ (vec![k.f], "a top-level frame"),
+ (vec![k.r], "a top-level layer"),
+ (vec![k.a, k.b], "two layers in one group (not their group)"),
+ (vec![k.a, k.d], "layers in different parents (not the parents)"),
+ (vec![k.a, k.r], "a nested layer and a top-level one"),
+ ];
+ for (ids, why) in cases {
+ ex(&mut s, "select.set", json!({"ids": ids}));
+ assert_eq!(sorted(s.selection()), sorted(ids.clone()), "{why}: selected");
+ s.escape().unwrap();
+ assert_eq!(s.selection(), Vec::::new(), "Esc on {why} selects nothing");
+ }
}
#[test]
From 67f5e24a3f685058626c9590819e72e02c3c133b Mon Sep 17 00:00:00 2001
From: dotCooCoo
Date: Sun, 8 Nov 2026 19:45:14 -0800
Subject: [PATCH 037/103] Updates: take craft-update from craft-libs main at
1b34eba41
---
Cargo.lock | 2 +-
Cargo.toml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 672a5f8e1..8ca91ac04 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1134,7 +1134,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
-source = "git+https://github.com/dotCooCoo/craft-libs?rev=c7c84230631cbc01ee928f0a0e0d838b1ed612ae#c7c84230631cbc01ee928f0a0e0d838b1ed612ae"
+source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [
"chrono",
"dunce",
diff --git a/Cargo.toml b/Cargo.toml
index cf6ca6fea..af0dcaa8b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -43,7 +43,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
-craft-update = { git = "https://github.com/dotCooCoo/craft-libs", version = "=0.1.0", rev = "c7c84230631cbc01ee928f0a0e0d838b1ed612ae", features = ["egui"] }
+craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
From 0aba3f7632457150cfe18c96510022e1566151c6 Mon Sep 17 00:00:00 2001
From: ljkx <177671075+ljkx@users.noreply.github.com>
Date: Mon, 9 Nov 2026 06:15:26 +0100
Subject: [PATCH 038/103] M4: a Pencil stroke stays where it was drawn, without
spikes off it (#1741)
kurbo's simplify_bezpath, which smooths Pencil samples and runs Simplify
path, fitted two common shapes with a curve that ran far off the path: a
stroke that goes out and back to the very point it left (kurbo keeps a
reversal as a smooth join) and nearly collinear samples (kurbo measures
its error across the path, not along it).
figcraft_geom::simplify_path cuts the path where it turns back by more
than a right angle, simplifies each run with kurbo, and keeps a run as it
was when its fit strays more than twice the accuracy from it. The Pencil
and vector.simplify use it.
---
crates/engine/src/brush_tests.rs | 46 +++++++
crates/engine/src/canvas/tools/pen.rs | 5 +-
crates/engine/src/cmd/structure.rs | 8 +-
crates/engine/src/vector_behaviour_tests.rs | 21 +++
crates/geom/src/lib.rs | 2 +
crates/geom/src/simplify.rs | 140 ++++++++++++++++++++
crates/geom/src/tests.rs | 117 ++++++++++++++++
7 files changed, 333 insertions(+), 6 deletions(-)
create mode 100644 crates/geom/src/simplify.rs
diff --git a/crates/engine/src/brush_tests.rs b/crates/engine/src/brush_tests.rs
index 35dba6a7f..50317496d 100644
--- a/crates/engine/src/brush_tests.rs
+++ b/crates/engine/src/brush_tests.rs
@@ -149,3 +149,49 @@ fn the_pencil_draws_white_on_a_dark_background() {
assert_eq!(stroke(&mut s, 0.0, 600.0).0, want, "on {bg}");
}
}
+
+/// A Pencil stroke stays where it was drawn: the fitted curve shot far off the stroke, a spike
+/// (#1741, its second point), where the stroke went out and back to the very point it left and
+/// where samples ran on nearly straight. Pointer pixels become fractional page points, so going
+/// back to a pixel lands on exactly the same point. Both sample runs are ones kurbo's fit took
+/// hundreds of units off.
+#[test]
+fn the_pencil_keeps_its_stroke_where_it_was_drawn() {
+ let mut s = Session::new();
+ s.execute("tool.set", &json!({"tool": "pencil"})).unwrap();
+ let (p, q) = ((86.043_565_719_086_97, -173.744_402_349_100_94), (87.851_850_739_335_35, -179.202_846_445_954_52));
+ let back = [(80.0, -160.0), p, q, p, (95.0, -185.0)];
+ let straight = [
+ (204.0, -198.0),
+ (209.598_690_794_015_5, -204.909_328_119_739_88),
+ (211.277_914_263_870_97, -207.190_974_020_753_4),
+ (214.246_506_828_720_84, -211.228_827_590_623_24),
+ (218.0, -214.0),
+ ];
+ for (what, samples) in [("out and back", &back[..]), ("nearly straight", &straight[..])] {
+ let (first, last) = (samples[0], samples[samples.len() - 1]);
+ s.pointer(&PointerEvent::at(PointerKind::Down, first.0, first.1, 1.0)).unwrap();
+ for (x, y) in &samples[1..] {
+ s.pointer(&PointerEvent::at(PointerKind::Move, *x, *y, 1.0)).unwrap();
+ }
+ s.pointer(&PointerEvent::at(PointerKind::Up, last.0, last.1, 1.0)).unwrap();
+ let doc = s.doc().unwrap();
+ let n = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Vector { .. })).max_by_key(|n| n.id).unwrap();
+ let NodeKind::Vector { network } = &n.kind else { panic!("{what}: not a vector") };
+ let drawn = doc.world_transform(n.id) * network.stroke_path();
+ let samples_box = figcraft_geom::points_bounds(samples.iter().map(|(x, y)| Point::new(*x, *y))).unwrap();
+ let (path_box, node_box) = (figcraft_geom::Shape::bounding_box(&drawn), doc.world_bounds(n.id).unwrap());
+ assert!(
+ samples_box.inflate(1.5, 1.5).contains_rect(path_box),
+ "{what}: the stroke {path_box:?} goes past the samples {samples_box:?}: {}",
+ drawn.to_svg()
+ );
+ assert!(samples_box.inflate(1.5, 1.5).contains_rect(node_box), "{what}: the layer {node_box:?}, the samples {samples_box:?}");
+ }
+ // The turn is a point of the stroke, where it was drawn.
+ let doc = s.doc().unwrap();
+ let turned = doc.nodes().find(|n| n.name == "Vector" && doc.world_bounds(n.id).is_some_and(|b| b.y1 < -150.0 && b.x1 < 100.0)).unwrap();
+ let NodeKind::Vector { network } = &turned.kind else { panic!("not a vector") };
+ let xf = doc.world_transform(turned.id);
+ assert!(network.vertices.iter().any(|v| (xf * Point::new(v.x, v.y) - Point::new(q.0, q.1)).hypot() < 1e-6), "no point at the turn");
+}
diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs
index 4b98ccee1..3f8b47e59 100644
--- a/crates/engine/src/canvas/tools/pen.rs
+++ b/crates/engine/src/canvas/tools/pen.rs
@@ -313,7 +313,8 @@ impl Session {
if points.len() < 2 {
return Ok(());
}
- // Smooth: fit a curve through the samples.
+ // Smooth: fit a curve through the samples, kept from shooting past them where the stroke
+ // turns back or runs nearly straight (#1741).
let mut poly = figcraft_geom::BezPath::new();
for (k, p) in points.iter().enumerate() {
if k == 0 {
@@ -322,7 +323,7 @@ impl Session {
poly.line_to(*p);
}
}
- let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default());
+ let fitted = figcraft_geom::simplify_path(&poly, 0.75);
let d = fitted.to_svg();
if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) {
return self.ink_finish(&d);
diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs
index 3339faa38..34cc91fbb 100644
--- a/crates/engine/src/cmd/structure.rs
+++ b/crates/engine/src/cmd/structure.rs
@@ -799,7 +799,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result {
WindingRule::NonZero
};
let path = drawn.fill_path();
- let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let path = figcraft_geom::simplify_path(&path, tol);
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
Ok(path) => (path, WindingRule::EvenOdd),
Err(_) => (path, winding),
@@ -809,7 +809,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result {
} else {
for (index, region) in source.regions.iter().enumerate() {
let Some(path) = drawn.region_path(index) else { continue };
- let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let path = figcraft_geom::simplify_path(&path, tol);
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
append_region_path(&mut net, &path, region, region.winding);
}
@@ -826,7 +826,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result {
open.regions.clear();
if !open.segments.is_empty() {
let path = open.stroke_path();
- let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let simplified = figcraft_geom::simplify_path(&path, tol);
let mut loose = VectorNetwork::from_path(&simplified);
loose.regions.clear();
append_network(&mut net, loose);
@@ -834,7 +834,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result {
}
if source.regions.is_empty() {
let path = source.stroke_path();
- let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
+ let simplified = figcraft_geom::simplify_path(&path, tol);
net = VectorNetwork::from_path(&simplified);
net.regions.clear();
}
diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs
index c990ad348..4666c3028 100644
--- a/crates/engine/src/vector_behaviour_tests.rs
+++ b/crates/engine/src/vector_behaviour_tests.rs
@@ -326,6 +326,27 @@ fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) {
doc.get_mut(id).unwrap().kind = NodeKind::Vector { network };
}
+/// Simplify path keeps nearly collinear points near where they were: kurbo's fit, which Simplify
+/// path and the Pencil share, ran a handle hundreds of units on along the line (#1741).
+#[test]
+fn simplify_keeps_nearly_collinear_points_near_them() {
+ let mut s = Session::new();
+ let id = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L10 10"}))["id"]).unwrap();
+ // The points exactly as sampled, at the page origin (`vector.create` would move them).
+ let d = "M209.5986907940155 -204.90932811973988 L211.27791426387097 -207.1909740207534 L214.24650682872084 -211.22882759062324";
+ let before = BezPath::from_svg(d).unwrap();
+ {
+ let n = s.doc_mut().unwrap().get_mut(id).unwrap();
+ n.transform = figcraft_geom::Affine::IDENTITY;
+ n.kind = NodeKind::Vector { network: VectorNetwork::from_path(&before) };
+ }
+ assert_eq!(net(&s, id).stroke_path().elements(), before.elements(), "the samples as they were");
+ ex(&mut s, "vector.simplify", json!({"ids": [id], "tolerance": 0.75}));
+ let after = s.doc().unwrap().world_transform(id) * net(&s, id).stroke_path();
+ let (b0, b1) = (before.bounding_box(), after.bounding_box());
+ assert!(b0.inflate(1.5, 1.5).contains_rect(b1), "{b1:?} past {b0:?}: {}", after.to_svg());
+}
+
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs
index a775a4170..191b30419 100644
--- a/crates/geom/src/lib.rs
+++ b/crates/geom/src/lib.rs
@@ -12,7 +12,9 @@ use kurbo::ParamCurve;
use std::f64::consts::{FRAC_PI_2, PI, TAU};
pub mod measure;
+mod simplify;
mod transform;
+pub use simplify::simplify_path;
pub use transform::*;
/// Corner radii in clockwise order from the top-left: `[tl, tr, br, bl]`.
diff --git a/crates/geom/src/simplify.rs b/crates/geom/src/simplify.rs
new file mode 100644
index 000000000..e768f303a
--- /dev/null
+++ b/crates/geom/src/simplify.rs
@@ -0,0 +1,140 @@
+//! Path simplification that keeps to its input where kurbo's fit would shoot past it.
+
+use kurbo::{BezPath, ParamCurve, ParamCurveNearest, PathEl, PathSeg, Point, Vec2};
+
+/// How far, as a multiple of the accuracy asked for, a fitted run may stray from the run it
+/// replaces before the run is kept as it was. kurbo's own fits stay within about the accuracy; its
+/// failures stray by tens to hundreds of units, so twice the accuracy tells them apart without
+/// undoing fits that are merely a little over.
+const STRAY: f64 = 2.0;
+
+/// `path` simplified to within `accuracy` by kurbo's [`simplify_bezpath`], kept from straying
+/// far from `path` where kurbo's fit does.
+///
+/// Two inputs made kurbo's fit shoot far past the path it simplified (#1741, a Pencil stroke with
+/// a spike off it), and both are common in pointer samples:
+/// - A join where the path turns straight back (samples going down and back up the same line):
+/// kurbo keeps two segments in one smooth fit when their tangents are parallel whichever way
+/// they point (`|cross| <= |dot| * angle_thresh`), so it fits a smooth curve through the cusp.
+/// The path is cut where it turns back by more than a right angle, each run between the cuts is
+/// simplified on its own, and the runs are joined again, so the turn stays a sharp corner. Any
+/// other join that isn't straight within kurbo's threshold is a corner to kurbo already, so the
+/// cut changes nothing there.
+/// - A run of nearly collinear segments: kurbo measures its error across the path, not along it, so
+/// a fitted cubic whose handle runs hundreds of units on along the line passes its check. Each
+/// fitted run is measured against the run it replaces, and one that strays more than
+/// twice `accuracy` from it is kept as it was instead.
+///
+/// Subpaths and closes are kept, and a closing segment stays implicit, as kurbo leaves it.
+///
+/// [`simplify_bezpath`]: kurbo::simplify::simplify_bezpath
+pub fn simplify_path(path: &BezPath, accuracy: f64) -> BezPath {
+ let mut out = BezPath::new();
+ let mut run = BezPath::new();
+ // Where the current subpath started, where the pen is, and the last segment's end tangent.
+ let mut start = Point::ZERO;
+ let mut at = Point::ZERO;
+ let mut last_tan: Option = None;
+ // Whether the next run that draws anything has to start the subpath with its move.
+ let mut fresh = true;
+ for el in path.elements() {
+ let seg = match *el {
+ PathEl::MoveTo(p) => {
+ flush(&mut out, &mut run, accuracy, &mut fresh);
+ (start, at, last_tan, fresh) = (p, p, None, true);
+ run.move_to(p);
+ continue;
+ }
+ PathEl::ClosePath => {
+ flush(&mut out, &mut run, accuracy, &mut fresh);
+ if !fresh {
+ out.close_path();
+ }
+ // A segment after a close starts a new subpath at the closed one's start.
+ (at, last_tan, fresh) = (start, None, true);
+ run.move_to(start);
+ continue;
+ }
+ PathEl::LineTo(p) => PathSeg::Line(kurbo::Line::new(at, p)),
+ PathEl::QuadTo(p1, p2) => PathSeg::Quad(kurbo::QuadBez::new(at, p1, p2)),
+ PathEl::CurveTo(p1, p2, p3) => PathSeg::Cubic(kurbo::CubicBez::new(at, p1, p2, p3)),
+ };
+ if run.elements().is_empty() {
+ run.move_to(at);
+ }
+ let Some((t0, t1)) = tangents(seg) else { continue };
+ if last_tan.is_some_and(|last| last.dot(t0) < 0.0) {
+ flush(&mut out, &mut run, accuracy, &mut fresh);
+ run.move_to(at);
+ }
+ run.push(*el);
+ at = seg.end();
+ last_tan = Some(t1);
+ }
+ flush(&mut out, &mut run, accuracy, &mut fresh);
+ out
+}
+
+/// Simplify `run` (one move and the segments after it) with kurbo and append it to `out`; the
+/// run's move is kept only when it starts a subpath (`fresh`), else the run carries on from the
+/// last point drawn. `run` is left empty.
+fn flush(out: &mut BezPath, run: &mut BezPath, accuracy: f64, fresh: &mut bool) {
+ if run.elements().len() > 1 {
+ let fitted = kurbo::simplify::simplify_bezpath(run.iter(), accuracy, &Default::default());
+ let fitted = if keeps_to(&fitted, run, STRAY * accuracy) { fitted } else { std::mem::take(run) };
+ for el in fitted.elements() {
+ match el {
+ PathEl::MoveTo(_) if !*fresh => {}
+ _ => {
+ out.push(*el);
+ *fresh = false;
+ }
+ }
+ }
+ }
+ *run = BezPath::new();
+}
+
+/// Whether every point of `fitted` lies within `tol` of `source`: one subpath each, from the same
+/// start to the same end, `fitted` being kurbo's simplification of `source`.
+///
+/// kurbo ends each fitted segment on the source, so the two are walked together: for each fitted
+/// segment, the source segments from where it starts to the first one its end lies on are the only
+/// ones its sample points are measured against. The work is linear in the two lengths. A fitted end
+/// found on no source segment counts as straying.
+fn keeps_to(fitted: &BezPath, source: &BezPath, tol: f64) -> bool {
+ /// Parts each fitted segment is cut into: the points between them are the ones measured.
+ const SAMPLES: u32 = 16;
+ let src: Vec = source.segments().collect();
+ // kurbo puts each end on the source by evaluating it: off by rounding at most.
+ let on2 = (tol * 1e-3).powi(2);
+ let (tol2, mut cur) = (tol * tol, 0);
+ for seg in fitted.segments() {
+ let Some(last) = (cur..src.len()).find(|&i| src.get(i).is_some_and(|s| s.nearest(seg.end(), 1e-9).distance_sq <= on2)) else {
+ return false;
+ };
+ let near = src.get(cur..=last).unwrap_or_default();
+ for i in 1..SAMPLES {
+ let p = seg.eval(f64::from(i) / f64::from(SAMPLES));
+ if !near.iter().any(|s| s.nearest(p, 1e-6).distance_sq <= tol2) {
+ return false;
+ }
+ }
+ cur = last;
+ }
+ true
+}
+
+/// The directions `seg` leaves its start and reaches its end, skipping control points that sit on
+/// an end; `None` for a segment of no length, which kurbo skips too.
+fn tangents(seg: PathSeg) -> Option<(Vec2, Vec2)> {
+ let pts: Vec = match seg {
+ PathSeg::Line(l) => vec![l.p0, l.p1],
+ PathSeg::Quad(q) => vec![q.p0, q.p1, q.p2],
+ PathSeg::Cubic(c) => vec![c.p0, c.p1, c.p2, c.p3],
+ };
+ let (first, last) = (*pts.first()?, *pts.last()?);
+ let t0 = pts.iter().map(|p| *p - first).find(|d| d.hypot2() > 0.0)?;
+ let t1 = pts.iter().rev().map(|p| last - *p).find(|d| d.hypot2() > 0.0)?;
+ Some((t0, t1))
+}
diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs
index 459b2b101..10e09318c 100644
--- a/crates/geom/src/tests.rs
+++ b/crates/geom/src/tests.rs
@@ -366,3 +366,120 @@ fn svg_path_refuses_numbers_past_the_limit_before_parsing() {
assert!(number_past("M0 0L-2.5.5 1e-3-4e3z", 1e3));
assert!(number_past("M.5.5e10", 1e3));
}
+
+/// A path through `pts` with straight lines (pencil samples).
+fn polyline(pts: &[(f64, f64)]) -> BezPath {
+ let mut p = BezPath::new();
+ for (k, (x, y)) in pts.iter().enumerate() {
+ if k == 0 { p.move_to((*x, *y)) } else { p.line_to((*x, *y)) }
+ }
+ p
+}
+
+/// How far `out` strays from `input`: the largest distance from a point sampled along `out` to the
+/// nearest segment of `input` (closing segments included).
+fn deviation(out: &BezPath, input: &BezPath) -> f64 {
+ use kurbo::{ParamCurve, ParamCurveNearest};
+ let segs: Vec = input.segments().collect();
+ out.segments()
+ .flat_map(|seg| (0..=32).map(move |i| seg.eval(f64::from(i) / 32.0)))
+ .map(|p| segs.iter().map(|s| s.nearest(p, 1e-6).distance_sq).fold(f64::INFINITY, f64::min).sqrt())
+ .fold(0.0, f64::max)
+}
+
+/// Pencil strokes kurbo's fit shot far past keep to their samples (#1741): a stroke that goes out
+/// and back to the very point it left, nearly collinear samples, and strokes that turn back along
+/// their own line. The first two are from fuzzing pencil-like strokes, where kurbo's curve went
+/// about 390 and 160 units off; at whole-pixel coordinates the same shapes happened to fit well.
+#[test]
+fn simplifying_keeps_to_strokes_kurbo_shot_past() {
+ use kurbo::ParamCurve;
+ let cases: [(&str, &[(f64, f64)]); 5] = [
+ (
+ "out and back to the same point",
+ &[
+ (86.043_565_719_086_97, -173.744_402_349_100_94),
+ (87.851_850_739_335_35, -179.202_846_445_954_52),
+ (86.043_565_719_086_97, -173.744_402_349_100_94),
+ ],
+ ),
+ (
+ "nearly collinear",
+ &[
+ (209.598_690_794_015_5, -204.909_328_119_739_88),
+ (211.277_914_263_870_97, -207.190_974_020_753_4),
+ (214.246_506_828_720_84, -211.228_827_590_623_24),
+ ],
+ ),
+ (
+ "a dip straight down and back up",
+ &[(0.0, 0.0), (10.0, 0.0), (20.0, 2.0), (30.0, 6.0), (30.0, 12.0), (30.0, 6.0), (40.0, 2.0), (50.0, 0.0)],
+ ),
+ ("a slip back at the end", &[(0.0, 0.0), (10.0, 3.0), (20.0, 8.0), (30.0, 15.0), (30.0, 25.0), (30.0, 22.0)]),
+ (
+ "a near reversal in a curve",
+ &[(0.0, 0.0), (8.0, 2.0), (16.0, 6.0), (24.0, 12.0), (32.0, 20.0), (24.0, 12.000_01), (16.0, 6.0), (8.0, 2.0)],
+ ),
+ ];
+ for (what, pts) in cases {
+ let input = polyline(pts);
+ let out = simplify_path(&input, 0.75);
+ let d = deviation(&out, &input);
+ assert!(d <= 1.5, "{what}: strays {d} from the samples: {}", out.to_svg());
+ let (inb, outb) = (input.bounding_box(), out.bounding_box());
+ assert!(inb.inflate(1.5, 1.5).contains_rect(outb), "{what}: {outb:?} past {inb:?}");
+ // One subpath, starting and ending where the stroke does.
+ let moves = out.elements().iter().filter(|e| matches!(e, kurbo::PathEl::MoveTo(_))).count();
+ assert_eq!(moves, 1, "{what}: {}", out.to_svg());
+ let (first, last) = (pts[0], pts[pts.len() - 1]);
+ assert_eq!(out.segments().next().map(|s| s.start()), Some(Point::new(first.0, first.1)), "{what}");
+ assert_eq!(out.segments().last().map(|s| s.end()), Some(Point::new(last.0, last.1)), "{what}");
+ }
+}
+
+/// Where a path never turns back and kurbo's fit holds, the result is kurbo's own, element for
+/// element: smooth runs, corners sharper than a right angle, closed subpaths and several subpaths.
+#[test]
+fn simplifying_is_kurbos_where_its_fit_holds() {
+ let wave: Vec<(f64, f64)> = (0..=60).map(|i| (f64::from(i) * 4.0, (f64::from(i) / 6.0).sin() * 30.0)).collect();
+ let zigzag: Vec<(f64, f64)> = (0..=8).map(|i| (f64::from(i) * 10.0, if i % 2 == 0 { 0.0 } else { 40.0 })).collect();
+ let mut closed = polyline(&wave[..20]);
+ closed.close_path();
+ let mut several = polyline(&wave);
+ several.extend(polyline(&zigzag).elements().iter().map(|e| match *e {
+ kurbo::PathEl::MoveTo(p) => kurbo::PathEl::MoveTo(p + Vec2::new(0.0, 100.0)),
+ kurbo::PathEl::LineTo(p) => kurbo::PathEl::LineTo(p + Vec2::new(0.0, 100.0)),
+ e => e,
+ }));
+ let curves = BezPath::from_svg("M0 0 C10 -20 30 -20 40 0 C50 20 70 20 80 0 Q100 -30 120 0 L140 10 Z M200 0 L240 0").unwrap();
+ for (what, input) in
+ [("a wave", polyline(&wave)), ("a zigzag", polyline(&zigzag)), ("closed", closed), ("two subpaths", several), ("curves", curves)]
+ {
+ let ours = simplify_path(&input, 0.75);
+ let theirs = kurbo::simplify::simplify_bezpath(input.iter(), 0.75, &Default::default());
+ assert_eq!(ours.elements(), theirs.elements(), "{what}");
+ }
+}
+
+/// A closed path with a reversal in it stays one closed subpath, and each subpath of several keeps
+/// its own start.
+#[test]
+fn simplifying_keeps_closes_and_subpaths_across_a_reversal() {
+ let mut input = polyline(&[(0.0, 0.0), (40.0, 0.0), (40.0, 30.0), (40.0, 20.0), (0.0, 30.0)]);
+ input.close_path();
+ input.extend(polyline(&[(100.0, 0.0), (100.0, 20.0), (100.0, 10.0)]).elements().iter().copied());
+ let out = simplify_path(&input, 0.75);
+ let els = out.elements();
+ let moves: Vec = els.iter().filter_map(|e| if let kurbo::PathEl::MoveTo(p) = e { Some(*p) } else { None }).collect();
+ assert_eq!(moves, [Point::new(0.0, 0.0), Point::new(100.0, 0.0)], "{}", out.to_svg());
+ assert_eq!(els.iter().filter(|e| matches!(e, kurbo::PathEl::ClosePath)).count(), 1, "{}", out.to_svg());
+ let close = els.iter().position(|e| matches!(e, kurbo::PathEl::ClosePath)).unwrap();
+ assert!(matches!(els[close + 1], kurbo::PathEl::MoveTo(_)), "the first subpath closes before the second starts");
+ assert!(deviation(&out, &input) <= 1.5, "{}", out.to_svg());
+ // Nothing to draw: nothing out, not a stray move or close.
+ let mut empty = BezPath::new();
+ empty.move_to((5.0, 5.0));
+ empty.line_to((5.0, 5.0));
+ empty.close_path();
+ assert!(simplify_path(&empty, 0.75).elements().is_empty());
+}
From 72cfe7be308a8587041a40aad39d2d8fc0ad56c9 Mon Sep 17 00:00:00 2001
From: FredApps <153173854+FredApps@users.noreply.github.com>
Date: Mon, 9 Nov 2026 06:22:48 +0100
Subject: [PATCH 039/103] fix(naming): use direct FigCraft feature descriptions
(#2131)
Drops the "Figma-class" self-description from the MCP instructions and the "Accessibility at Figma" aside from pref.uiScaleUp help. Help text only; ids and preference keys unchanged. Adds a display-name guard over mcp/src and engine/src/cmd string literals.
---
crates/engine/src/canvas/arc_handles.rs | 3 +-
crates/engine/src/cmd/view.rs | 2 +-
crates/mcp/src/server.rs | 2 +-
crates/mcp/tests/display_names.rs | 187 ++++++++++++++++++++++++
docs/feature-names.md | 5 +
5 files changed, 195 insertions(+), 4 deletions(-)
create mode 100644 crates/mcp/tests/display_names.rs
create mode 100644 docs/feature-names.md
diff --git a/crates/engine/src/canvas/arc_handles.rs b/crates/engine/src/canvas/arc_handles.rs
index 8a38cdfc5..b8fc45b76 100644
--- a/crates/engine/src/canvas/arc_handles.rs
+++ b/crates/engine/src/canvas/arc_handles.rs
@@ -1,5 +1,4 @@
-//! Arc handles: the on-canvas handles of a selected ellipse's arc (as public documentation of
-//! Figma-class tools describes them).
+//! Arc handles: on-canvas controls for a selected ellipse's arc.
//!
//! - **Sweep** starts on the ellipse's right edge, on its centre line. Dragging it around the
//! ellipse opens it into an arc: the sweep is a signed share of a full turn, positive dragging up
diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs
index c33878066..43feb6c39 100644
--- a/crates/engine/src/cmd/view.rs
+++ b/crates/engine/src/cmd/view.rs
@@ -431,7 +431,7 @@ pub fn specs() -> Vec {
spec!(
"pref.uiScaleUp",
"Larger interface",
- "{} the next of the Preferences sizes above the current one (the interface size keys of Accessibility at Figma); at the largest it stays → {scale}",
+ "{} the next of the Preferences sizes above the current one; at the largest it stays → {scale}",
false,
|s, _| ui_scale_step(s, 1)
),
diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs
index 1a25ac7f3..70ac05999 100644
--- a/crates/mcp/src/server.rs
+++ b/crates/mcp/src/server.rs
@@ -28,7 +28,7 @@ pub const RESOURCE_NOT_FOUND: i64 = -32002;
const MAX_LINE: usize = 32 * 1024 * 1024;
/// The `instructions` string sent on initialize.
-pub const INSTRUCTIONS: &str = "FigCraft is an interface design tool (Figma-class): pages hold frames, \
+pub const INSTRUCTIONS: &str = "FigCraft is an interface design tool: pages hold frames, \
groups, shapes, text, components and instances, with auto layout, styles, variables and prototyping. \
Coordinates are page units (1 unit = 1 px at 100% zoom), x right and y DOWN. create_node and set_properties \
take x/y RELATIVE TO THE PARENT (the page when there is no parent, so page coordinates); inspect_document \
diff --git a/crates/mcp/tests/display_names.rs b/crates/mcp/tests/display_names.rs
new file mode 100644
index 000000000..29335dd62
--- /dev/null
+++ b/crates/mcp/tests/display_names.rs
@@ -0,0 +1,187 @@
+//! Guard the production display surfaces without banning compatibility identifiers or notices.
+use std::path::{Path, PathBuf};
+
+const BANNED: &[&str] = &["Figma-class", "Accessibility at Figma"];
+const ALLOWED: &[&str] = &[];
+const ROOTS: &[&str] = &["src", "../engine/src/cmd"];
+
+fn is_word(c: char) -> bool {
+ c.is_alphanumeric() || c == '_'
+}
+
+fn banned_display(text: &str) -> bool {
+ if ALLOWED.contains(&text) {
+ return false;
+ }
+ BANNED.iter().any(|banned| {
+ text.match_indices(banned)
+ .any(|(at, _)| !text[..at].chars().next_back().is_some_and(is_word) && !text[at + banned.len()..].chars().next().is_some_and(is_word))
+ })
+}
+
+// Read ordinary and raw Rust string literals, ignoring comments. No compiler dependency is
+// needed, so this guard can also run by itself when a full application build is unavailable.
+#[allow(clippy::cognitive_complexity)] // This small test-only lexer keeps raw-string/comment state together.
+fn literals(source: &str) -> Vec {
+ let chars: Vec = source.chars().collect();
+ let mut out = Vec::new();
+ let mut at = 0;
+ let mut test_module = false;
+ let mut test_depth = 0_usize;
+ while at < chars.len() {
+ if chars.get(at..at + 12) == Some(&['#', '[', 'c', 'f', 'g', '(', 't', 'e', 's', 't', ')', ']']) {
+ at += 12;
+ let rest: String = chars[at..].iter().collect();
+ if rest.trim_start().starts_with("mod ") {
+ test_module = true;
+ }
+ } else if chars.get(at..at + 2) == Some(&['/', '/']) {
+ while at < chars.len() && chars[at] != '\n' {
+ at += 1;
+ }
+ } else if chars.get(at..at + 2) == Some(&['/', '*']) {
+ at += 2;
+ let mut depth = 1;
+ while at < chars.len() && depth > 0 {
+ if chars.get(at..at + 2) == Some(&['/', '*']) {
+ depth += 1;
+ at += 2;
+ } else if chars.get(at..at + 2) == Some(&['*', '/']) {
+ depth -= 1;
+ at += 2;
+ } else {
+ at += 1;
+ }
+ }
+ } else if chars[at] == 'r' && matches!(chars.get(at + 1), Some('"' | '#')) {
+ let start = at;
+ at += 1;
+ while chars.get(at) == Some(&'#') {
+ at += 1;
+ }
+ let hashes = at - start - 1;
+ if chars.get(at) != Some(&'"') {
+ continue;
+ }
+ at += 1;
+ let begin = at;
+ while at < chars.len() {
+ if chars[at] == '"' && (1..=hashes).all(|n| chars.get(at + n) == Some(&'#')) {
+ break;
+ }
+ at += 1;
+ }
+ if !test_module {
+ out.push(chars[begin..at].iter().collect());
+ }
+ at += 1 + hashes;
+ } else if chars[at] == '"' {
+ at += 1;
+ let mut text = String::new();
+ while at < chars.len() {
+ let c = chars[at];
+ at += 1;
+ if c == '"' {
+ break;
+ }
+ if c == '\\' && at < chars.len() {
+ text.push(chars[at]);
+ at += 1;
+ } else {
+ text.push(c);
+ }
+ }
+ if !test_module {
+ out.push(text);
+ }
+ } else if chars[at] == '\'' && chars.get(at + 2) == Some(&'\'') {
+ at += 3; // A character literal such as '"', not the start of a string.
+ } else {
+ if test_module {
+ match chars[at] {
+ '{' => test_depth += 1,
+ '}' => {
+ test_depth = test_depth.saturating_sub(1);
+ if test_depth == 0 {
+ test_module = false;
+ }
+ }
+ ';' if test_depth == 0 => test_module = false,
+ _ => {}
+ }
+ }
+ at += 1;
+ }
+ }
+ out
+}
+
+fn check_tree(path: &Path, failures: &mut Vec) -> std::io::Result<()> {
+ if path.is_dir() {
+ for entry in std::fs::read_dir(path)? {
+ let path = entry?.path();
+ if path.file_name().is_some_and(|name| name == "tests") {
+ continue;
+ }
+ check_tree(&path, failures)?;
+ }
+ } else if path.extension().is_some_and(|ext| ext == "rs") {
+ let name = path.file_name().ok_or_else(|| std::io::Error::other("source filename missing"))?.to_string_lossy();
+ if name == "tests.rs" || name.ends_with("_tests.rs") {
+ return Ok(());
+ }
+ let source = std::fs::read_to_string(path)?;
+ // Skip only inline test modules, not later production following test-only imports.
+ for text in literals(&source) {
+ if banned_display(&text) {
+ failures.push(format!("{}: {text:?}", path.display()));
+ }
+ }
+ }
+ Ok(())
+}
+
+#[test]
+fn production_display_names_stay_neutral() -> std::io::Result<()> {
+ let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
+ let mut failures = Vec::new();
+ for root in ROOTS {
+ let root = base.join(root);
+ assert!(root.exists(), "missing display source: {}", root.display());
+ check_tree(&root, &mut failures)?;
+ }
+ assert!(failures.is_empty(), "Banned display names:\n{}", failures.join("\n"));
+ Ok(())
+}
+
+#[test]
+fn guard_distinguishes_labels_from_identifiers_and_comments() {
+ for name in BANNED {
+ assert!(banned_display(name), "{name}");
+ }
+ for name in ALLOWED {
+ assert!(!banned_display(name), "{name}");
+ }
+ assert!(!banned_display("filter.cameraRaw"));
+ assert!(!banned_display("tool.guidedUpright"));
+ assert!(!banned_display("insert.smartArt"));
+ assert_eq!(literals("// \"ignored\"\n/* \"ignored\" */ \"label\""), ["label"]);
+ assert_eq!(literals("r#\"raw label\"#"), ["raw label"]);
+}
+
+#[test]
+fn guard_scans_production_after_test_only_items() {
+ let source = r###"#[cfg(test)] mod tests { let _ = "ignored"; }
+ #[cfg(test)] use fixtures::Helper;
+ fn production() { let _ = r#"live label"#; }
+ "###;
+ assert_eq!(literals(source), ["live label"]);
+ assert_eq!(literals("#[cfg(test)] mod tests; fn live() { let _ = \"live label\"; }"), ["live label"]);
+}
+
+#[test]
+fn source_read_failures_are_reported() {
+ // A file cannot be traversed as a directory. A failed scan must fail, not silently pass.
+ let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/display_names.rs/not-a-directory.rs");
+ assert!(check_tree(&path, &mut Vec::new()).is_err());
+}
diff --git a/docs/feature-names.md b/docs/feature-names.md
new file mode 100644
index 000000000..c3df4d593
--- /dev/null
+++ b/docs/feature-names.md
@@ -0,0 +1,5 @@
+# Feature naming
+
+FigCraft describes itself as an **interface design tool**, without the comparative Figma-class tag. Interface-size command help describes the size steps directly. Command IDs, file-format interoperability references, behavioral documentation and attribution remain unchanged.
+
+Regression tests prevent the retired comparative display phrases from returning to MCP instructions or command help. This copy change makes no legal claim about the comparative terms and does not change behavior.
From 3839fe4a0cb4897c1fed1fb03e0b60e191ff61cb Mon Sep 17 00:00:00 2001
From: skylar taylor-barrick
Date: Mon, 9 Nov 2026 06:24:44 +0100
Subject: [PATCH 040/103] Naming: use Developer Mode instead of Figma's Dev
Mode (#2135)
Figma holds a US registration for DEV MODE (US 7574348). Display text only: the view.devMode label, Inspect tab, shortcut row, refusal messages, CLI help, docs and all locale catalogs now say Developer Mode. Command ids, preference keys and saved fields are unchanged; "Dev Mode" stays a search alias in the Actions palette. Adds a naming guard test.
---
README.md | 8 +-
ROADMAP.md | 3 +-
apps/figcraft-cli/src/args.rs | 2 +-
crates/engine/src/boards/board.rs | 2 +-
crates/engine/src/catalog.rs | 4 +
crates/engine/src/cmd/view.rs | 2 +-
crates/engine/src/dev/dev_measurements.rs | 2 +-
crates/engine/src/keymap.rs | 2 +-
crates/format/src/fig/export/layer.rs | 2 +-
crates/ui-egui/locales/ar.toml | 4 +-
crates/ui-egui/locales/bn.toml | 2 +-
crates/ui-egui/locales/bs.toml | 6 +-
crates/ui-egui/locales/cs.toml | 2 +-
crates/ui-egui/locales/de.toml | 4 +-
crates/ui-egui/locales/en.toml | 4 +-
crates/ui-egui/locales/es.toml | 6 +-
crates/ui-egui/locales/fi.toml | 4 +-
crates/ui-egui/locales/fr.toml | 6 +-
crates/ui-egui/locales/id.toml | 4 +-
crates/ui-egui/locales/it.toml | 2 +-
crates/ui-egui/locales/ja.toml | 6 +-
crates/ui-egui/locales/ka.toml | 4 +-
crates/ui-egui/locales/ko.toml | 6 +-
crates/ui-egui/locales/nl.toml | 6 +-
crates/ui-egui/locales/pl.toml | 5 +-
crates/ui-egui/locales/pt-BR.toml | 6 +-
crates/ui-egui/locales/ru.toml | 7 +-
crates/ui-egui/locales/sv.toml | 5 +-
crates/ui-egui/locales/th.toml | 6 +-
crates/ui-egui/locales/tr.toml | 3 +-
crates/ui-egui/locales/vi.toml | 4 +-
crates/ui-egui/locales/zh-Hans.toml | 6 +-
crates/ui-egui/locales/zh-Hant.toml | 6 +-
crates/ui-egui/src/app.rs | 15 ++--
crates/ui-egui/src/canvas.rs | 2 +-
crates/ui-egui/src/control.rs | 2 +-
crates/ui-egui/src/devmode_tail_tests.rs | 2 +-
crates/ui-egui/src/lib.rs | 2 +
crates/ui-egui/src/menus.rs | 2 +-
crates/ui-egui/src/menus/palette.rs | 15 +++-
crates/ui-egui/src/naming_tests.rs | 101 ++++++++++++++++++++++
crates/ui-egui/src/palette_tests.rs | 15 ++++
docs/architecture.md | 2 +-
docs/boards-parity.md | 2 +-
docs/cli.md | 4 +-
docs/collaboration-parity.md | 4 +-
docs/components-parity.md | 2 +-
docs/control-protocol.md | 6 +-
docs/dev-mode-parity.md | 3 +-
docs/file-format-parity.md | 4 +-
docs/localization-parity.md | 4 +-
docs/mcp.md | 16 ++--
docs/plugins.md | 2 +-
docs/roadmap.md | 3 +-
docs/translating.md | 2 +-
docs/ui-parity.md | 4 +-
56 files changed, 251 insertions(+), 104 deletions(-)
create mode 100644 crates/ui-egui/src/naming_tests.rs
diff --git a/README.md b/README.md
index 012ca857a..7bbc4d524 100644
--- a/README.md
+++ b/README.md
@@ -103,8 +103,8 @@
-
-
Dev Mode: ready-for-dev status, annotations and React code where component properties become props · examples/ridgeline-app.figcraft
+
+
Developer Mode: ready-for-dev status, annotations and React code where component properties become props · examples/ridgeline-app.figcraft
@@ -127,8 +127,8 @@
Boards: a whiteboard file kind with sticky notes, templates, connectors, a shared timer and voting, in the same live sessions
-
-
Dev Mode measurements: redlines placed between layers or along a side, saved in the file and following the layers · examples/ridgeline-app.figcraft
+
+
Developer Mode measurements: redlines placed between layers or along a side, saved in the file and following the layers · examples/ridgeline-app.figcraft
diff --git a/ROADMAP.md b/ROADMAP.md
index a574499c0..651f3ab2e 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** patch (the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -13,6 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
| | Value | Kind |
|---|---|---|
+| 2026-11-07 | patch | Naming: the handoff mode is called Developer Mode in the interface, its shortcut row, its toasts, the control channel's refusal, the board refusal, the measurement parameter doc, the `.fig` export note, the CLI help and every catalog (24 languages, translations awaiting native review), since Figma holds a US registration for Dev Mode (7574348); command ids (`view.devMode`, `devMode.*`), preference keys and saved-document fields are unchanged, and the Actions palette still finds the command by the old name |
| Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs
index fb4759f7f..4696b8664 100644
--- a/apps/figcraft-cli/src/args.rs
+++ b/apps/figcraft-cli/src/args.rs
@@ -243,7 +243,7 @@ pub enum ConnectCommand {
/// Link a design file's components to their code as a figcraft.connect.json says, and save it
///
-/// The mappings are what Dev Mode's Export code connections writes, kept in the code repository:
+/// The mappings are what Developer Mode's Export code connections writes, kept in the code repository:
/// each links a component (or component set) of that name to its code component for a language.
/// Entries that don't fit (an unknown component, a bad template) are reported and skipped.
#[derive(Args)]
diff --git a/crates/engine/src/boards/board.rs b/crates/engine/src/boards/board.rs
index 1f10f5d94..1a66eed22 100644
--- a/crates/engine/src/boards/board.rs
+++ b/crates/engine/src/boards/board.rs
@@ -76,7 +76,7 @@ pub use reshape::selected_connector_handles;
/// Why a board command refuses on a design file.
pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board";
/// Why prototyping and Dev Mode refuse on a board.
-pub const NOT_ON_BOARDS: &str = "boards have no prototypes or Dev Mode: use a design file for those";
+pub const NOT_ON_BOARDS: &str = "boards have no prototypes or Developer Mode: use a design file for those";
/// A sticky note's side (it grows taller for long notes).
pub const STICKY: f64 = 200.0;
diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs
index a8a18b38c..576e42cb6 100644
--- a/crates/engine/src/catalog.rs
+++ b/crates/engine/src/catalog.rs
@@ -18,6 +18,10 @@ macro_rules! m {
pub const SEP: &str = "-";
+/// Extra search terms the Actions palette reads, by command id: a name a feature had before it was
+/// renamed, so people who know the old name still find the command. Never shown.
+pub static SEARCH_ALIASES: &[(&str, &str)] = &[("view.devMode", "Dev Mode")];
+
pub static MENU: &[MenuItem] = &[
// File
m!("File", "New design file", "file.new"),
diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs
index 43feb6c39..46ca701ff 100644
--- a/crates/engine/src/cmd/view.rs
+++ b/crates/engine/src/cmd/view.rs
@@ -347,7 +347,7 @@ pub fn specs() -> Vec {
spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")),
spec!("view.prototype", "Prototype panel", "{}", false, |s, _| ui(s, "panel.prototype")),
spec!("view.designPrototype", "Switch between Design and Prototype", "{}", false, |s, _| ui(s, "panel.designPrototype")),
- spec!("view.devMode", "Dev Mode", "{}", false, |s, _| ui(s, "panel.devMode")),
+ spec!("view.devMode", "Developer Mode", "{}", false, |s, _| ui(s, "panel.devMode")),
spec!("view.leftSidebar", "Show/Hide left sidebar", "{}", false, |s, _| ui(s, "panel.leftSidebar")),
spec!(
"view.panelWidths",
diff --git a/crates/engine/src/dev/dev_measurements.rs b/crates/engine/src/dev/dev_measurements.rs
index c5df56ea2..6c5091fc9 100644
--- a/crates/engine/src/dev/dev_measurements.rs
+++ b/crates/engine/src/dev/dev_measurements.rs
@@ -200,7 +200,7 @@ pub fn specs() -> Vec {
spec!(
"devMode.addMeasurement",
"Add measurement",
- "{from? (default the first selected layer), to? (a layer on the same page), side? (top|right|bottom|left)} keeps a measurement on the canvas: between `from` and `to`, across one side of `from`, or (neither) to the layer around it; Dev Mode draws it and it follows the layers → {from, index, lines}",
+ "{from? (default the first selected layer), to? (a layer on the same page), side? (top|right|bottom|left)} keeps a measurement on the canvas: between `from` and `to`, across one side of `from`, or (neither) to the layer around it; Developer Mode draws it and it follows the layers → {from, index, lines}",
true,
add
),
diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs
index da75cfdd0..e45ced8d4 100644
--- a/crates/engine/src/keymap.rs
+++ b/crates/engine/src/keymap.rs
@@ -224,7 +224,7 @@ pub static KEYS: &[Binding] = &[
k(View, "view.design", &["Alt+8"]).label("Design panel"),
k(View, "view.prototype", &["Alt+9"]).label("Prototype panel"),
k(View, "view.designPrototype", &["Shift+E"]),
- k(View, "view.devMode", &["Shift+D"]).label("Dev Mode on/off"),
+ k(View, "view.devMode", &["Shift+D"]).label("Developer Mode on/off"),
k(View, "view.minimize", &["Shift+Cmd+\\"]),
k(View, "view.nextRegion", &["F6"]).win(&["F6", "Ctrl+F6"]).label("Focus the next region (toolbar, layers, canvas, properties)").both(),
k(View, "view.previousRegion", &["Shift+F6"]).label("Focus the previous region").both(),
diff --git a/crates/format/src/fig/export/layer.rs b/crates/format/src/fig/export/layer.rs
index b4fda9138..bebd09059 100644
--- a/crates/format/src/fig/export/layer.rs
+++ b/crates/format/src/fig/export/layer.rs
@@ -304,7 +304,7 @@ impl<'d> Exporter<'d> {
|| !n.measurements.is_empty()
|| !n.dev_status.is_none()
{
- self.warn("Dev Mode annotations, statuses, resources, measurements and code links were left out");
+ self.warn("Developer Mode annotations, statuses, resources, measurements and code links were left out");
}
if !n.plugin_data.is_empty() {
self.warn("plugin data was left out");
diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml
index da4474e79..e50a9b9c4 100644
--- a/crates/ui-egui/locales/ar.toml
+++ b/crates/ui-egui/locales/ar.toml
@@ -433,9 +433,9 @@ status = "draft — needs native review"
"Detach styles" = "فصل الأنماط"
"Detach variable" = "فصل المتغير"
"Details" = "التفاصيل"
-"Dev Mode" = "وضع المطور"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "وضع المطور للفحص فقط؛ انتقل إلى التصميم (Shift+D) للتحرير"
"Dev resources" = "موارد المطورين"
+"Developer Mode" = "وضع المطور"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "وضع المطور للفحص فقط؛ انتقل إلى التصميم (Shift+D) للتحرير"
"Development" = "التطوير"
"Device" = "الجهاز"
"Device at 100%" = "الجهاز بحجم 100%"
diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml
index 5d01928bf..b79877271 100644
--- a/crates/ui-egui/locales/bn.toml
+++ b/crates/ui-egui/locales/bn.toml
@@ -415,8 +415,8 @@ status = "draft — needs native review"
"Detach styles" = "স্টাইলগুলো বিচ্ছিন্ন করুন"
"Detach variable" = "ভেরিয়েবল বিচ্ছিন্ন করুন"
"Details" = "বিস্তারিত"
-"Dev Mode" = "ডেভ মোড"
"Dev resources" = "ডেভ রিসোর্স"
+"Developer Mode" = "ডেভেলপার মোড"
"Development" = "ডেভেলপমেন্ট"
"Device" = "ডিভাইস"
"Device at 100%" = "১০০%-এ ডিভাইস"
diff --git a/crates/ui-egui/locales/bs.toml b/crates/ui-egui/locales/bs.toml
index 36d71664c..919bc0381 100644
--- a/crates/ui-egui/locales/bs.toml
+++ b/crates/ui-egui/locales/bs.toml
@@ -436,9 +436,9 @@ status = "draft — needs native review"
"Detach styles" = "Odvoji stilove"
"Detach variable" = "Odvoji varijablu"
"Details" = "Detalji"
-"Dev Mode" = "Dev Mode"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Dev Mode služi samo za pregled: prebacite se na Dizajn (Shift+D) za uređivanje"
"Dev resources" = "Resursi za programere"
+"Developer Mode" = "Razvojni režim"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "Razvojni režim služi samo za pregled: prebacite se na Dizajn (Shift+D) za uređivanje"
"Development" = "Razvoj"
"Device" = "Uređaj"
"Device at 100%" = "Uređaj na 100%"
@@ -2513,7 +2513,7 @@ status = "draft — needs native review"
"view.contextMenu" = "Prikaži kontekstni meni"
"view.design" = "Panel dizajna"
"view.designPrototype" = "Prebacuj između Dizajna i Prototipa"
-"view.devMode" = "Dev Mode"
+"view.devMode" = "Razvojni režim"
"view.frameOutlines" = "Konture okvira"
"view.fullscreen" = "Preko cijelog ekrana"
"view.get" = "Dohvati prikaz"
diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml
index d401654ac..7ecc6779a 100644
--- a/crates/ui-egui/locales/cs.toml
+++ b/crates/ui-egui/locales/cs.toml
@@ -405,8 +405,8 @@ status = "draft — needs native review"
"Detach styles" = "Odpojit styly"
"Detach variable" = "Odpojit proměnnou"
"Details" = "Podrobnosti"
-"Dev Mode" = "Režim pro vývojáře"
"Dev resources" = "Zdroje pro vývojáře"
+"Developer Mode" = "Režim pro vývojáře"
"Development" = "Vývoj"
"Device" = "Zařízení"
"Device at 100%" = "Zařízení ve 100 %"
diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml
index 6fd1838d6..f57fcc5d3 100644
--- a/crates/ui-egui/locales/de.toml
+++ b/crates/ui-egui/locales/de.toml
@@ -404,9 +404,9 @@ status = "draft — needs native review"
"Detach styles" = "Stile lösen"
"Detach variable" = "Variable lösen"
"Details" = "Details"
-"Dev Mode" = "Entwicklermodus"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Der Entwicklermodus prüft nur: Wechseln Sie zu Design (Shift+D), um zu bearbeiten"
"Dev resources" = "Entwicklerressourcen"
+"Developer Mode" = "Entwicklermodus"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "Der Entwicklermodus prüft nur: Wechseln Sie zu Design (Shift+D), um zu bearbeiten"
"Development" = "Entwicklung"
"Device" = "Gerät"
"Device at 100%" = "Gerät bei 100 %"
diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml
index bb8546ee6..625ee3bd8 100644
--- a/crates/ui-egui/locales/en.toml
+++ b/crates/ui-egui/locales/en.toml
@@ -479,9 +479,9 @@ name = "English"
"Detach styles" = "Detach styles"
"Detach variable" = "Detach variable"
"Details" = "Details"
-"Dev Mode" = "Dev Mode"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Dev Mode only inspects: switch to Design (Shift+D) to edit"
"Dev resources" = "Dev resources"
+"Developer Mode" = "Developer Mode"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "Developer Mode only inspects: switch to Design (Shift+D) to edit"
"Development" = "Development"
"Device" = "Device"
"Device at 100%" = "Device at 100%"
diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml
index 2baa2b31e..258a1527d 100644
--- a/crates/ui-egui/locales/es.toml
+++ b/crates/ui-egui/locales/es.toml
@@ -438,9 +438,9 @@ status = "draft — needs native review"
"Detach styles" = "Separar estilos"
"Detach variable" = "Separar variable"
"Details" = "Detalles"
-"Dev Mode" = "Modo desarrollo"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "El modo desarrollo solo inspecciona: cambia a Diseño (Shift+D) para editar"
"Dev resources" = "Recursos de desarrollo"
+"Developer Mode" = "Modo de desarrollo"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "El modo de desarrollo solo inspecciona: cambia a Diseño (Shift+D) para editar"
"Development" = "Desarrollo"
"Device" = "Dispositivo"
"Device at 100%" = "Dispositivo al 100%"
@@ -2476,7 +2476,7 @@ status = "draft — needs native review"
"view.contextMenu" = "Mostrar menú contextual"
"view.design" = "Panel de diseño"
"view.designPrototype" = "Alternar entre Diseño y Prototipo"
-"view.devMode" = "Modo desarrollo"
+"view.devMode" = "Modo de desarrollo"
"view.frameOutlines" = "Contornos de marcos"
"view.get" = "Obtener vista"
"view.layers" = "Panel de capas"
diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml
index 2f5db319a..88fb21e06 100644
--- a/crates/ui-egui/locales/fi.toml
+++ b/crates/ui-egui/locales/fi.toml
@@ -439,9 +439,9 @@ status = "draft — needs native review"
"Detach styles" = "Irrota tyylit"
"Detach variable" = "Irrota muuttuja"
"Details" = "Tiedot"
-"Dev Mode" = "Kehittäjätila"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Kehittäjätilassa voi vain tarkastella: vaihda Suunnittelu-tilaan (Shift+D) muokataksesi"
"Dev resources" = "Kehittäjäresurssit"
+"Developer Mode" = "Kehittäjätila"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "Kehittäjätilassa voi vain tarkastella: vaihda Suunnittelu-tilaan (Shift+D) muokataksesi"
"Development" = "Kehitys"
"Device" = "Laite"
"Device at 100%" = "Laite koossa 100 %"
diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml
index d47ad9597..e40b3a060 100644
--- a/crates/ui-egui/locales/fr.toml
+++ b/crates/ui-egui/locales/fr.toml
@@ -415,9 +415,9 @@ status = "draft — needs native review"
"Detach styles" = "Détacher les styles"
"Detach variable" = "Détacher la variable"
"Details" = "Détails"
-"Dev Mode" = "Mode Dev"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Le mode Dev sert seulement à inspecter : passez en Design (Shift+D) pour modifier"
"Dev resources" = "Ressources de développement"
+"Developer Mode" = "Mode développeur"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "Le mode développeur sert seulement à inspecter : passez en Design (Shift+D) pour modifier"
"Development" = "Développement"
"Device" = "Appareil"
"Device at 100%" = "Appareil à 100 %"
@@ -2413,7 +2413,7 @@ status = "draft — needs native review"
"view.contextMenu" = "Afficher le menu contextuel"
"view.design" = "Panneau Design"
"view.designPrototype" = "Basculer entre Design et Prototype"
-"view.devMode" = "Mode Dev"
+"view.devMode" = "Mode développeur"
"view.frameOutlines" = "Contours des cadres"
"view.get" = "Obtenir l’affichage"
"view.layers" = "Panneau Calques"
diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml
index cbebaf1c4..59dbd492c 100644
--- a/crates/ui-egui/locales/id.toml
+++ b/crates/ui-egui/locales/id.toml
@@ -419,8 +419,8 @@ status = "draft — needs native review"
"Detach styles" = "Lepas gaya"
"Detach variable" = "Lepas variabel"
"Details" = "Detail"
-"Dev Mode" = "Dev Mode"
"Dev resources" = "Sumber daya dev"
+"Developer Mode" = "Mode Pengembang"
"Development" = "Pengembangan"
"Device" = "Perangkat"
"Device at 100%" = "Perangkat pada 100%"
@@ -2367,7 +2367,7 @@ status = "draft — needs native review"
"view.contextMenu" = "Tampilkan menu konteks"
"view.design" = "Panel desain"
"view.designPrototype" = "Beralih antara Desain dan Prototype"
-"view.devMode" = "Dev Mode"
+"view.devMode" = "Mode Pengembang"
"view.frameOutlines" = "Outline frame"
"view.get" = "Ambil tampilan"
"view.layers" = "Panel layer"
diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml
index 364aeedf8..f61ee86fe 100644
--- a/crates/ui-egui/locales/it.toml
+++ b/crates/ui-egui/locales/it.toml
@@ -398,8 +398,8 @@ status = "draft — needs native review"
"Detach styles" = "Scollega stili"
"Detach variable" = "Scollega variabile"
"Details" = "Dettagli"
-"Dev Mode" = "Modalità sviluppo"
"Dev resources" = "Risorse per sviluppatori"
+"Developer Mode" = "Modalità sviluppo"
"Development" = "Sviluppo"
"Device" = "Dispositivo"
"Device at 100%" = "Dispositivo al 100%"
diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml
index 5a8918a20..d440e910f 100644
--- a/crates/ui-egui/locales/ja.toml
+++ b/crates/ui-egui/locales/ja.toml
@@ -413,9 +413,9 @@ status = "draft — needs native review"
"Detach styles" = "スタイルを切り離す"
"Detach variable" = "変数を切り離す"
"Details" = "詳細"
-"Dev Mode" = "開発モード"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "開発モードは検査専用です。編集するにはデザイン (Shift+D) に切り替えてください"
"Dev resources" = "開発リソース"
+"Developer Mode" = "開発者モード"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "開発者モードは検査専用です。編集するにはデザイン (Shift+D) に切り替えてください"
"Development" = "開発"
"Device" = "デバイス"
"Device at 100%" = "デバイス(100%)"
@@ -2391,7 +2391,7 @@ status = "draft — needs native review"
"view.contextMenu" = "コンテキストメニューを表示"
"view.design" = "デザインパネル"
"view.designPrototype" = "デザインとプロトタイプを切り替え"
-"view.devMode" = "開発モード"
+"view.devMode" = "開発者モード"
"view.frameOutlines" = "フレームのアウトライン"
"view.get" = "ビューを取得"
"view.layers" = "レイヤーパネル"
diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml
index 2af58f385..4c6764afa 100644
--- a/crates/ui-egui/locales/ka.toml
+++ b/crates/ui-egui/locales/ka.toml
@@ -426,9 +426,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Detach styles" = "სტილების გამოყოფა"
"Detach variable" = "ცვლადის გამოყოფა"
"Details" = "დეტალები"
-"Dev Mode" = "დეველოპერის რეჟიმი"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "დეველოპერის რეჟიმში მხოლოდ ინსპექტირებაა შესაძლებელი: რედაქტირებისთვის გადაერთეთ დიზაინზე (Shift+D)"
"Dev resources" = "დეველოპერის რესურსები"
+"Developer Mode" = "დეველოპერის რეჟიმი"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "დეველოპერის რეჟიმში მხოლოდ ინსპექტირებაა შესაძლებელი: რედაქტირებისთვის გადაერთეთ დიზაინზე (Shift+D)"
"Development" = "დეველოპმენტი"
"Device" = "მოწყობილობა"
"Device at 100%" = "მოწყობილობა 100%-ზე"
diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml
index ebea678d1..b4c960fbc 100644
--- a/crates/ui-egui/locales/ko.toml
+++ b/crates/ui-egui/locales/ko.toml
@@ -397,9 +397,9 @@ status = "draft — needs native review"
"Detach styles" = "스타일 분리"
"Detach variable" = "변수 분리"
"Details" = "세부 정보"
-"Dev Mode" = "개발 모드"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "개발 모드는 검사 전용입니다. 편집하려면 디자인(Shift+D)으로 전환하세요"
"Dev resources" = "개발 리소스"
+"Developer Mode" = "개발자 모드"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "개발자 모드는 검사 전용입니다. 편집하려면 디자인(Shift+D)으로 전환하세요"
"Development" = "개발"
"Device" = "기기"
"Device at 100%" = "기기 100%"
@@ -2423,7 +2423,7 @@ status = "draft — needs native review"
"view.contextMenu" = "컨텍스트 메뉴 표시"
"view.design" = "디자인 패널"
"view.designPrototype" = "디자인과 프로토타입 전환"
-"view.devMode" = "개발 모드"
+"view.devMode" = "개발자 모드"
"view.frameOutlines" = "프레임 윤곽선"
"view.get" = "보기 가져오기"
"view.layers" = "레이어 패널"
diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml
index e43d40b5e..2d6816ab6 100644
--- a/crates/ui-egui/locales/nl.toml
+++ b/crates/ui-egui/locales/nl.toml
@@ -417,8 +417,8 @@ status = "draft — needs native review"
"Detach styles" = "Stijlen loskoppelen"
"Detach variable" = "Variabele loskoppelen"
"Details" = "Details"
-"Dev Mode" = "Dev Mode"
"Dev resources" = "Dev-bronnen"
+"Developer Mode" = "Ontwikkelaarsmodus"
"Development" = "Ontwikkeling"
"Device" = "Apparaat"
"Device at 100%" = "Apparaat op 100%"
@@ -1602,7 +1602,7 @@ status = "draft — needs native review"
"Wedge" = "Wedge"
"Weekly planner" = "Weekplanner"
"Weight" = "Dikte"
-"What changed since it was marked ready for dev" = "Wat er veranderd is sinds het is gemarkeerd als klaar voor Dev Mode"
+"What changed since it was marked ready for dev" = "Wat er veranderd is sinds het is gemarkeerd als klaar voor ontwikkeling"
"What is wrong with this file? The server's operator gets your report." = "Wat klopt er niet aan dit bestand? Je melding gaat naar de beheerder van de server."
"What it's for" = "Waarvoor het dient"
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Vanwaar dit is veranderd, is niet bekend: het veranderde voordat FigCraft een kopie bewaarde van dev-klare frames. Los de wijziging op (of markeer het weer als te vergelijken) om vanaf nu te kunnen vergelijken."
@@ -2408,7 +2408,7 @@ status = "draft — needs native review"
"view.contextMenu" = "Contextmenu tonen"
"view.design" = "Ontwerppaneel"
"view.designPrototype" = "Wisselen tussen Design en Prototype"
-"view.devMode" = "Dev Mode"
+"view.devMode" = "Ontwikkelaarsmodus"
"view.frameOutlines" = "Framecontouren"
"view.fullscreen" = "Volledig scherm"
"view.get" = "Weergave ophalen"
diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml
index 68fe136d7..bcc9f0051 100644
--- a/crates/ui-egui/locales/pl.toml
+++ b/crates/ui-egui/locales/pl.toml
@@ -3,6 +3,7 @@
# Wording reviewed and approved by Lucas (@lukasz-sklad).
# Complete Polish localization for FigCraft.
# See docs/translating.md; plural forms follow Polish CLDR integer rules.
+# The Developer Mode label (2026-11-07) is machine-translated and awaits review.
[meta]
name = "Polski"
@@ -416,8 +417,8 @@ name = "Polski"
"Detach styles" = "Odłącz style"
"Detach variable" = "Odłącz zmienną"
"Details" = "Szczegóły"
-"Dev Mode" = "Tryb deweloperski"
"Dev resources" = "Zasoby programistyczne"
+"Developer Mode" = "Tryb deweloperski"
"Development" = "Programowanie"
"Device" = "Urządzenie"
"Device at 100%" = "Urządzenie w 100%"
@@ -2426,7 +2427,7 @@ name = "Polski"
"view.contextMenu" = "Pokaż menu kontekstowe"
"view.design" = "Panel projektu"
"view.designPrototype" = "Przełącz między Projektem a Prototypem"
-"view.devMode" = "Tryb programisty"
+"view.devMode" = "Tryb deweloperski"
"view.frameOutlines" = "Kontury ramek"
"view.fullscreen" = "Tryb pełnoekranowy"
"view.get" = "Pobierz widok"
diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml
index c2ccba835..9108dea4d 100644
--- a/crates/ui-egui/locales/pt-BR.toml
+++ b/crates/ui-egui/locales/pt-BR.toml
@@ -396,9 +396,9 @@ status = "draft — needs native review"
"Detach styles" = "Desvincular estilos"
"Detach variable" = "Desvincular variável"
"Details" = "Detalhes"
-"Dev Mode" = "Modo Dev"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "O modo Dev só inspeciona: mude para Design (Shift+D) para editar"
"Dev resources" = "Recursos de desenvolvimento"
+"Developer Mode" = "Modo do desenvolvedor"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "O modo do desenvolvedor só inspeciona: mude para Design (Shift+D) para editar"
"Development" = "Desenvolvimento"
"Device" = "Dispositivo"
"Device at 100%" = "Dispositivo a 100%"
@@ -2422,7 +2422,7 @@ status = "draft — needs native review"
"view.contextMenu" = "Mostrar menu de contexto"
"view.design" = "Painel de design"
"view.designPrototype" = "Alternar entre Design e Protótipo"
-"view.devMode" = "Modo Dev"
+"view.devMode" = "Modo do desenvolvedor"
"view.frameOutlines" = "Contornos dos quadros"
"view.get" = "Obter visualização"
"view.layers" = "Painel de camadas"
diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml
index 01e1d86ac..36e9abeaa 100644
--- a/crates/ui-egui/locales/ru.toml
+++ b/crates/ui-egui/locales/ru.toml
@@ -1,8 +1,9 @@
# FigCraft interface strings: Russian.
# Wording reviewed and approved by VanTan.
# Strings added since that review (2026-10-20) are a machine draft awaiting review.
-# Keep Frame, Dev Mode and Smart animate as familiar tool names.
+# Keep Frame and Smart animate as familiar tool names.
# See docs/translating.md; plural forms follow Russian integer rules.
+# The Developer Mode label (2026-11-07) is machine-translated and awaits review.
[meta]
name = "Русский"
@@ -416,8 +417,8 @@ name = "Русский"
"Detach styles" = "Отвязать стили"
"Detach variable" = "Отвязать переменную"
"Details" = "Подробности"
-"Dev Mode" = "Dev Mode"
"Dev resources" = "Ресурсы для разработки"
+"Developer Mode" = "Режим разработчика"
"Development" = "Разработка"
"Device" = "Устройство"
"Device at 100%" = "Устройство в масштабе 100%"
@@ -2427,7 +2428,7 @@ name = "Русский"
"view.contextMenu" = "Показать контекстное меню"
"view.design" = "Панель дизайна"
"view.designPrototype" = "Переключить дизайн/прототип"
-"view.devMode" = "Dev Mode"
+"view.devMode" = "Режим разработчика"
"view.frameOutlines" = "Контуры Frame"
"view.get" = "Получить вид"
"view.layers" = "Панель слоёв"
diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml
index a94feb2a9..ce02b3f7f 100644
--- a/crates/ui-egui/locales/sv.toml
+++ b/crates/ui-egui/locales/sv.toml
@@ -2,6 +2,7 @@
#
# Written from the English catalog with ordinary Swedish software vocabulary; no other catalog and no
# proprietary localisation was translated from. Brand names and keyboard glyphs stay as they are.
+# The Developer Mode label (2026-11-07) is machine-translated and awaits review.
[meta]
name = "Svenska"
@@ -442,9 +443,9 @@ name = "Svenska"
"Detach styles" = "Koppla bort stilar"
"Detach variable" = "Koppla bort variabel"
"Details" = "Detaljer"
-"Dev Mode" = "Utvecklarläge"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Utvecklarläget kan bara granska: växla till Design (Shift+D) för att redigera"
"Dev resources" = "Utvecklarresurser"
+"Developer Mode" = "Utvecklarläge"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "Utvecklarläget kan bara granska: växla till Design (Shift+D) för att redigera"
"Development" = "Utveckling"
"Device" = "Enhet"
"Device at 100%" = "Enhet i 100 %"
diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml
index 81b581ce0..f4662a2b8 100644
--- a/crates/ui-egui/locales/th.toml
+++ b/crates/ui-egui/locales/th.toml
@@ -414,9 +414,9 @@ status = "draft — needs native review"
"Detach styles" = "ถอดสไตล์"
"Detach variable" = "ถอดตัวแปร"
"Details" = "รายละเอียด"
-"Dev Mode" = "Dev Mode"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Dev Mode ใช้ตรวจสอบเท่านั้น: สลับไปที่ดีไซน์ (Shift+D) เพื่อแก้ไข"
"Dev resources" = "แหล่งข้อมูลสำหรับนักพัฒนา"
+"Developer Mode" = "โหมดนักพัฒนา"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "โหมดนักพัฒนาใช้ตรวจสอบเท่านั้น: สลับไปที่ดีไซน์ (Shift+D) เพื่อแก้ไข"
"Development" = "การพัฒนา"
"Device" = "อุปกรณ์"
"Device at 100%" = "อุปกรณ์ที่ 100%"
@@ -2385,7 +2385,7 @@ status = "draft — needs native review"
"view.contextMenu" = "แสดงเมนูบริบท"
"view.design" = "แผงดีไซน์"
"view.designPrototype" = "สลับระหว่างดีไซน์และโปรโตไทป์"
-"view.devMode" = "Dev Mode"
+"view.devMode" = "โหมดนักพัฒนา"
"view.frameOutlines" = "เค้าร่างเฟรม"
"view.get" = "ดึงมุมมอง"
"view.layers" = "แผงเลเยอร์"
diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml
index b07d25462..b98f8389b 100644
--- a/crates/ui-egui/locales/tr.toml
+++ b/crates/ui-egui/locales/tr.toml
@@ -5,6 +5,7 @@
# address the user politely (-in/-ın: "seçin", "tıklayın"). The percent sign goes before the number
# (%50). Turkish nouns stay singular after a count, so plural entries give only `other`. See
# docs/translating.md.
+# The Developer Mode label (2026-11-07) is machine-translated and awaits review.
[meta]
name = "Türkçe"
@@ -409,8 +410,8 @@ name = "Türkçe"
"Detach styles" = "Stilleri ayır"
"Detach variable" = "Değişkeni ayır"
"Details" = "Ayrıntılar"
-"Dev Mode" = "Geliştirici Modu"
"Dev resources" = "Geliştirici kaynakları"
+"Developer Mode" = "Geliştirici Modu"
"Development" = "Geliştirme"
"Device" = "Cihaz"
"Device at 100%" = "Cihaz %100'de"
diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml
index 9a4d600fe..5bd9cf7ff 100644
--- a/crates/ui-egui/locales/vi.toml
+++ b/crates/ui-egui/locales/vi.toml
@@ -399,8 +399,8 @@ status = "draft — needs native review"
"Detach styles" = "Tách các kiểu"
"Detach variable" = "Tách biến"
"Details" = "Chi tiết"
-"Dev Mode" = "Dev Mode"
"Dev resources" = "Tài nguyên dev"
+"Developer Mode" = "Chế độ nhà phát triển"
"Development" = "Phát triển"
"Device" = "Thiết bị"
"Device at 100%" = "Thiết bị ở mức 100%"
@@ -2306,7 +2306,7 @@ status = "draft — needs native review"
"view.contextMenu" = "Hiện trình đơn ngữ cảnh"
"view.design" = "Bảng Thiết kế"
"view.designPrototype" = "Chuyển đổi giữa Thiết kế và Nguyên mẫu"
-"view.devMode" = "Dev Mode"
+"view.devMode" = "Chế độ nhà phát triển"
"view.frameOutlines" = "Đường viền khung"
"view.fullscreen" = "Toàn màn hình"
"view.get" = "Lấy khung nhìn"
diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml
index b48345acc..f78f0d938 100644
--- a/crates/ui-egui/locales/zh-Hans.toml
+++ b/crates/ui-egui/locales/zh-Hans.toml
@@ -401,9 +401,9 @@ status = "draft — needs native review"
"Detach styles" = "分离样式"
"Detach variable" = "分离变量"
"Details" = "详细"
-"Dev Mode" = "开发模式"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "开发模式仅用于检查:切换到设计 (Shift+D) 进行编辑"
"Dev resources" = "开发资源"
+"Developer Mode" = "开发者模式"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "开发者模式仅用于检查:切换到设计 (Shift+D) 进行编辑"
"Development" = "开发"
"Device" = "设备"
"Device at 100%" = "设备 100%"
@@ -2322,7 +2322,7 @@ status = "draft — needs native review"
"view.contextMenu" = "显示上下文菜单"
"view.design" = "设计面板"
"view.designPrototype" = "在设计和原型之间切换"
-"view.devMode" = "开发模式"
+"view.devMode" = "开发者模式"
"view.frameOutlines" = "画框轮廓"
"view.get" = "获取视图"
"view.layers" = "图层面板"
diff --git a/crates/ui-egui/locales/zh-Hant.toml b/crates/ui-egui/locales/zh-Hant.toml
index f14698b00..a1179aa0c 100644
--- a/crates/ui-egui/locales/zh-Hant.toml
+++ b/crates/ui-egui/locales/zh-Hant.toml
@@ -453,9 +453,9 @@ status = "draft — needs native review"
"Detach styles" = "分離樣式"
"Detach variable" = "分離變數"
"Details" = "詳細資料"
-"Dev Mode" = "Dev Mode"
-"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Dev Mode 僅供檢視:切換至「設計」(Shift+D) 以編輯"
"Dev resources" = "開發資源"
+"Developer Mode" = "開發者模式"
+"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "開發者模式僅供檢視:切換至「設計」(Shift+D) 以編輯"
"Development" = "開發"
"Device" = "裝置"
"Device at 100%" = "裝置 100%"
@@ -2572,7 +2572,7 @@ status = "draft — needs native review"
"view.contextMenu" = "顯示內容選單"
"view.design" = "設計面板"
"view.designPrototype" = "在設計與原型之間切換"
-"view.devMode" = "Dev Mode"
+"view.devMode" = "開發者模式"
"view.frameOutlines" = "框架外框"
"view.fullscreen" = "全螢幕"
"view.get" = "取得檢視"
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
index 1290f4d5b..f0513520b 100644
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -358,11 +358,7 @@ impl FigApp {
// each side of the panel (240 pt by default). The edge is drawn in `right_panel`.
.frame(egui::Frame::NONE.fill(t.panel))
.show(ui, |ui| {
- let label = match self.ui.right_tab {
- RightTab::Design => i18n::tr("Design"),
- RightTab::Prototype => i18n::tr("Prototype"),
- RightTab::Inspect => i18n::tr("Dev Mode"),
- };
+ let label = right_tab_label(self.ui.right_tab);
a11y::region(&mut self.ui.nav, ui, a11y::Region::Properties, egui::accesskit::Role::Pane, label);
right::right_panel(self, ui)
});
@@ -443,3 +439,12 @@ impl FigApp {
self.observe_ime(&ctx, !typing && !self.field_focus && self.ui.palette.is_none());
}
}
+
+/// The right panel's tab name, which its accessibility region reads.
+pub(crate) fn right_tab_label(tab: RightTab) -> &'static str {
+ match tab {
+ RightTab::Design => i18n::tr("Design"),
+ RightTab::Prototype => i18n::tr("Prototype"),
+ RightTab::Inspect => i18n::tr("Developer Mode"),
+ }
+}
diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs
index 18d888c82..251ff25e6 100644
--- a/crates/ui-egui/src/canvas.rs
+++ b/crates/ui-egui/src/canvas.rs
@@ -235,7 +235,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
let label = if documents {
tr("Drop to open")
} else if app.ui.right_tab == crate::RightTab::Inspect {
- tr("Dev Mode only inspects: switch to Design (Shift+D) to edit")
+ tr("Developer Mode only inspects: switch to Design (Shift+D) to edit")
} else {
tr("Drop to import")
};
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index ee9f39e2d..925b1fc39 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -1062,7 +1062,7 @@ fn ui_key(app: &mut FigApp, ctx: &Context, p: &Value) -> Result {
};
// Dev Mode only inspects, as the window's key path says.
if crate::menus::dev_mode_refuses(app, cmd) {
- return Err("Dev Mode only inspects: switch to Design to edit".into());
+ return Err("Developer Mode only inspects: switch to Design to edit".into());
}
app.session.execute(cmd, ¶ms).map_err(|e| e.to_string())
}
diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs
index b7c7a755c..688f01e90 100644
--- a/crates/ui-egui/src/devmode_tail_tests.rs
+++ b/crates/ui-egui/src/devmode_tail_tests.rs
@@ -319,7 +319,7 @@ fn drop_on_canvas(u: &mut crate::layers_drag_tests::Ui, names: &[&str], bytes: &
#[test]
fn dev_mode_imports_no_dropped_file_into_the_design_and_says_why() {
let svg = br#""#;
- let refusal = "Dev Mode only inspects: switch to Design (Shift+D) to edit";
+ let refusal = "Developer Mode only inspects: switch to Design (Shift+D) to edit";
let mut u = crate::layers_drag_tests::Ui::new(Session::new());
let layers = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(u.app.session.page().unwrap()).unwrap().children.len();
u.app.ui.right_tab = crate::RightTab::Inspect;
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index 11c96da74..60c68f526 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -184,6 +184,8 @@ mod mirror_tests;
#[cfg(test)]
mod missing_fonts_tests;
#[cfg(test)]
+mod naming_tests;
+#[cfg(test)]
mod online_tests;
#[cfg(test)]
mod open_issues_tests;
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 98fd881d1..a5361778c 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -53,7 +53,7 @@ pub const UI_COMMANDS: &[&str] = &[
pub fn dev_mode_refuses(app: &mut FigApp, id: &str) -> bool {
let refused = app.ui.right_tab == crate::RightTab::Inspect && !figcraft_engine::devinspect::dev_mode_allows(id);
if refused {
- app.toast(tr("Dev Mode only inspects: switch to Design (Shift+D) to edit"));
+ app.toast(tr("Developer Mode only inspects: switch to Design (Shift+D) to edit"));
}
refused
}
diff --git a/crates/ui-egui/src/menus/palette.rs b/crates/ui-egui/src/menus/palette.rs
index 69c2408cf..7b3697660 100644
--- a/crates/ui-egui/src/menus/palette.rs
+++ b/crates/ui-egui/src/menus/palette.rs
@@ -242,6 +242,19 @@ fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow, highlighted: bool) -> egui
}
/// Whether `label` (or `extra`) matches the query, in any case.
+/// What a search reads besides the label in the UI's language: the English label, the id, and a
+/// name the feature had before it was renamed ([`figcraft_engine::catalog::SEARCH_ALIASES`]).
+fn search_text(english: &str, id: &str) -> String {
+ let mut text = format!("{english} {id}");
+ for (alias_id, alias) in figcraft_engine::catalog::SEARCH_ALIASES {
+ if *alias_id == id {
+ text.push(' ');
+ text.push_str(alias);
+ }
+ }
+ text
+}
+
fn matches(q: &str, label: &str, extra: &str) -> bool {
q.is_empty() || label.to_lowercase().contains(q) || extra.to_lowercase().contains(q)
}
@@ -295,7 +308,7 @@ pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec)> {
} else if menu_labels.contains(&same) {
continue;
}
- if matches(&ql, &label, &format!("{english} {id}")) {
+ if matches(&ql, &label, &search_text(english, id)) {
hits.push((group, command_row(id, label.into_owned())));
}
}
diff --git a/crates/ui-egui/src/naming_tests.rs b/crates/ui-egui/src/naming_tests.rs
new file mode 100644
index 000000000..757a542c8
--- /dev/null
+++ b/crates/ui-egui/src/naming_tests.rs
@@ -0,0 +1,101 @@
+//! The handoff mode is named "Developer Mode" in every label a person reads. Figma holds a US
+//! registration for DEV MODE (US 7574348) and sent a cease-and-desist over it in 2025, so neither
+//! our labels nor a translation may call it that again.
+//!
+//! Identifiers keep their names (`view.devMode`, `devMode.*`, `dev_mode`, the module and file
+//! names), and the Actions palette keeps the old name as a search alias so people who know it still
+//! find the command (`palette_tests::the_old_name_still_finds_the_mode`).
+//!
+//! The labels are read where they are published: the engine's command labels and parameter docs,
+//! the menu catalog, and the baked-in catalogs. A `tr("Dev Mode")` added to the code would need an
+//! English entry, which this rejects.
+
+use figcraft_engine::catalog::MENU;
+
+use crate::i18n::{LANGS, Text};
+
+/// The retired name, as a label would read it.
+const RETIRED: &str = "Dev Mode";
+
+fn is_word(c: char) -> bool {
+ c.is_alphanumeric() || c == '_'
+}
+
+/// `text` reads the retired name: whole words, so "Developer Mode" and "devMode" don't.
+fn retired(text: &str) -> bool {
+ text.match_indices(RETIRED)
+ .any(|(at, _)| !text[..at].chars().next_back().is_some_and(is_word) && !text[at + RETIRED.len()..].chars().next().is_some_and(is_word))
+}
+
+/// The strings one catalog entry shows.
+fn entry_texts(text: &Text) -> Vec<&str> {
+ match text {
+ Text::Plain(value) => vec![*value],
+ Text::Plural(forms) => [forms.one, forms.few, forms.many].into_iter().flatten().chain([forms.other]).collect(),
+ }
+}
+
+/// Every label the app publishes, with where it comes from.
+fn labels() -> Vec<(String, &'static str)> {
+ let mut out: Vec<(String, &'static str)> =
+ figcraft_engine::all_commands().iter().flat_map(|c| [(format!("{} label", c.id), c.label), (format!("{} params", c.id), c.params)]).collect();
+ out.extend(MENU.iter().map(|m| (format!("menu {}", m.id), m.label)));
+ out
+}
+
+#[test]
+fn command_labels_and_parameter_docs_read_developer_mode() {
+ let bad: Vec = labels().into_iter().filter(|(_, text)| retired(text)).map(|(what, text)| format!("{what}: {text:?}")).collect();
+ assert!(bad.is_empty(), "a label still names the retired mode:\n{}", bad.join("\n"));
+ let id = figcraft_engine::find_command("view.devMode").expect("the mode's command exists");
+ assert_eq!(id.label, "Developer Mode", "the mode's command label");
+}
+
+#[test]
+fn the_catalogs_name_developer_mode() {
+ let mut bad = Vec::new();
+ for lang in LANGS {
+ for (key, text) in lang.ui {
+ if retired(key) {
+ bad.push(format!("{}: key {key:?}", lang.code));
+ }
+ for value in entry_texts(text).into_iter().filter(|value| retired(value)) {
+ bad.push(format!("{}: {key:?} = {value:?}", lang.code));
+ }
+ }
+ for (id, text) in lang.commands {
+ for value in entry_texts(text).into_iter().filter(|value| retired(value)) {
+ bad.push(format!("{}: {id} = {value:?}", lang.code));
+ }
+ }
+ for (id, text) in lang.menu {
+ for value in entry_texts(text).into_iter().filter(|value| retired(value)) {
+ bad.push(format!("{}: {id} = {value:?}", lang.code));
+ }
+ }
+ }
+ assert!(bad.is_empty(), "a catalog still names the retired mode:\n{}", bad.join("\n"));
+}
+
+#[test]
+fn the_inspect_tab_and_its_refusal_read_developer_mode() {
+ assert_eq!(crate::app::right_tab_label(crate::RightTab::Inspect), "Developer Mode", "the panel's name");
+ // The engine's refusal on a board, and the toast the UI shows where the mode refuses an edit.
+ let board = figcraft_engine::board::NOT_ON_BOARDS;
+ assert!(!retired(board), "{board:?}");
+ assert_eq!(crate::i18n::LANGS.first().map(|l| l.code), Some("en"), "English is baked in first");
+ assert!(
+ LANGS.first().is_some_and(|l| l.ui.iter().any(|(k, _)| *k == "Developer Mode only inspects: switch to Design (Shift+D) to edit")),
+ "the refusal toast is in the catalog"
+ );
+}
+
+#[test]
+fn the_guard_reads_words_not_identifiers() {
+ for label in ["Dev Mode", "Dev Mode on/off", "the Dev Mode panel"] {
+ assert!(retired(label), "{label}");
+ }
+ for label in ["Developer Mode", "Developer Mode on/off", "view.devMode", "devMode.addMeasurement", "Development", "Ready for dev"] {
+ assert!(!retired(label), "{label}");
+ }
+}
diff --git a/crates/ui-egui/src/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs
index 6f456322e..eb395e818 100644
--- a/crates/ui-egui/src/palette_tests.rs
+++ b/crates/ui-egui/src/palette_tests.rs
@@ -50,6 +50,21 @@ fn query_filters_to_matches() {
assert!(crate::menus::command_sections("zzz-no-such-action").iter().all(|(_, r)| r.is_empty()));
}
+/// The handoff mode is "Developer Mode", and the name it had before is still a search term: a
+/// person who knows "Dev Mode" finds the command, and its row reads the new name.
+#[test]
+fn the_old_name_still_finds_the_mode() {
+ let rows: Vec<(String, String)> =
+ crate::menus::command_sections("dev mode").iter().flat_map(|(_, rows)| rows.iter().map(|r| (r.key.clone(), r.label.clone()))).collect();
+ assert!(rows.iter().any(|(key, _)| key == "command:view.devMode"), "'dev mode' finds the mode: {rows:?}");
+ assert!(
+ rows.iter().filter(|(key, _)| key == "command:view.devMode").all(|(_, label)| label == "Developer Mode"),
+ "and reads the new name: {rows:?}"
+ );
+ // Without the alias the words don't match the label or the id.
+ assert!(!rows.iter().any(|(key, label)| key != "command:view.devMode" && label.contains("Developer Mode")), "only that row: {rows:?}");
+}
+
#[test]
fn gone_entries_drop_out_of_recents() {
let u = Ui::new(Session::new());
diff --git a/docs/architecture.md b/docs/architecture.md
index 18d7ac550..6ef06da49 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -27,7 +27,7 @@ replaced.
| L4 | `figcraft-svg` | ~3.7k | SVG export and import (usvg) |
| L4 | `figcraft-pdf` | ~2.3k | PDF export (krilla) with print options |
| L4 | `figcraft-vectordrawable` | ~1.2k | Android Vector Drawable (XML) export: baked path data, gradients through `aapt:attr`, clip groups, compatibility modes and warnings |
-| L5 | `figcraft-engine` | ~39.3k | Sessions, undo history (`Arc` snapshots), the command registry (`cmd/*`, `spec!`), pointer tools and vector edit mode, snapping and measuring, components sync, libraries, prototype runtime, Dev Mode code generation, the menu catalog (`catalog.rs`) and the keymap (`keymap.rs`), a panic guard |
+| L5 | `figcraft-engine` | ~39.3k | Sessions, undo history (`Arc` snapshots), the command registry (`cmd/*`, `spec!`), pointer tools and vector edit mode, snapping and measuring, components sync, libraries, prototype runtime, Developer Mode code generation, the menu catalog (`catalog.rs`) and the keymap (`keymap.rs`), a panic guard |
| L6 | `figcraft-ui-egui` | ~31.9k | The UI3-style frontend: canvas (render worker), toolbar, layers, Design/Prototype/Inspect panels, colour picker, menus, dialogs, presenter, comments, accessibility (AccessKit), the control channel server |
| L6 | `figcraft-mcp` | ~2.4k | MCP server (JSON-RPC over stdio), headless or bridged to a running app |
| L6 | `figcraft-collab-net` | ~3.0k | Live sessions over the network: the WebSocket transport (tungstenite, a reader and a writer thread per connection), the hub that runs a collaboration core server per open file for the session host the app embeds and for `figcraft-server` (behind a `Backend`), the joining and managing clients (publishing), invite links, presence relay and limits; the browser client; the only crate allowed networking dependencies |
diff --git a/docs/boards-parity.md b/docs/boards-parity.md
index 45f47a4e7..16456492c 100644
--- a/docs/boards-parity.md
+++ b/docs/boards-parity.md
@@ -29,7 +29,7 @@ all end with the same geometry.
| Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) |
| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Actions, then the templates and the Design panel's buttons — on the same 32 pt grid as a design file's (no Resources button, as there) |
| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL); for connectors straight/elbow/curved, the start and end points (none, round, square, line arrow, triangle arrow, reversed triangle, circle, diamond), solid or dashed, the weight, and any colour in the colour picker beside the board colours (#1596); each control says what it is on hover; the Design panel opens with its button or View ▸ Design panel |
-| Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why |
+| Prototyping and Developer Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why |
| Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it |
| Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks |
| Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar |
diff --git a/docs/cli.md b/docs/cli.md
index 292268331..a83fbe5c3 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -126,7 +126,7 @@ page 1 "Screens": 380 nodes, 4 top-level
written from public format notes, including variables with their modes and bindings and shared
styles with their layer links. Checked by opening it again in FigCraft and, on the example
documents and small test files, in other software; other apps may not open every file. What was
- left out (comments, Dev Mode data, some bindings and settings…) is printed as `note:` lines;
+ left out (comments, Developer Mode data, some bindings and settings…) is printed as `note:` lines;
`docs/file-format-parity.md` lists what was checked and what is unchecked.
- `--format xml` (or an `` ending in `.xml`) writes an Android Vector Drawable, e.g.
`figcraft-cli export app.figcraft --node Icon -o ic_icon.xml`. What the format can't draw is
@@ -299,7 +299,7 @@ lists); it is never printed. The bundle is checked before it is sent and again b
### `connect publish [--mappings FILE] [--dry-run]`
Publishes a code repository's component mappings to a design file: each link in the mappings file
-(default `figcraft.connect.json` in the current folder, the file Dev Mode's **Export code
+(default `figcraft.connect.json` in the current folder, the file Developer Mode's **Export code
connections** and `codeConnect.export` write) connects every component or component set of that
name to its code component for a language, replacing its link for that language, and the file is
saved in place, written whole or not at all, with its version history. Links that don't fit (no
diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md
index 65545447d..4fb173ccc 100644
--- a/docs/collaboration-parity.md
+++ b/docs/collaboration-parity.md
@@ -17,8 +17,8 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address, with an edit code and a view-only code, picked before or while sharing, whose holders see every change and can't make one; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 |
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
-| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
-| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. Auto layout settings (a frame's and a child's) merge field by field, each field changed both ways a conflict of its own (`/layout.padding`), and sizes and places auto layout works out (hugging, filling, a child's place) or that an instance takes from its main are no conflict: the merged file lays them out again. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
+| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Developer Mode compares a ready-for-dev layer with its state when marked) | — |
+| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Developer Mode resources merge item by item. Auto layout settings (a frame's and a child's) merge field by field, each field changed both ways a conflict of its own (`/layout.padding`), and sizes and places auto layout works out (hugging, filling, a child's place) or that an instance takes from its main are no conflict: the merged file lays them out again. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
| Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 |
| Notifications, activity | yes | no | with sharing |
diff --git a/docs/components-parity.md b/docs/components-parity.md
index d6b1ee5e6..8fcaea78a 100644
--- a/docs/components-parity.md
+++ b/docs/components-parity.md
@@ -87,7 +87,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects//`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. |
| 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged |
| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
-| 2026-10-31 | minor | M6: as the help center describes code syntax, Dev Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged |
+| 2026-10-31 | minor | M6: as the help center describes code syntax, Developer Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged |
| 2026-10-31 | minor | M3, gap 10: library analytics over time. A file counts the instances of library components it inserts (placed, created, pasted or duplicated, Alt-dragged copies too; not those nested in another instance) and reports them like its detaches (`ReportInserted`). The server counts what a file's insertion and detach totals grow past their highest in the current week, a Monday at 00:00 UTC, so an undo and a redo count once, and keeps a year of weeks in `actions.json` (`Actions`). `library.usage {days?}` adds `insertions`, `detachments` and `weeks`, and per component `insertions` and `detachments`; Show usage has a 30 days, 90 days or 1 year choice, the period's totals, and each component's counts on a line of their own. The new variants are appended, so older messages keep their bytes; an older server refuses them and Show usage says it doesn't count insertions. A file's first report after this counts its total in that week. Tests: `library_actions_tests`, `inserting_library_instances_counts_them`. Grade unchanged |
| 2026-10-30 | minor | M3, gap 14: a collection of this file can be extended, as the help center shows (right-click a collection, Extend collection); before, only a library's could. `variable.extendCollection {collection, name?}` makes " (extended)" with its modes and one inheriting variable per variable, and the Variables editor's collection menu offers it beside Rename and Delete collection. Edits of the collection it extends reach it at once (values in modes it doesn't override, new variables and modes, renames, mode order), so normalising keeps extensions in step whenever variables change, not only on library updates; undo takes both back. An extended collection extends again (parents settle first; an extension doesn't take its parent's overrides as its own), and a loop of extensions from a hostile file comes apart into plain collections keeping their values. Deleting the parent leaves a plain collection with the values it showed, as for libraries. Tests: `extended_chain_tests` (engine), `variables_rename_tests::a_collection_of_this_file_extends_from_its_context_menu` (headless UI). Grade unchanged |
| 2026-10-30 | minor | Library analytics count detaches: a file that detaches an instance of a library component counts it (kept with the file) and its app reports the counts to the library's server beside its usage; **Show usage** and `library.usage` list them per component and per file (`detached`, `detachesKnown`). Older servers and apps are unaffected: the reports are new messages and the old ones keep their encoding. Still nothing over time. Numbers unchanged |
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
index 819fbcbaf..4835bd1e1 100644
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -194,9 +194,9 @@ layout (including grid `columnTracks` / `rowTracks`), sizing, `minWidth` / `maxW
children), `gridCell` (a grid auto layout child's column, row and spans), clip, text properties,
component/instance links, overrides, `slot` (a slot layer's property name), boundVariables, interactions, exports,
`devStatus` (handoff status), `devChanged` (changed since it was marked ready for dev),
-`annotations` (Dev Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's
+`annotations` (Developer Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's
links to code: `[{language, importPath, name, template}]`), `devResources` (links for developers,
-its own and inherited: `[{name, url, index?, from?}]`), `measurements` (Dev Mode measurements
+its own and inherited: `[{name, url, index?, from?}]`), `measurements` (Developer Mode measurements
starting from the layer: `[{to} | {side}]`), `pluginData` (layer data written with
`node.set {props: {pluginData: {key: value}}}`, pages included: `{key: value}`, values as strings;
only when there is some). The auto layout props
@@ -379,7 +379,7 @@ closes it), `commentDraft` (`{x, y}`: start a new comment there), `commentFilter
(`date|page`), `commentSearch` (the panel's search text), `commentReply` (the open thread's reply
text, focused with the cursor at its end: an `@` there shows the mention picker), `commentEdit` (a
message index of the open thread to edit, 0 for the first; `null` stops), `presentComments` (bool:
-the presenter shows the presented frame's comments, also Shift+C there). Dev Mode: `devCompare` (a
+the presenter shows the presented frame's comments, also Shift+C there). Developer Mode: `devCompare` (a
frame or section id opens the Compare changes view, like `dev.compare {id, show:
true}`; `null` closes it) with `devCompareVersion` (a version id to compare with; `null`: when it
was marked ready for dev), `devCompareMode` (`sideBySide|overlay`) and `devCompareSplit` (the
diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md
index 8ece694a8..87dc3739f 100644
--- a/docs/dev-mode-parity.md
+++ b/docs/dev-mode-parity.md
@@ -1,6 +1,6 @@
# Dev Mode parity
-> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-26 · **Change:** minor (class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-07 · **Change:** patch (the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
**Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect
to the code components of a repository (with their imports, also inside generated layer trees),
@@ -10,6 +10,7 @@ repository, third-party integrations. Remaining: 1–2 h.
| Feature | Status | Notes |
|---|---|---|
+| 2026-11-07 | patch | Naming: the handoff mode is called Developer Mode in the interface, its shortcut row, its toasts, the control channel's refusal, the board refusal, the measurement parameter doc, the `.fig` export note, the CLI help and every catalog (24 languages, translations awaiting native review), since Figma holds a US registration for Dev Mode (7574348); command ids (`view.devMode`, `devMode.*`), preference keys and saved-document fields are unchanged, and the Actions palette still finds the command by the old name |
| 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), Flutter | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup; Flutter is ours too: a `StatelessWidget` for a component, a widget tree for a layer); 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; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index a90038427..dd04fdf75 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -23,7 +23,7 @@ and prototype interactions are still unchecked there.
| `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with generated sticky and shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors (an elbowed connector keeps the path it was saved with, #1910), with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — |
| `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
-| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); image members are `images/`, and a key that can't be one — path text, a space, over 128 chars — is left out with a complaint instead of loaded (`image_tests`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — |
+| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); image members are `images/`, and a key that can't be one — path text, a space, over 128 chars — is left out with a complaint instead of loaded (`image_tests`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Developer Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — |
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
## Import (place / paste / drop)
@@ -135,7 +135,7 @@ scales and empty-style clears; they have not been checked in other software.
missing, or more than 32 variables to read), variable bindings the format has no field for (component
properties, arc fields, font weight: written as the values they give), text styles on part of a
text (written as values), variables' publishing, code syntax and inheritance settings, extended
- collections (written as collections of their own), comments, Dev Mode annotations, statuses,
+ collections (written as collections of their own), comments, Developer Mode annotations, statuses,
resources, measurements and code links, plugin data, ruler
guides, prototype device settings, slot properties and slot content, brush
strokes (written plain), strokes wider on one side of their path (written as filled outlines, or
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index c3490a195..3b48ac54c 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -26,7 +26,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
a missing translation shows English, never a raw key. One English text that needs two
translations takes a context (`"Fill|sizing"`).
- **Strings:** 1,747 UI keys in `en.toml` (42 with plural forms; ~110 are labels from engine and
- document data, such as tool names, blend modes and Dev Mode properties) plus 574 command labels
+ document data, such as tool names, blend modes and Developer Mode properties) plus 574 command labels
and 31 menu wordings by command id: 2,352 entries (counted against `en.toml` and the complete
Spanish catalog's command and menu tables). Command ids, the CLI, MCP and the control
channel stay English. Accessibility names come from the same strings.
@@ -51,7 +51,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
added later shows English until someone translates it, and `cargo xtask i18n-report` (part of
`cargo xtask ci`) lists what each language lacks. Every draft translated every string on
2026-10-12; by 2026-10-17 the strings added since left them at 95–99% (table below).
-- **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values
+- **Not yet localized:** messages the engine writes (errors, a few toasts, Developer Mode property values
such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules
(`trn` takes integers: Czech and Bosnian use one/few/other and Russian one/few/many; French and Brazilian
Portuguese take the singular for 0 as well; Japanese, Korean, Chinese and Vietnamese have one form;
diff --git a/docs/mcp.md b/docs/mcp.md
index aebcbf46d..04b37e1f0 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -373,7 +373,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
| Tool | What it does |
|---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
-| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose, ArkUI or Flutter for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}` or a colour calculation (`{alpha}`, `{mix}`), `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
+| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose, ArkUI or Flutter for a layer; components take their properties as parameters and instances are calls, see Developer Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}` or a colour calculation (`{alpha}`, `{mix}`), `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Developer Mode below)). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`), which keeps one copy of the document however many steps there are (an `edit.undo` step undoes the batch's edits so far together); 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. |
@@ -1179,7 +1179,7 @@ import uses: the layer tree, geometry, paints with image bytes, strokes, effects
runs, vector networks, auto layout, constraints, components, sets, instances with their overrides
and property values, variable collections with their modes, variables and bindings, shared styles
with their layer links, and prototype interactions. `warnings` lists what was left out (comments,
-Dev Mode data, some bindings and settings and the like). Round trips through FigCraft's own
+Developer Mode data, some bindings and settings and the like). Round trips through FigCraft's own
importer are tested, and the example documents and small test files were opened in other software
(`docs/file-format-parity.md` says what was checked); other apps may not open every file. Without
`path` the file comes back as base64 `data`. Check one by opening it again:
@@ -1365,7 +1365,7 @@ branches). Driven with `run_command`:
changes formatted like Inspect (`fill #2563EB → #DC2626`); `document` lists styles, variables,
comments and file settings. A conflict is the same property changed on both sides to different
values (key `/`, e.g. `4/fills`; fills, strokes, effects, layout grids,
- export settings, guides, prototype interactions, annotations and Dev Mode resources compare item
+ export settings, guides, prototype interactions, annotations and Developer Mode resources compare item
by item, so changing different items of one list on each side, or adding items at its end on one
side, merges without a conflict; other lists, such as code links, compare whole; a layer's parent
and position are one property, `parent`; auto layout settings, `layout` and `childLayout`, compare
@@ -1690,7 +1690,7 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.directory.install","params":{"id":"org.figcraft.examples.swatch-grid","confirm":true}}}}
```
-### Dev Mode
+### Developer 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
@@ -1791,11 +1791,11 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
saved with the file and synced; the same two layers are measured once (either way round), a
layer starts at most 64. One whose other layer was deleted or moved to another page isn't listed
or drawn (an undo brings it back); a copy keeps the side measurements and those to layers copied
- with it. In the app, Dev Mode draws them with the hover redlines, and the Inspect tab lists them
+ with it. In the app, Developer Mode draws them with the hover redlines, and the Inspect tab lists them
(with nothing selected, every one on the page), selects a measurement's layer on a click, removes
one with −, and places new ones from its Add measurement menu.
- **Annotations:** notes for developers on any layer, each with optional pinned properties that
- Dev Mode shows with their current values (and the style or variable behind them).
+ Developer Mode shows with their current values (and the style or variable behind them).
`annotation.add {id?, label?, properties?}` (a note, pinned properties or both; returns `{id,
index}`), `annotation.edit {id?, index, label?, properties?}` (`properties` replaces the list),
`annotation.remove {id?, index?}` (no index: all of the layer's), all undoable, and
@@ -1827,9 +1827,9 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
(`changed`: ready for dev and edited since). The Inspect tab sets the status of the selected
frames and, with nothing selected, lists them with a status filter (Ready for dev, Changed,
Completed, Any status; changed ones carry a Changed badge; click one to go to it, or its focus
- button to open it in the focus view). In the app's Dev Mode the canvas only inspects: presses
+ button to open it in the focus view). In the app's Developer Mode the canvas only inspects: presses
select (and pan, and comment), never move, resize, draw or edit text and vectors, and the menus
- and shortcuts refuse commands that edit the design (Dev Mode's own annotations, statuses, dev
+ and shortcuts refuse commands that edit the design (Developer Mode's own annotations, statuses, dev
resources, measurements, code connections and comments still work); choosing a drawing tool in
the toolbar goes back to the design, and the layers panel only selects and expands. Commands sent over MCP or the control channel aren't affected.
- **Compare changes:** when a layer marked Ready for dev first changes (it turns "Changed"), the
diff --git a/docs/plugins.md b/docs/plugins.md
index bc31cca63..ea70c3130 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -256,7 +256,7 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p
| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets), `4` (saving files, widget fields and property menus) or `5` (panels from widgets). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. |
| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"). A plugin directory's entries don't carry it (their wire format stays as it is), so installing any API 4 plugin from one says it may offer files to save. |
| `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). |
-| `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. |
+| `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Developer Mode's Inspect tab. `write`: edits too. |
| `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. |
| `commands` | The menu commands, each `{id, label, params?}`. Without any, the plugin has one, `run`, unless it's a widget (a widget needs no command). |
| `params[]` | `{name, label?, type, default?, options?, min?, max?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean`, `select` (one of `options`, a menu), `list` (one of `options`, shown as a list), `slider` (a number between `min` and `max`, both required, shown as a slider with its value beside it) or `color` (hex, `#RRGGBB` or `#RRGGBBAA`). A default that doesn't fit is dropped. |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 75de5f1af..f2dd92966 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** patch (the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -29,6 +29,7 @@ builders' side-by-side checks with synthetic files; nothing has been run on real
| Workflow | Works end to end? | Evidence | Hours to pass |
|---|---|---|---|
+| 2026-11-07 | patch | Naming: the handoff mode is called Developer Mode in the interface, its shortcut row, its toasts, the control channel's refusal, the board refusal, the measurement parameter doc, the `.fig` export note, the CLI help and every catalog (24 languages, translations awaiting native review), since Figma holds a US registration for Dev Mode (7574348); command ids (`view.devMode`, `devMode.*`), preference keys and saved-document fields are unchanged, and the Actions palette still finds the command by the old name |
| Design screens: frames, shapes, vectors, text, auto layout, styles; save, reopen, export assets | yes | tools and panels in `crates/engine` and `crates/ui-egui`; `.figcraft` round trips (`crates/format/src/large_tests.rs`); PNG/JPG/SVG/PDF export tests | — |
| Build and use a design system: components, variants, properties, variables and modes, libraries | yes | `components_scale_tests.rs` (300 components, 2,000 instances), `library_tests.rs`, `library_variables_tests.rs`; libraries are local files, not shared | — |
| Prototype and present: interactions, flows, overlays, smart animate, variables | yes | `proto_*_tests.rs`, `present_*_tests.rs` | — |
diff --git a/docs/translating.md b/docs/translating.md
index 6eb472281..5f647816b 100644
--- a/docs/translating.md
+++ b/docs/translating.md
@@ -21,7 +21,7 @@ The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin Amer
(`fi`), Bengali (`bn`), Italian (`it`), Vietnamese (`vi`), Thai (`th`) and Bosnian (`bs`), 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
+Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame 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.
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index aa0ef25bd..463be3b47 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -97,7 +97,7 @@ interaction feel not yet compared.
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) |
| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
| Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. Not yet included: per-paragraph settings, text-style support and the `.fig` import/export mapping (#1827). Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` |
-| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Dev Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way (with `text-align: left` beside `rtl`, as Left is absolute on the canvas); `.fig` import and export carry each line's `sourceDirectionality`, read up to the paragraph count. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` |
+| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Developer Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way (with `text-align: left` beside `rtl`, as Left is absolute on the canvas); `.fig` import and export carry each line's `sourceDirectionality`, read up to the paragraph count. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` |
| Text: superscript and subscript | done | `sups` / `subs` use the font's own glyphs where it has the feature and every letter or digit of the run gets one; otherwise the standard glyphs are drawn shrunk and raised or lowered by the font's OS/2 superscript and subscript metrics (0.65 em at 0.35 em up or 0.14 em down without them), and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says ("Figma draws a synthesized version by using the font's standard version of the character, shrinking it, and positioning it accordingly"; mixed glyphs "all update to the synthesized form for consistency"), #1828; tests `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them` |
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) |
| Comment (C), Shift+C to show/hide | done | local only |
@@ -205,7 +205,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. |
| 2026-11-04 | patch | M1, #1852: a press on one of the open gradient's handles or stops on the canvas is the colour picker's own, not a press outside it (Figma keeps the picker and the handles up while a handle or stop is dragged; the stops are made to be dragged: "You can click and drag a stop to change its placement on the gradient", Use gradients as a fill or stroke). The canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once; the handles vanished with it and the drag went on without an undo step of its own. Now the picker stays while a gradient handle or stop drags, and the drag is one undo step; a press elsewhere on the canvas still closes it. A handle drag whose button goes up off the canvas (over a panel or the picker) ends there too, instead of waiting, uncommitted, to move the handle to the next press on the canvas. Tests: `colorpicker_selection_tests::dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open`, `colorpicker_selection_tests::a_gradient_drag_released_off_the_canvas_ends_there`. |
| 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. |
-| 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. |
+| 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Developer Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. |
| 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` |
| 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` |
| 2026-11-02 | patch | M4, #1828: superscript and subscript synthesised where the font has no `sups` / `subs` glyphs (the standard glyph shrunk and shifted by the OS/2 metrics, the usual 0.65 em and 0.35 / 0.14 em offsets without them), and a run mixing available and unavailable glyphs synthesised whole, as Explore text properties says. Tests: `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them`. |
From cf567c3f63993b0ec0aabe122b2fac73b900e6e0 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Mon, 9 Nov 2026 00:51:03 -0500
Subject: [PATCH 041/103] M2: a child dragged in auto layout passes a sibling
when its leading edge reaches the sibling's middle
Within its own row or column, a dragged child's slot follows the box it would fill under the pointer (start bounds plus the unsnapped drag), by the edge facing the drag, not the pointer. Measured in Figma's web app, 2026-11-09. Dragging in from outside, wrapping layouts and grids keep their rules. Several moving layers use their combined box.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 measured and wrote at medium
---
crates/engine/src/al_drag_tests.rs | 53 ++++++++++++++++++++++++++
crates/engine/src/canvas/drop.rs | 40 ++++++++++++++++---
crates/engine/src/canvas/tools/drag.rs | 16 +++++++-
docs/auto-layout-parity.md | 5 ++-
4 files changed, 105 insertions(+), 9 deletions(-)
diff --git a/crates/engine/src/al_drag_tests.rs b/crates/engine/src/al_drag_tests.rs
index 528fdd9d7..23f416481 100644
--- a/crates/engine/src/al_drag_tests.rs
+++ b/crates/engine/src/al_drag_tests.rs
@@ -69,3 +69,56 @@ fn grid_drag_takes_the_cell_under_the_pointer() {
let cl = s.doc().unwrap().node(kids[0]).unwrap().child_layout.clone();
assert_eq!((cl.grid_column, cl.grid_row), (None, None), "one undo step");
}
+
+/// Three 60 x 60 rectangles in a row (or column), gap 20, at 0, 80 and 160 along the flow.
+fn three(s: &mut Session, mode: &str) -> (NodeId, [NodeId; 3]) {
+ let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap());
+ let kids: Vec =
+ (0..3).map(|_| id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 60, "height": 60, "parent": f})).unwrap())).collect();
+ s.execute("layout.add", &json!({"ids": [f]})).unwrap();
+ s.execute("node.set", &json!({"id": f, "props": {"layoutMode": mode, "gap": 20, "padding": 0, "sizingH": "fixed", "sizingV": "fixed"}})).unwrap();
+ s.execute("select.none", &json!({})).unwrap();
+ (f, [kids[0], kids[1], kids[2]])
+}
+
+/// The order after dragging kid `which` by `by` along the flow, grabbed `grab` from its start edge.
+fn dragged_order(mode: &str, which: usize, grab: f64, by: f64) -> Vec {
+ let mut s = Session::new();
+ let (f, kids) = three(&mut s, mode);
+ let vertical = mode == "vertical";
+ let b = s.doc().unwrap().world_bounds(kids[which]).unwrap();
+ let from = if vertical { (b.center().x, b.y0 + grab) } else { (b.x0 + grab, b.center().y) };
+ let to = if vertical { (from.0, from.1 + by) } else { (from.0 + by, from.1) };
+ drag(&mut s, from, to);
+ let order = s.doc().unwrap().node(f).unwrap().children.clone();
+ order.iter().map(|c| kids.iter().position(|k| k == c).unwrap()).collect()
+}
+
+#[test]
+fn a_child_dragged_right_passes_a_sibling_when_its_leading_edge_reaches_the_siblings_middle() {
+ assert_eq!(dragged_order("horizontal", 0, 30.0, 49.0), vec![0, 1, 2]);
+ assert_eq!(dragged_order("horizontal", 0, 30.0, 50.0), vec![1, 0, 2]);
+ assert_eq!(dragged_order("horizontal", 0, 30.0, 129.0), vec![1, 0, 2]);
+ assert_eq!(dragged_order("horizontal", 0, 30.0, 130.0), vec![1, 2, 0]);
+}
+
+#[test]
+fn where_the_child_is_grabbed_does_not_change_its_slot() {
+ assert_eq!(dragged_order("horizontal", 0, 10.0, 49.0), vec![0, 1, 2]);
+ assert_eq!(dragged_order("horizontal", 0, 10.0, 50.0), vec![1, 0, 2]);
+ assert_eq!(dragged_order("horizontal", 0, 10.0, 129.0), vec![1, 0, 2]);
+ assert_eq!(dragged_order("horizontal", 0, 10.0, 130.0), vec![1, 2, 0]);
+}
+
+#[test]
+fn a_child_dragged_left_passes_a_sibling_by_its_left_edge() {
+ assert_eq!(dragged_order("horizontal", 2, 30.0, -49.0), vec![0, 1, 2]);
+ assert_eq!(dragged_order("horizontal", 2, 30.0, -50.0), vec![0, 2, 1]);
+ assert_eq!(dragged_order("horizontal", 2, 30.0, -130.0), vec![2, 0, 1]);
+}
+
+#[test]
+fn a_column_reorders_by_the_bottom_edge() {
+ assert_eq!(dragged_order("vertical", 0, 30.0, 50.0), vec![1, 0, 2]);
+ assert_eq!(dragged_order("vertical", 0, 30.0, 130.0), vec![1, 2, 0]);
+}
diff --git a/crates/engine/src/canvas/drop.rs b/crates/engine/src/canvas/drop.rs
index e46734ac9..7bad66ac3 100644
--- a/crates/engine/src/canvas/drop.rs
+++ b/crates/engine/src/canvas/drop.rs
@@ -20,6 +20,31 @@ pub struct DropSlot {
/// placed child whose centre is past the pointer, and the line to draw there. Hidden children and
/// those ignoring auto layout don't take part.
pub fn auto_layout_slot(doc: &Document, parent: NodeId, p: Point, exclude: &[NodeId]) -> Option<(usize, Option<[f64; 4]>)> {
+ slot_in(doc, parent, p, exclude, None)
+}
+
+/// [`auto_layout_slot`] for layers dragged within their own frame (measured in Figma's web app,
+/// 2026-11-09): the slot follows the leading edge of `moved`, the box the layers would fill under
+/// the pointer, in the direction of `delta` (the drag along the page). Wrapping layouts keep the
+/// pointer rule.
+pub fn auto_layout_slot_within(
+ doc: &Document,
+ parent: NodeId,
+ p: Point,
+ exclude: &[NodeId],
+ moved: figcraft_geom::Rect,
+ delta: Point,
+) -> Option<(usize, Option<[f64; 4]>)> {
+ slot_in(doc, parent, p, exclude, Some((moved, delta)))
+}
+
+fn slot_in(
+ doc: &Document,
+ parent: NodeId,
+ p: Point,
+ exclude: &[NodeId],
+ dragged: Option<(figcraft_geom::Rect, Point)>,
+) -> Option<(usize, Option<[f64; 4]>)> {
let pn = doc.node(parent)?;
let layout = pn.layout.as_ref()?;
let vertical = layout.mode == LayoutMode::Vertical;
@@ -34,11 +59,16 @@ pub fn auto_layout_slot(doc: &Document, parent: NodeId, p: Point, exclude: &[Nod
let (k, line) = wrap_slot(doc, &flow_ids, p, vertical);
return Some((index(k), line));
}
- let pos = if vertical { p.y } else { p.x };
- let k = flow_ids
- .iter()
- .position(|c| doc.world_bounds(*c).is_some_and(|b| pos < if vertical { b.center().y } else { b.center().x }))
- .unwrap_or(flow_ids.len());
+ let mid = |c: &NodeId| doc.world_bounds(*c).map(|b| if vertical { b.center().y } else { b.center().x });
+ let k = if let Some((r, d)) = dragged {
+ let (lo, hi, dd) = if vertical { (r.y0, r.y1, d.y) } else { (r.x0, r.x1, d.x) };
+ // Forward: past every sibling whose middle the trailing edge has reached; backward: past
+ // every one whose middle lies before the leading (low) edge.
+ flow_ids.iter().position(|c| mid(c).is_some_and(|m| if dd > 0.0 { m > hi } else { m >= lo })).unwrap_or(flow_ids.len())
+ } else {
+ let pos = if vertical { p.y } else { p.x };
+ flow_ids.iter().position(|c| mid(c).is_some_and(|m| pos < m)).unwrap_or(flow_ids.len())
+ };
let line = flow_ids.get(k).and_then(|c| doc.world_bounds(*c)).map(|b| line_before(b, vertical, 2.0)).or_else(|| {
flow_ids
.last()
diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs
index 64d48bc86..94f0e654e 100644
--- a/crates/engine/src/canvas/tools/drag.rs
+++ b/crates/engine/src/canvas/tools/drag.rs
@@ -216,6 +216,8 @@ impl Session {
}
}
// Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping.
+ // The pointer's own drag, before snapping: a reorder in auto layout follows it.
+ let raw = d;
let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl;
let mut guides = Vec::new();
if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) {
@@ -268,8 +270,18 @@ impl Session {
// cell) under the pointer, as the frame shows it now. A frame other than the
// one they come from is outlined too.
let cell = if mode == LayoutMode::Grid { crate::drop::grid_cell_at(doc, target, p) } else { None };
- let idx =
- if mode == LayoutMode::Grid { 0 } else { crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i) };
+ // Within the frame they came from, the slot follows the leading edge of the
+ // box they would fill (their combined start bounds moved by the drag), not
+ // the pointer. The preview starts from the document as the drag began.
+ let within = moving.iter().all(|m| doc.node(*m).is_some_and(|n| n.parent == Some(target)));
+ let idx = if mode == LayoutMode::Grid {
+ 0
+ } else if let (true, Some(b)) = (within, doc.bounds_of(&moving)) {
+ let moved = Rect::new(b.x0 + raw.x, b.y0 + raw.y, b.x1 + raw.x, b.y1 + raw.y);
+ crate::drop::auto_layout_slot_within(doc, target, p, &moving, moved, Point::new(raw.x, raw.y)).map_or(0, |(i, _)| i)
+ } else {
+ crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i)
+ };
for m in &moving {
if doc.get(*m)?.parent != Some(target) {
doc.reparent(*m, target, None)?;
diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md
index f60cd4350..16776ca5e 100644
--- a/docs/auto-layout-parity.md
+++ b/docs/auto-layout-parity.md
@@ -1,6 +1,6 @@
# Auto layout and constraints parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** patch (a Cmd/Ctrl-click on a padding field sets all four sides with the value typed, as the Guide to auto layout's shortcut guide says; Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-09 · **Change:** patch (a child dragged within its own auto layout row or column passes a sibling when its leading edge reaches the sibling's middle, as measured in Figma's web app, wherever it is grabbed; a Cmd/Ctrl-click on a padding field sets all four sides with the value typed, as the Guide to auto layout's shortcut guide says; Suggest auto layout is its own command, as the help center says: it adds as many auto layout frames as needed, splitting a frame's layers into rows or columns by the gaps between them, and keeps the placement of the design where the gaps are even (#1823); baseline alignment puts a layer without text with its bottom on the line, as the help center's figure shows an icon next to text (#1825); fill children share the free space by their content area, border-box fashion, as the Flexbox article says (#1824); double-click an edge of an auto layout frame's box for Hug contents, Alt+double-click an edge of a child's box for Fill container, as the Guide to auto layout says (#1838); a child resized to the full available space of its auto layout frame becomes Fill container on that axis, as the Guide to auto layout says; a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
Auto layout is the most used layout feature in Figma and the one where small differences (a
hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%,
@@ -88,7 +88,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| Absolute position (ignore auto layout) with constraints | done | |
| Strokes included in layout (inside strokes), canvas stacking | done | |
| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation) |
-| Drag to reorder (wrap-aware, by row or column), into grid cells, across nested frames | done | `al_drag_tests.rs` |
+| Drag to reorder (wrap-aware, by row or column), into grid cells, across nested frames | done | `al_drag_tests.rs`. A child dragged within its own row or column takes its slot by the dragged box's leading edge, not the pointer: it passes a sibling when the edge facing the drag reaches the sibling's middle (right or bottom edge going forward, left or top edge going back), wherever it was grabbed. Measured in Figma's web app, 2026-11-09 (three 60 x 60 rectangles, gap 20: after B at 50, after C at 130; the mirror for a leftward drag; the same along y). Layers dragged in from outside and wrapping layouts keep the pointer rule (unmeasured). Tests: `a_child_dragged_right_passes_a_sibling_when_its_leading_edge_reaches_the_siblings_middle`, `where_the_child_is_grabbed_does_not_change_its_slot`, `a_child_dragged_left_passes_a_sibling_by_its_left_edge`, `a_column_reorders_by_the_bottom_edge` |
| Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` |
| Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | |
| Layout grids: columns, rows, grid; stretch/left/centre/right; variables bound | done | |
@@ -104,6 +104,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
| 2026-11-05 | patch | M2, #1823: Suggest auto layout is no longer Add auto layout under another name: `layout.suggest` splits a frame's layers into rows (Vertical) or else columns (Horizontal) by the gaps between their boxes, wraps each band of several layers in a new frame without fill or clipping that hugs it, and repeats inside it (16 levels, 1,000 layers per frame at most). The frame keeps its size; gap and padding come from the bands, so layers keep their boxes where the gaps are even and the bands start at the same offset; instances and layers inside them are refused; uneven gaps (smallest used), bands that start at different offsets across the flow (lined up at the smallest) and layers that overlap along both axes come back as `warnings`. A band frame hugs only when its own split succeeds: a band whose layers overlap along both axes gets no auto layout and stays Fixed, so it cannot reflow those children. `direction` sets the top level only; one undo step; `layout.add` is unchanged. Tests in `layout_suggest_tests.rs` |
| 2026-11-02 | patch | M2: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. The modifier was ignored before. Test: `padding_individual_tests` |
| 2026-11-03 | patch | M2, #1825: baseline alignment of a layer without text (an icon next to a text) puts its bottom on the line's baseline, as the help center's figure shows ("the bottoms of the icon and the word home are aligned on the red line"); it kept to the top of the line. A layer reaching below the text's baseline moves the text down, and a line with no text lines the layers' bottoms up (the documentation is silent; CSS baseline alignment does the same). Tests: `alignment_rules` (three cases), engine `baseline_alignment_puts_an_icons_bottom_on_the_text_baseline`. Numbers unchanged. |
+| 2026-11-09 | patch | M2: a child dragged within its own auto layout row or column takes its slot by the dragged box's leading edge instead of the pointer, as measured in Figma's web app on 2026-11-09: it passes a sibling when the edge facing the drag reaches the sibling's middle, so the result no longer depends on where the child was grabbed. Several moving layers use their combined box. Dragging in from outside, wrapping layouts and grids are unchanged. Tests in `al_drag_tests.rs` |
| 2026-11-02 | patch | M2, #1824: fill container children share the free space by their content area: each one's own insets (padding and an included inside stroke, from the same `insets` the frame uses) come off the free space before the equal split and go back on its share, so a child with more padding or a thicker inside stroke takes a little more and the content areas match ("Figma distributes space amongst fill container children by the children's content area instead of by their size, matching the CSS border-box model", Use auto layout with CSS Flexbox in mind). Min and max still clamp the outer sizes and the frozen-share loop is as it was; children without insets split as before. Test: `nested_tests::fill_children_share_by_content_area` |
| 2026-11-02 | patch | M2, #1838: a double-click on an edge of an auto layout frame's bounding box sets Hug contents on that axis, Alt+double-click on an edge of a child's Fill container ("Double-click the vertical or horizontal edge of an auto layout object's bounding box to set it to Hug contents", "Hold ⌥ Option or Alt and double-click ... to set it to Fill container", Guide to auto layout); a vertical edge sizes the width, a horizontal one the height; the Keyboard shortcuts panel lists both gestures. Test: `double_click_tests::a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills`. Numbers unchanged. |
| 2026-11-02 | patch | M2: a child of an auto layout frame resized to the full space available to it on an axis (the frame's size less its padding: across the flow for any child, along the flow for a lone child) becomes Fill container on that axis, whether by `transform.resize`, the W/H fields or a canvas handle ("Child objects of an auto layout frame will also be set to Fill container if they are manually resized to the full available space of the parent frame", Guide to auto layout). Any other size keeps the axis Fixed as before; beside siblings along the flow, in a hugging or wrapping frame, in a grid and for a layer that ignores the layout nothing changes. Test: `al_behaviour_tests::a_child_resized_to_the_full_available_space_becomes_fill` |
From 92145e6f8da614aae4ea48f4fade36086accb7f5 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Mon, 9 Nov 2026 02:59:53 -0500
Subject: [PATCH 042/103] M8: K focuses the Scale field, so K, 2, Enter scales
by two (#2146)
The Scale tool now opens its factor field focused with its text selected (once per activation), the origin starts at the centre, and Enter or Escape in the field returns the tool to Move with the selection kept. Measured in Figma web, 2026-11-09. Docs: ui-parity Scale tool row and revision.
Co-Authored-By: Claude Sonnet 5.5
Model-Effort: Opus 5.5 drove and briefed; Sonnet 5.5 measured and wrote at medium
---
crates/ui-egui/src/field_tests.rs | 63 ++++++++++++++++++++++++
crates/ui-egui/src/panels/right.rs | 2 +
crates/ui-egui/src/panels/scale_panel.rs | 33 +++++++++++--
crates/ui-egui/src/widgets.rs | 9 +++-
docs/ui-parity.md | 5 +-
5 files changed, 106 insertions(+), 6 deletions(-)
diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs
index e3bdad1dd..a0f5f24d6 100644
--- a/crates/ui-egui/src/field_tests.rs
+++ b/crates/ui-egui/src/field_tests.rs
@@ -737,6 +737,69 @@ fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() {
assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60");
}
+/// A 100 x 100 rectangle at the origin, selected, in a Ui with the Move tool.
+fn scale_setup() -> (Ui, NodeId) {
+ let mut s = Session::new();
+ let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
+ s.execute("select.set", &json!({"ids": [r]})).unwrap();
+ let mut u = Ui::new(s);
+ u.frame(vec![]);
+ u.frame(vec![]);
+ (u, r)
+}
+
+fn scale_field_focused(u: &Ui) -> bool {
+ u.ctx.memory(|m| m.has_focus(Id::unique("scale_k").with("te")))
+}
+
+/// Measured in Figma's web app (2026-11-09): K opens the Scale section with its input focused.
+#[test]
+fn pressing_k_focuses_the_scale_field_with_its_text_selected() {
+ let (mut u, _) = scale_setup();
+ assert!(!scale_field_focused(&u));
+ key(&mut u, Key::K, Modifiers::NONE);
+ u.frame(vec![]);
+ assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Scale);
+ assert!(scale_field_focused(&u), "the scale field has the keyboard");
+ let state = egui::TextEdit::load_state(&u.ctx, Id::unique("scale_k").with("te")).expect("the editor exists");
+ let range = state.cursor.char_range().expect("a selection");
+ assert_ne!(range.primary.index, range.secondary.index, "its text is selected");
+}
+
+/// K, 2, Enter: 100 x 100 becomes 200 x 200 about the centre, the tool is Move, one undo step.
+#[test]
+fn k_two_enter_scales_the_selection_by_two_about_its_centre() {
+ let (mut u, r) = scale_setup();
+ let steps = undo_steps(&u);
+ key(&mut u, Key::K, Modifiers::NONE);
+ u.frame(vec![]);
+ u.frame(vec![Event::Text("2".to_string())]);
+ key(&mut u, Key::Enter, Modifiers::NONE);
+ u.frame(vec![]);
+ assert_eq!(geom(&u, r), (-50.0, -50.0, 200.0, 200.0));
+ assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Move);
+ assert_eq!(u.app.session.ds().unwrap().selection, vec![r]);
+ assert_eq!(undo_steps(&u), steps + 1);
+ u.app.run("edit.undo", json!({}));
+ assert_eq!(geom(&u, r), (0.0, 0.0, 100.0, 100.0));
+}
+
+/// Escape in the scale field closes it and changes nothing.
+#[test]
+fn escape_in_the_scale_field_closes_it_and_changes_nothing() {
+ let (mut u, r) = scale_setup();
+ let steps = undo_steps(&u);
+ key(&mut u, Key::K, Modifiers::NONE);
+ u.frame(vec![]);
+ u.frame(vec![Event::Text("3".to_string())]);
+ key(&mut u, Key::Escape, Modifiers::NONE);
+ u.frame(vec![]);
+ assert_eq!(geom(&u, r), (0.0, 0.0, 100.0, 100.0));
+ assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Move);
+ assert_eq!(u.app.session.ds().unwrap().selection, vec![r]);
+ assert_eq!(undo_steps(&u), steps);
+}
+
/// The texts painted in the last frame: what they say, whether they end in an ellipsis, and where
/// they show (within their clip).
fn painted_texts(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, bool, egui::Rect)> {
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index bba57c778..5c190576f 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -281,6 +281,8 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
if app.session.tool.tool == figcraft_engine::ToolId::Scale {
crate::scale_panel::section(app, ui, &shown, &sel);
widgets::separator(ui);
+ } else {
+ crate::scale_panel::closed(ui.ctx());
}
position(app, ui, &doc, &shown, &sel);
}
diff --git a/crates/ui-egui/src/panels/scale_panel.rs b/crates/ui-egui/src/panels/scale_panel.rs
index fd3a100e8..d86355c25 100644
--- a/crates/ui-egui/src/panels/scale_panel.rs
+++ b/crates/ui-egui/src/panels/scale_panel.rs
@@ -17,6 +17,27 @@ use crate::widgets::{self, FieldEdit, Lead};
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let Some(b) = doc.bounds_of(sel) else { return };
widgets::section_header(ui, tr("Scale"), &[], false);
+ // The origin starts at the centre, as Figma's grid does: set once per app, so a point the
+ // user picked later stays.
+ let anchor_init = Id::unique("scale_anchor_init");
+ if !ui.data(|d| d.get_temp::(anchor_init)).unwrap_or(false) {
+ ui.data_mut(|d| d.insert_temp(anchor_init, true));
+ app.run("tool.scaleAnchor", json!({"anchor": "c"}));
+ }
+ // The factor field takes the keyboard once, with its text selected, when the tool opens.
+ let k_id = Id::unique("scale_k");
+ let opened = Id::unique("scale_opened");
+ if !ui.data(|d| d.get_temp::(opened)).unwrap_or(false) {
+ ui.data_mut(|d| d.insert_temp(opened, true));
+ widgets::request_edit(ui, k_id);
+ }
+ // Enter or Escape in the factor field ends the tool (back to Move, the selection kept).
+ // (Focus as it was last frame: the shortcut layer may already have let go of it this frame.)
+ let in_k_id = Id::unique("scale_in_k");
+ let in_k = ui.data(|d| d.get_temp::(in_k_id)).unwrap_or(false);
+ let focused_now = ui.memory(|m| m.has_focus(k_id.with("te")));
+ ui.data_mut(|d| d.insert_temp(in_k_id, focused_now));
+ let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
let col = widgets::col_width(ui.available_width());
// Anchor: column and row (0 start, 1 middle, 2 end) of the point that stays put.
let anchor = app.session.tool.scale_anchor;
@@ -41,9 +62,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
widgets::sublabels(ui, tr("Scale"), Some(tr("Anchor")));
crate::right::block(ui, 52.0, 2.0, |ui| {
// Relative to the layers as they are: a scrub applies once, when let go.
- if let Some(FieldEdit::Set(k) | FieldEdit::Scrub(k, true)) =
- widgets::number_field(ui, Id::unique("scale_k"), Lead::None, Some(1.0), "x", col, 0.1)
- {
+ if let Some(FieldEdit::Set(k) | FieldEdit::Scrub(k, true)) = widgets::number_field(ui, k_id, Lead::None, Some(1.0), "x", col, 0.1) {
factor = Some(k);
}
ui.add_space(8.0);
@@ -70,6 +89,14 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
}
}
}
+ if in_k && (enter || esc) {
+ app.run("tool.set", json!({"tool": "move"}));
+ }
+}
+
+/// Forget that the section opened, so the next time the Scale tool opens it takes the keyboard.
+pub fn closed(ctx: &egui::Context) {
+ ctx.data_mut(|d| d.remove::(Id::unique("scale_opened")));
}
/// The factor (clamped to 0.01–100) and pivot for scaling `b` by `k` around `anchor`; None when
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index eae46fd81..b25ed54b9 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -223,6 +223,12 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix:
number_field_mixed(ui, id, lead, value, suffix, width, step)?.settled()
}
+/// Ask number field `id` to open for typing on its next frame, its text selected (a tool that
+/// opens with its field focused, such as Scale).
+pub fn request_edit(ui: &Ui, id: Id) {
+ ui.data_mut(|d| d.insert_temp(id.with("beginedit"), true));
+}
+
/// Whether the edit of number field `id` began with a Cmd/Ctrl-click. A padding field edited
/// that way sets every side (Guide to auto layout, Keyboard shortcut guide).
pub fn edit_began_with_command(ui: &Ui, id: Id) -> bool {
@@ -400,7 +406,8 @@ fn number_field_impl(
}
// Typing on a field the keyboard reached (Tab) edits it: the editor takes the typed text.
let typed_now = editing.is_none() && on_field && ui.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(_))));
- let started = editing.is_none() && ((field_resp.clicked() && !alt_now) || typed_now);
+ let requested = ui.data_mut(|d| d.remove_temp::(id.with("beginedit"))).unwrap_or(false);
+ let started = editing.is_none() && ((field_resp.clicked() && !alt_now) || typed_now || requested);
// The text the field would commit to no effect: what it showed when the edit began, or the
// value the arrows last stepped it to.
let kept_id = id.with("kept");
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 463be3b47..9eedb74db 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
# UI and interaction parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the origin grid's centre (the default) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two_about_its_centre`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -67,7 +67,7 @@ interaction feel not yet compared.
| Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. With nothing selected, the arrows first show a crosshair in the middle of the view and then move it by the nudge amounts (`tool.crosshair`; Shift the large one), and Enter places there; Escape or a tool change takes it away ("Use the arrow keys to activate the crosshair cursor. It will appear in the middle of the viewport indicating where the new object will be placed", Accessibility at Figma; #1839) |
| Alt-drag duplicate | done | the copy snaps to the layer it came from too (edges, centres, equal spacing); Duplicate (Cmd+D) then repeats the copy's offset, also after a Duplicate whose copy was moved, as long as the copies stay selected (the documentation doesn't say; pinned from community answers) |
| Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) |
-| Scale tool (K): handles about an anchor, live factor, panel | done | |
+| Scale tool (K): handles about an anchor, live factor, panel | done | Pressing K opens the Scale section with its factor field focused and its text selected; typing 2 (or 2x, 1.5) then Enter scales the selection by it about the origin grid's centre (the default), as one undo step, then closes the section and returns to Move with the selection kept; Escape in the field closes it, back to Move, nothing changed. Measured in Figma's web app, 2026-11-09. Tests: `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two_about_its_centre`, `escape_in_the_scale_field_closes_it_and_changes_nothing`. |
| Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children |
| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; a guide dragged out of a ruler or moved snaps to layer edges and centres too (Snap to Objects; Cmd/Ctrl suspends), on its own axis only; page guides see the page's top-level layers, frame guides the frame's box, layout grids and every layer in it, along a rotated frame's own axes; thresholds not yet compared with Figma's (`guide_snap_tests`) |
| Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing |
@@ -271,4 +271,5 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| 2026-10-11 | minor | Canvas fixes: boolean groups clicked where their result is, a layer dragged inside a group stays in it, View ▸ Frame outlines and View ▸ Slices draw |
| 2026-10-11 | minor | Outline mode done: outlines for the selection (the selected layers draw as outlines, the rest normally) and mask outlines |
| 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) |
+| 2026-11-09 | patch | M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the origin grid's centre (the default) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two_about_its_centre`, `escape_in_the_scale_field_closes_it_and_changes_nothing`) |
| 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |
From 6856437479dfa29d8e224359eda3db13b3b91320 Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Mon, 9 Nov 2026 03:03:26 -0500
Subject: [PATCH 043/103] M8: the Scale panel keeps FigCraft's origin; the
centre default is a follow-up (#2146)
The panel set the origin to the centre once per app. The origin is the Scale tool's drag pivot
too (scale_factor), so that also changed handle drags, unmeasured against Figma, while the
engine and its tests keep top-left. This PR keeps to the focus and the Enter and Escape
endings the issue asks for.
Co-Authored-By: Claude Opus 5.5
Model-Effort: Opus 5.5 drove and wrote
---
crates/ui-egui/src/field_tests.rs | 5 +++--
crates/ui-egui/src/panels/scale_panel.rs | 7 -------
docs/ui-parity.md | 6 +++---
3 files changed, 6 insertions(+), 12 deletions(-)
diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs
index a0f5f24d6..dfa097db0 100644
--- a/crates/ui-egui/src/field_tests.rs
+++ b/crates/ui-egui/src/field_tests.rs
@@ -768,7 +768,7 @@ fn pressing_k_focuses_the_scale_field_with_its_text_selected() {
/// K, 2, Enter: 100 x 100 becomes 200 x 200 about the centre, the tool is Move, one undo step.
#[test]
-fn k_two_enter_scales_the_selection_by_two_about_its_centre() {
+fn k_two_enter_scales_the_selection_by_two() {
let (mut u, r) = scale_setup();
let steps = undo_steps(&u);
key(&mut u, Key::K, Modifiers::NONE);
@@ -776,7 +776,8 @@ fn k_two_enter_scales_the_selection_by_two_about_its_centre() {
u.frame(vec![Event::Text("2".to_string())]);
key(&mut u, Key::Enter, Modifiers::NONE);
u.frame(vec![]);
- assert_eq!(geom(&u, r), (-50.0, -50.0, 200.0, 200.0));
+ // About the panel's origin, FigCraft's top-left default (Figma's grid starts at the centre).
+ assert_eq!(geom(&u, r), (0.0, 0.0, 200.0, 200.0));
assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Move);
assert_eq!(u.app.session.ds().unwrap().selection, vec![r]);
assert_eq!(undo_steps(&u), steps + 1);
diff --git a/crates/ui-egui/src/panels/scale_panel.rs b/crates/ui-egui/src/panels/scale_panel.rs
index d86355c25..7ba0618c8 100644
--- a/crates/ui-egui/src/panels/scale_panel.rs
+++ b/crates/ui-egui/src/panels/scale_panel.rs
@@ -17,13 +17,6 @@ use crate::widgets::{self, FieldEdit, Lead};
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let Some(b) = doc.bounds_of(sel) else { return };
widgets::section_header(ui, tr("Scale"), &[], false);
- // The origin starts at the centre, as Figma's grid does: set once per app, so a point the
- // user picked later stays.
- let anchor_init = Id::unique("scale_anchor_init");
- if !ui.data(|d| d.get_temp::(anchor_init)).unwrap_or(false) {
- ui.data_mut(|d| d.insert_temp(anchor_init, true));
- app.run("tool.scaleAnchor", json!({"anchor": "c"}));
- }
// The factor field takes the keyboard once, with its text selected, when the tool opens.
let k_id = Id::unique("scale_k");
let opened = Id::unique("scale_opened");
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index 9eedb74db..5a6614d2e 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
# UI and interaction parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the origin grid's centre (the default) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two_about_its_centre`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -67,7 +67,7 @@ interaction feel not yet compared.
| Enter with a shape or frame tool chosen | done | places what a click makes (100×100, lines 100 long, sections 400×300) in the middle of the view, selected (`tool.place`); from the toolbar, Enter on the chosen tool. With nothing selected, the arrows first show a crosshair in the middle of the view and then move it by the nudge amounts (`tool.crosshair`; Shift the large one), and Enter places there; Escape or a tool change takes it away ("Use the arrow keys to activate the crosshair cursor. It will appear in the middle of the viewport indicating where the new object will be placed", Accessibility at Figma; #1839) |
| Alt-drag duplicate | done | the copy snaps to the layer it came from too (edges, centres, equal spacing); Duplicate (Cmd+D) then repeats the copy's offset, also after a Duplicate whose copy was moved, as long as the copies stay selected (the documentation doesn't say; pinned from community answers) |
| Drag threshold; double-click into groups and text | done | a press moves or resizes only once the pointer has travelled 3 px on screen, so a click or double-click made with an unsteady hand drags nothing; a double-click goes one level into a group and the next one edits the text under it, wherever on the text it lands, with all of it selected (a double-click in the text being edited selects the word) (`double_click_tests`); Enter on a selected text does the same (all of it selected, typing replaces it) and Escape leaves editing with the layer still selected (`text_enter_tests`) |
-| Scale tool (K): handles about an anchor, live factor, panel | done | Pressing K opens the Scale section with its factor field focused and its text selected; typing 2 (or 2x, 1.5) then Enter scales the selection by it about the origin grid's centre (the default), as one undo step, then closes the section and returns to Move with the selection kept; Escape in the field closes it, back to Move, nothing changed. Measured in Figma's web app, 2026-11-09. Tests: `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two_about_its_centre`, `escape_in_the_scale_field_closes_it_and_changes_nothing`. |
+| Scale tool (K): handles about an anchor, live factor, panel | done | Pressing K opens the Scale section with its factor field focused and its text selected; typing 2 (or 2x, 1.5) then Enter scales the selection by it about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured), as one undo step, then closes the section and returns to Move with the selection kept; Escape in the field closes it, back to Move, nothing changed. Measured in Figma's web app, 2026-11-09. Tests: `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`. |
| Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children |
| Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; a guide dragged out of a ruler or moved snaps to layer edges and centres too (Snap to Objects; Cmd/Ctrl suspends), on its own axis only; page guides see the page's top-level layers, frame guides the frame's box, layout grids and every layer in it, along a rotated frame's own axes; thresholds not yet compared with Figma's (`guide_snap_tests`) |
| Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing |
@@ -271,5 +271,5 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| 2026-10-11 | minor | Canvas fixes: boolean groups clicked where their result is, a layer dragged inside a group stays in it, View ▸ Frame outlines and View ▸ Slices draw |
| 2026-10-11 | minor | Outline mode done: outlines for the selection (the selected layers draw as outlines, the rest normally) and mask outlines |
| 2026-10-11 | minor | Design panel arc fields, live boolean editing (operands on the canvas, live results, per-operand operations), shape builder on strokes and open paths, one-sided widths (Alt-drag a width handle) |
-| 2026-11-09 | patch | M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the origin grid's centre (the default) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two_about_its_centre`, `escape_in_the_scale_field_closes_it_and_changes_nothing`) |
+| 2026-11-09 | patch | M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`) |
| 2026-10-10 | major | Created from the ROADMAP's look-and-feel and interaction items, the keymap and the canvas code |
From 197a390a8406150a3c9beae0ad4c7855f370985e Mon Sep 17 00:00:00 2001
From: Trevor Brown
Date: Mon, 9 Nov 2026 03:40:27 -0500
Subject: [PATCH 044/103] M8: the Scale field refocuses after another tab, and
Enter elsewhere keeps the tool (#2148 review)
The focus request was reset only while the Design panel drew, so choosing the tool again on the
Prototype tab left the field without the keyboard; it now follows the frame the section last
showed in. Whether the factor field had the keyboard was read before the widgets ran, so an
Enter right after a click into W or H could end the tool; it is read after them now. A number
field's leftover edit from when it was last shown no longer swallows the focus request.
Co-Authored-By: Claude Opus 5.5
Model-Effort: Opus 5.5 drove and wrote
---
crates/ui-egui/src/field_tests.rs | 39 +++++++++++++++++++++++-
crates/ui-egui/src/panels/right.rs | 2 --
crates/ui-egui/src/panels/scale_panel.rs | 27 ++++++++--------
crates/ui-egui/src/widgets.rs | 8 +++--
4 files changed, 58 insertions(+), 18 deletions(-)
diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs
index dfa097db0..06b78823c 100644
--- a/crates/ui-egui/src/field_tests.rs
+++ b/crates/ui-egui/src/field_tests.rs
@@ -766,7 +766,44 @@ fn pressing_k_focuses_the_scale_field_with_its_text_selected() {
assert_ne!(range.primary.index, range.secondary.index, "its text is selected");
}
-/// K, 2, Enter: 100 x 100 becomes 200 x 200 about the centre, the tool is Move, one undo step.
+/// The Scale tool chosen again while the Design tab was away (Prototype tab, Move, then K): the
+/// field takes the keyboard again when the Design tab comes back.
+#[test]
+fn the_scale_field_takes_the_keyboard_again_after_another_tab() {
+ let (mut u, _) = scale_setup();
+ key(&mut u, Key::K, Modifiers::NONE);
+ u.frame(vec![]);
+ assert!(scale_field_focused(&u));
+ u.app.ui.right_tab = crate::state::RightTab::Prototype;
+ u.frame(vec![]);
+ u.app.run("tool.set", json!({"tool": "move"}));
+ u.frame(vec![]);
+ u.app.run("tool.set", json!({"tool": "scale"}));
+ u.frame(vec![]);
+ u.app.ui.right_tab = crate::state::RightTab::Design;
+ u.frame(vec![]);
+ u.frame(vec![]);
+ assert!(scale_field_focused(&u), "the field has the keyboard again");
+}
+
+/// Tab moves the keyboard from the factor field to the next one; an Enter there leaves the
+/// Scale tool on.
+#[test]
+fn enter_in_another_field_after_tab_keeps_the_scale_tool() {
+ let (mut u, _) = scale_setup();
+ key(&mut u, Key::K, Modifiers::NONE);
+ u.frame(vec![]);
+ assert!(scale_field_focused(&u));
+ // Tab, then Enter on the very next frame (as a quick Tab, Enter or a click and Enter would).
+ let ev = |key, pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE };
+ u.frame(vec![ev(Key::Tab, true), ev(Key::Tab, false)]);
+ assert!(!scale_field_focused(&u), "Tab moved the keyboard on");
+ u.frame(vec![ev(Key::Enter, true), ev(Key::Enter, false)]);
+ u.frame(vec![]);
+ assert_eq!(u.app.session.tool.tool, figcraft_engine::ToolId::Scale);
+}
+
+/// K, 2, Enter: 100 x 100 becomes 200 x 200 about the panel's origin, the tool is Move, one undo step.
#[test]
fn k_two_enter_scales_the_selection_by_two() {
let (mut u, r) = scale_setup();
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index 5c190576f..bba57c778 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -281,8 +281,6 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
if app.session.tool.tool == figcraft_engine::ToolId::Scale {
crate::scale_panel::section(app, ui, &shown, &sel);
widgets::separator(ui);
- } else {
- crate::scale_panel::closed(ui.ctx());
}
position(app, ui, &doc, &shown, &sel);
}
diff --git a/crates/ui-egui/src/panels/scale_panel.rs b/crates/ui-egui/src/panels/scale_panel.rs
index 7ba0618c8..9b6dd98a1 100644
--- a/crates/ui-egui/src/panels/scale_panel.rs
+++ b/crates/ui-egui/src/panels/scale_panel.rs
@@ -17,19 +17,23 @@ use crate::widgets::{self, FieldEdit, Lead};
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let Some(b) = doc.bounds_of(sel) else { return };
widgets::section_header(ui, tr("Scale"), &[], false);
- // The factor field takes the keyboard once, with its text selected, when the tool opens.
+ // The factor field takes the keyboard, with its text selected, whenever the section shows
+ // after a frame without it: the tool was chosen, or the panel came back from another tab or a
+ // hidden UI.
let k_id = Id::unique("scale_k");
- let opened = Id::unique("scale_opened");
- if !ui.data(|d| d.get_temp::(opened)).unwrap_or(false) {
- ui.data_mut(|d| d.insert_temp(opened, true));
+ let shown_id = Id::unique("scale_shown_frame");
+ let frame = ui.ctx().cumulative_frame_nr();
+ let last = ui.data(|d| d.get_temp::(shown_id));
+ ui.data_mut(|d| d.insert_temp(shown_id, frame));
+ if last.is_none_or(|l| l.saturating_add(1) < frame) {
widgets::request_edit(ui, k_id);
}
- // Enter or Escape in the factor field ends the tool (back to Move, the selection kept).
- // (Focus as it was last frame: the shortcut layer may already have let go of it this frame.)
+ // Enter or Escape in the factor field ends the tool (back to Move, the selection kept). Whether
+ // the field had the keyboard is read as it was when the last frame's widgets were done: the
+ // shortcut layer may let go of it before this frame's panel runs, and a click or Tab that moved
+ // it to another field during the last frame counts.
let in_k_id = Id::unique("scale_in_k");
let in_k = ui.data(|d| d.get_temp::(in_k_id)).unwrap_or(false);
- let focused_now = ui.memory(|m| m.has_focus(k_id.with("te")));
- ui.data_mut(|d| d.insert_temp(in_k_id, focused_now));
let (enter, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
let col = widgets::col_width(ui.available_width());
// Anchor: column and row (0 start, 1 middle, 2 end) of the point that stays put.
@@ -82,16 +86,13 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
}
}
}
+ let focused_now = ui.memory(|m| m.has_focus(k_id.with("te")));
+ ui.data_mut(|d| d.insert_temp(in_k_id, focused_now));
if in_k && (enter || esc) {
app.run("tool.set", json!({"tool": "move"}));
}
}
-/// Forget that the section opened, so the next time the Scale tool opens it takes the keyboard.
-pub fn closed(ctx: &egui::Context) {
- ctx.data_mut(|d| d.remove::(Id::unique("scale_opened")));
-}
-
/// The factor (clamped to 0.01–100) and pivot for scaling `b` by `k` around `anchor`; None when
/// nothing would change or `k` isn't a usable number.
fn scale_about(b: figcraft_geom::Rect, anchor: (u8, u8), k: f64) -> Option<(f64, f64, f64)> {
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index b25ed54b9..252ae65af 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -224,9 +224,13 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix:
}
/// Ask number field `id` to open for typing on its next frame, its text selected (a tool that
-/// opens with its field focused, such as Scale).
+/// opens with its field focused, such as Scale). An edit left over from when the field was last
+/// shown (it went away with the keyboard on it) is dropped first, or the request would be ignored.
pub fn request_edit(ui: &Ui, id: Id) {
- ui.data_mut(|d| d.insert_temp(id.with("beginedit"), true));
+ ui.data_mut(|d| {
+ d.remove::(id.with("editing"));
+ d.insert_temp(id.with("beginedit"), true);
+ });
}
/// Whether the edit of number field `id` began with a Cmd/Ctrl-click. A padding field edited
From 984270fbecdaace2353d8247f04b94626f10390f Mon Sep 17 00:00:00 2001
From: ljkx <177671075+ljkx@users.noreply.github.com>
Date: Mon, 9 Nov 2026 12:38:29 +0100
Subject: [PATCH 045/103] M8: automation roots refuse a path on another
computer before resolving it (#1094)
On Windows, Roots::check resolved every path (canonicalize opens it) before
comparing it with the roots, so `\server\share\x` contacted the server, and
could send it the user's credentials, before it was refused. Now the path as
given and made absolute is classified by its spelling with craft_docpath
first: another computer that no root's share is on, a device path, or a DOS
device name anywhere in it is refused, and so is a link on the way that
leads to one (followed by its target's spelling, up to 40 links).
Fixes #1094
---
crates/engine/src/automation/file_access.rs | 189 ++++++++++++++++--
.../src/automation/file_access/tests.rs | 177 ++++++++++++++++
2 files changed, 344 insertions(+), 22 deletions(-)
diff --git a/crates/engine/src/automation/file_access.rs b/crates/engine/src/automation/file_access.rs
index 5ce06b122..97c6cd2f5 100644
--- a/crates/engine/src/automation/file_access.rs
+++ b/crates/engine/src/automation/file_access.rs
@@ -12,6 +12,11 @@
//! existing folder is resolved and the rest must be plain names. The comparison is by whole
//! components (`/work2` is not inside `/work`), and the I/O uses the resolved path, so what was
//! checked is what is opened.
+//!
+//! On Windows, resolving a path opens it, and opening a path on another computer contacts that
+//! computer and may send it the signed-in user's credentials. So before anything is resolved, a
+//! path on another computer that no root is on, a device path, or one reached through a link that
+//! leads to them is refused by how it is spelled (#1094).
#![allow(clippy::disallowed_methods)]
use std::cell::RefCell;
@@ -19,6 +24,8 @@ use std::ffi::{OsStr, OsString};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
+use craft_docpath::Refused;
+
use crate::{EngineError, Result};
/// What a command does with a file.
@@ -58,6 +65,19 @@ impl Roots {
Access::Read => ([self.read.as_slice(), self.write.as_slice()].concat(), "read"),
Access::Write => (self.write.clone(), "written"),
};
+ if cfg!(windows) {
+ match refused_unresolved(path, &roots) {
+ Some(Unresolved::Spelled(Refused::Network)) => {
+ return Err(format!(
+ "automation path rejected: {asked} is on another computer, and no folder that may be {what} is on its share"
+ ));
+ }
+ Some(Unresolved::Spelled(_)) => return Err(format!("automation path rejected: {asked} names a device, not a file")),
+ // Where a link leads is only told by the usual message: it would say what is there.
+ Some(Unresolved::Linked) => return Err(outside(asked, &roots, what)),
+ None => {}
+ }
+ }
match resolve(path) {
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
Ok(_) => {}
@@ -65,11 +85,7 @@ impl Roots {
Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(_) => {}
}
- if roots.is_empty() {
- return Err(format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}"));
- }
- let shown: Vec = roots.iter().map(|r| shown(r)).collect();
- Err(format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", ")))
+ Err(outside(asked, &roots, what))
}
/// The folders as JSON, resolved: `{read: [every folder that may be read], write: [the write roots]}`.
@@ -79,23 +95,146 @@ impl Roots {
}
}
+/// Why `asked` is refused: it is outside every one of `roots`, the folders that may be `what`.
+fn outside(asked: std::path::Display<'_>, roots: &[PathBuf], what: &str) -> String {
+ if roots.is_empty() {
+ return format!("automation path rejected: no folder may be {what} (no root for it was given): {asked}");
+ }
+ let shown: Vec = roots.iter().map(|r| shown(r)).collect();
+ format!("automation path rejected: {asked} is outside the folders that may be {what}: {}", shown.join(", "))
+}
+
+/// Why a path is refused before it is resolved (#1094).
+#[derive(Debug, PartialEq, Eq)]
+enum Unresolved {
+ /// Its spelling leads to another computer ([`Refused::Network`]) or a device ([`Refused::Device`]).
+ Spelled(Refused),
+ /// A link on the way leads there.
+ Linked,
+}
+
+/// How many links are followed on the way to a path before it is given up on, as on Linux.
+const MAX_LINKS: u8 = 40;
+
+/// Why `path` is refused before anything on the way to it is opened, if it is: resolving it would
+/// open a path on another computer, which on Windows contacts that computer and may send it the
+/// signed-in user's credentials, or open a device (#1094). That is the case when the path as
+/// given or made absolute is (see [`refused_spelling`]), or when a link on the way, or on the way
+/// on from where one leads, leads to such a path ([`link_refused`]). A path on the share of one of
+/// `roots` goes on to the usual check.
+fn refused_unresolved(path: &Path, roots: &[PathBuf]) -> Option {
+ // Made absolute without looking at the disk (on Windows `GetFullPathNameW`, which also turns a
+ // bare device name such as `NUL` into `\\.\NUL`, as opening it would).
+ let abs = std::path::absolute(path).ok()?;
+ if let Some(why) = [path, abs.as_path()].into_iter().find_map(|p| refused_spelling(&p.to_string_lossy(), roots)) {
+ return Some(Unresolved::Spelled(why));
+ }
+ link_refused(&abs, roots, MAX_LINKS, &on_disk).then_some(Unresolved::Linked)
+}
+
+/// What is at a path, looked at without following a link there.
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum Entry {
+ /// Nothing, or what can't be looked at: resolving the path meets the same there and goes no
+ /// further.
+ Missing,
+ /// A file or a folder.
+ Plain,
+ /// A link, with where it leads (`None` when that can't be read).
+ Link(Option),
+}
+
+/// What is at `p` on disk (see [`Entry`]).
+fn on_disk(p: &Path) -> Entry {
+ match std::fs::symlink_metadata(p) {
+ Ok(m) if m.is_symlink() => Entry::Link(std::fs::read_link(p).ok()),
+ Ok(_) => Entry::Plain,
+ Err(_) => Entry::Missing,
+ }
+}
+
+/// Why path `p` is refused by its spelling alone, if it is (see [`craft_docpath::resolve`], which
+/// reads both `/` and `\` as separators and compares names ignoring case):
+/// - on another computer (`\\server\share`, `//server/share`, `\\?\UNC\…`, `\\.\UNC\…`, or a
+/// server without a share) and on no root's share;
+/// - a device path (`\\.\pipe\…`, `\\?\GLOBALROOT\…`, `\\.\C:\…`), other than a local volume's
+/// `\\?\Volume{…}\`, which a folder can be mounted as;
+/// - or with a device name (`CON`, `NUL`, `COM1.png`, `lpt1 `…) as any of its names: each folder on
+/// the way is opened in turn while the path is resolved, and the device behind a name such as
+/// `LPT1` can be a printer on another computer.
+fn refused_spelling(p: &str, roots: &[PathBuf]) -> Option {
+ match craft_docpath::resolve(p, None) {
+ // With a root as the document, a path on its share is allowed.
+ Err(Refused::Network) if !roots.iter().any(|r| craft_docpath::resolve(p, Some(r)) != Err(Refused::Network)) => {
+ return Some(Refused::Network);
+ }
+ Err(Refused::Device) if !is_volume(p) => return Some(Refused::Device),
+ _ => {}
+ }
+ // `craft_docpath` stops at the first name it refuses, which may not be the device's.
+ p.split(['\\', '/']).any(device_name).then_some(Refused::Device)
+}
+
+/// Whether `p` is in a local volume by its `\\?\Volume{…}` (or `\\.\Volume{…}`) name.
+fn is_volume(p: &str) -> bool {
+ let mut names = p.split(['\\', '/']);
+ let (Some(""), Some(""), Some("?" | "."), Some(volume)) = (names.next(), names.next(), names.next(), names.next()) else {
+ return false;
+ };
+ volume.get(..7).is_some_and(|v| v.eq_ignore_ascii_case("Volume{")) && volume.ends_with('}')
+}
+
+/// Whether a link on the way to `abs` (an absolute path) leads to a path [`refused_spelling`]
+/// refuses, following at most `links` links: one that leads to another, or on through further
+/// links, is followed too, as resolving the path would. A link that can't be read, or more links
+/// than that, are refused as well. Each name on the way is looked at with `look` ([`on_disk`]),
+/// which doesn't follow a link: where a link leads is looked at only once its spelling is known
+/// not to be refused.
+fn link_refused(abs: &Path, roots: &[PathBuf], links: u8, look: &dyn Fn(&Path) -> Entry) -> bool {
+ let mut at = PathBuf::new();
+ let mut rest = abs.components();
+ while let Some(c) = rest.next() {
+ at.push(c);
+ if !matches!(c, Component::Normal(_)) {
+ continue;
+ }
+ let target = match look(&at) {
+ Entry::Plain => continue,
+ // Nothing is below what isn't there, so no link either.
+ Entry::Missing => return false,
+ Entry::Link(target) => target,
+ };
+ let (Some(links), Some(target)) = (links.checked_sub(1), target) else { return true };
+ // A relative target is relative to the link's folder; an absolute one replaces it.
+ let next = lexical(&at.parent().unwrap_or(&at).join(target).join(rest.as_path()));
+ return refused_spelling(&next.to_string_lossy(), roots).is_some() || link_refused(&next, roots, links, look);
+ }
+ false
+}
+
+/// `p` with its `.` and `..` taken by name, as Windows takes them.
+fn lexical(p: &Path) -> PathBuf {
+ let mut out = PathBuf::new();
+ for c in p.components() {
+ match c {
+ Component::ParentDir => {
+ out.pop();
+ }
+ Component::CurDir => {}
+ c => out.push(c),
+ }
+ }
+ out
+}
+
/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and
/// its longest leading part that exists resolved, so links on the way are followed as the roots'
/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it).
fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool {
let Ok(abs) = std::path::absolute(path) else { return false };
- let mut lexical = PathBuf::new();
- for c in abs.components() {
- match c {
- Component::ParentDir => {
- lexical.pop();
- }
- Component::CurDir => {}
- c => lexical.push(c),
- }
- }
+ let folded = lexical(&abs);
let mut rest: Vec<&OsStr> = Vec::new();
- let mut at = lexical.as_path();
+ let mut at = folded.as_path();
let resolved = loop {
if let Ok(mut base) = std::fs::canonicalize(at) {
base.extend(rest.iter().rev());
@@ -169,18 +308,24 @@ fn resolve(path: &Path) -> std::result::Result {
fn plain_name(name: &OsStr) -> std::result::Result<(), String> {
if cfg!(windows) {
let n = name.to_string_lossy();
- let stem = n.split('.').next().unwrap_or_default().trim_end().to_ascii_uppercase();
- let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
- || ((stem.starts_with("COM") || stem.starts_with("LPT"))
- && stem.chars().count() == 4
- && stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')));
- if device || n.contains(':') {
+ if device_name(&n) || n.contains(':') {
return Err(format!("`{n}` is a device or stream name, not a file"));
}
}
Ok(())
}
+/// Whether Windows opens a device for file or folder name `n`: `CON`, `PRN`, `AUX`, `NUL`,
+/// `CONIN$`, `CONOUT$`, `COM1`…`COM9` and `LPT1`…`LPT9` (also with `¹`, `²` or `³`), in any case,
+/// and with anything after a dot or a colon or trailing spaces (`nul.txt`, `COM1 .png`, `con:x`).
+fn device_name(n: &str) -> bool {
+ let stem = n.split(['.', ':']).next().unwrap_or_default().trim_end().to_ascii_uppercase();
+ matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$")
+ || ((stem.starts_with("COM") || stem.starts_with("LPT"))
+ && stem.chars().count() == 4
+ && stem.chars().last().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')))
+}
+
thread_local! {
static ROOTS: RefCell