node.set refuses unknown properties, but the effects inside it were read
by serde, which drops keys it doesn't know. A drop shadow given
offsetX/offsetY (the names variable bindings use for its offset) was
stored with the default offset [0, 4] and the call said ok. effect.add
and effect.set dropped them the same way.
The three now refuse what the effect doesn't read and say what it
takes: "effects[0]: unknown properties: offsetX, offsetY (a dropShadow
takes blend, color, offset, radius, showBehind, spread, type, visible)".
A key counts as read when setting it to null makes the effect fail to
parse or come out different, so serde's aliases count as read without a
list of them here.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
component.makeSlot adds its slot property through add_prop, which named
itself in every error: making a second slot called "Content" said
"invalid parameters for `component.addProperty`: a property named
`Content` already exists", a command the caller never ran. add_prop now
takes the command errors name, the fix#807 makes for wrap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An assignment and the layers that read it can name different copies of
one definition (the set's own id on one side, a variant's copy on the
other). The values were kept under the id the assignment names and read
under the id the layer names, so such a value never reached its layer.
Both sides now go through prop_root, the definition the copies were
made from (CodeRabbit on #847). In Untitled UI, 143 assignments name a
copy that no layer reads while another copy of it is read.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A layer with individual stroke sides (a header with only a bottom divider,
strokeSides [0, 0, 1, 0]) came out of the CSS, HTML, React and Leptos
code as `border: 1px solid ...` on all four sides, because the border
was written from the stroke weight alone. With individual sides it is now
a border-top/right/bottom/left for each side that has a weight, and a
side bound to a variable names it (border-top: var(--border-top, 3px)).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A component property assignment carries its value in `value`, and in
current files also in the variable form `varValue`. Many assignments
leave `value` empty and say it only in `varValue` (booleans as
boolValue, instance swaps as symbolIdValue, text as textDataValue), and
those were ignored: a hidden layer showed and a swapped icon stayed the
default. In Untitled UI that is 1,643 booleans and 185 swaps, so inputs
showed their hint text, help icon and leading icon on top of the
neighbouring layers. Both forms are read now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a path can't be resolved (a link that leads nowhere, `..` below a
missing folder), the reason is given only for paths inside a root.
That check compared the path as typed with the roots resolved, so a
root reached through a link (`/tmp/work` on macOS is
`/private/tmp/work`) called its own dangling links "outside the folders
that may be written: /private/tmp/work", and a link out of a root still
had its reason told. The existing part of the path is now resolved
before the comparison, as the roots were.
This is also why `links_that_lead_out_of_a_root_are_outside` and
`outside_paths_are_refused_alike_whatever_exists_there` failed on
macOS, where the temp folder is under `/var` -> `/private/var`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each variant of a component set keeps a copy of the set's property
definitions (an id and a parentPropDefId, no name or type), and the
variant's layers and its instances' assignments use the copy's id. Those
copies were skipped, so imported variants lost every boolean, text and
instance swap binding, and instances lost their property values. In
Untitled UI (8,074 copies) an input's "Hint text" no longer followed its
property. A copy now names the definition it was made from.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A mistyped or unsupported flag (e.g. --automation-write-roots) used to be
dropped silently, so the app started unconfined. It now exits 2 with
'unknown option', like a bad automation root. A bare -- ends the options.
Adds a desktop control-channel test that file.save / file.saveAs outside the
write root are refused and create nothing, while saves inside succeed, and
documents that the root flags arrived in 0.7.0 (0.6.0 ignores them).
Routing the CLI's writes through `file_access` changed what happens when the
output path is a symbolic link. `file_access::write` stages the bytes and
renames them into place, and a rename replaces the link with a regular file,
where `std::fs::write` followed it and wrote to its target. A render to a link
reported success while the target kept its old contents.
Unconfined writes go back to `std::fs::write`. Confined ones keep the staged
write, which is right there: `Roots::check` resolves the path first and has
already refused a link leading out of the root, so the write lands on the
resolved target inside it.
Found while testing crates/engine/src/cmd/styles.rs:119-124
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
`figcraft-cli mcp` takes `--automation-read-root` / `--automation-write-root`
to confine the files an agent's commands touch, but the file subcommands took
neither: `figcraft-cli run script.json --out /anywhere/x.figcraft` read and
wrote as the user who started it. The README invites agent use, and a script
or agent driving `run`, `export` or `convert` had no way to say which folders
it may reach (#833).
`info`, `export`, `convert`, `render` and `run` take the same two flags, with
the same semantics: repeatable, the folders must exist, reads inside a read or
write root, writes only inside a write root, everything else refused -- so
giving no write root denies writing altogether. Without the flags they behave
as before.
The flags would have been decorative on their own: `figcraft_engine` enforces
roots through `file_access`, which the engine's own loads already go through,
but the CLI wrote its output with `std::fs` and read its script the same way.
Those three calls now go through `file_access` too, so the confinement reaches
the files the CLI itself touches.
frame_and_text_marks_are_inset fails if either mark reaches the box edge
again; slice_is_one_connected_knife fails if the handle stops short of the
blade's base or the icon goes back to separate marks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
File ▸ Open, Import and Place image did nothing in the browser (the pickers
return a path, which a browser never has) and dropped files were ignored
(eframe hands their contents over asynchronously).
- Services::pick_open_contents: a platform can pick a file's name and
contents; the web build uses an <input type="file">. Open and Import pass
them to file.open / file.import as data + name.
- A dropped file without a path waits behind the opening card until its
contents are read (bytes_async on wasm), or says it couldn't be read.
- Canvas drops still import, as on the desktop.
Closes#759.
Co-authored-by: Cursor <cursoragent@cursor.com>
Found while testing crates/engine/src/codegen.rs:865 (swift_layer) and :1038 (compose_layer)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The closed Spacing row paired each icon with the other axis: the
side-bars field wrote top/bottom and the top/bottom-bars field wrote
left/right. The H field now binds paddingHorizontal (left+right) and the
V field paddingVertical (top+bottom); the PaddingH/PaddingV paintings
were swapped to match so every icon is pixel-identical.
(shut_pair_fields_edit_their_own_axis).
The Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a
2x2 grid in place of the H/V fields, each bound to its own padding key with
one undo step; shut H/V fields show both values ("16,54") when the sides
disagree instead of "Mixed". Original code-drawn side icons, English and
Spanish strings (padding_individual_tests).
Every mark in the bottom toolbar is now painted in one 18-point box. The
mode switcher drew its marks in a 14-point box, so Draw, Prototype and
Inspect read smaller than the tools. The flyout carets move from a
10-point box, whose stroke rounded to one physical pixel thinner than the
row's, to a 14-point box that rounds to the same width at 1x, 1.5x and 2x.
Frame and Text are inset by a unit: their long pixel-hinted strokes read
heavier than the anti-aliased outlines beside them. Slice is a knife (a
slim blade and a solid handle), one silhouette instead of a box crossed
by a diagonal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- text.rescanFonts offers a loaded family the files it hasn't read (styles
installed since) and refits every family that gained faces; `added` still
lists only the families that were unknown
- text.refit refuses an `ids` that isn't a list instead of refitting the
whole document
- text.loadFont with a file already loaded returns its families without
keeping its bytes or adding its faces again
- doc comments on the functions this change touches
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A text box keeps the size it was measured at, so text laid out while its
family was missing kept the fallback font's box after the font arrived (a
hugging chip clipped its label).
- text.refit {ids?} measures auto-sizing text again (whole document by
default) → {refitted}
- text.rescanFonts and text.loadFont refit the open document's text in the
families they add, as one undo step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The system fonts are indexed once per process, so a font installed while
FigCraft (or `figcraft-cli mcp`) runs stayed missing until a restart.
- text.rescanFonts walks the font folders again and offers the families
installed since (loaded families keep their faces) → {added}
- text.loadFont {path | data} adds a font file without installing it → {families}
- both clear the layout cache so text lays out again in the new face
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
edit.copy and edit.cut mirror a selection made only of text layers to the system clipboard as plain text, one layer per line, instead of SVG. Other selections stay SVG; pasting back into FigCraft still pastes the copied layers.
Closes#808
Co-authored-by: Cursor <cursoragent@cursor.com>
layout.remove and node.set {layout: null|"none"} now drop the bindings of auto layout fields (gap, crossGap, padding...), as frame-to-section already did for some of them (it now drops all of them). Re-applying a binding no longer creates an auto layout on a frame without one: the binding waits for the next auto layout.
Closes#810
Co-authored-by: Cursor <cursoragent@cursor.com>
Polish integer plural rules use Plural::One strictly for n == 1, while
higher counts ending in 1 (e.g. 21, 101) use Plural::Many.
Also adds unit tests for Polish integer plural coverage.
Co-Authored-By: Antigravity <noreply@google.com>
Escape closed the whole Preferences dialog even while a menu was open over
it. That held while Preferences had no menu of its own; the Interface size
menu is the first, so one Escape threw away the dialog with the menu.
Dialog keys now leave Escape to an open menu, and the next Escape closes the
dialog as before.
Adds a keyboard test of the Interface size menu: Tab reaches it, Enter opens
it, the arrows move through the sizes, Enter picks one and closes the menu,
and Escape closes only the menu.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Draws the interface the way a real window does (a fixed number of physical
pixels, its size in points being that divided by the scale drawn now) over
10 window sizes, 6 system scales and every interface size. Guards the rule a
fixed size promises: above 100 % the window still holds the smallest layout,
the panels leave room for a canvas, and the size settles without flicker.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Preferences > Interface size and the `pref.uiScale` command make the whole
interface larger or smaller. Auto keeps the system's display scale; a fixed
size from 75 % to 300 % is absolute (100 % is one screen pixel per point on
every system). The size is held back while the window is too small for it.
Strings are added in en, es and fr; the control protocol, MCP, UI parity and
roadmap docs are updated.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
#783 completed the French catalog to every current string, superseding
the translations added in #681. This keeps only the ~50 UI strings and
8 command labels whose wording a native French speaker corrected during
that review (keyboard focus, "Off" as "Désactivé", blend modes, stroke
join, spotlight, the no-break space before : ; ? !…), applied on top of
#783. The draft status is unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* docs: drop references to competing commercial software
* docs: clarify parity checklist coverage in status callout
* #786: say interface design once in the tagline
---------
Co-authored-by: bflatastic <bombayapirat@gmail.com>
Add full Polish localization for FigCraft UI, commands, and menus.
Includes Polish CLDR integer plural rules (one, few, many, other)
and eliminates all untranslated strings and borrowings.
Co-Authored-By: Antigravity <noreply@google.com>
Both reach wrap() (a page in a mask, a section outlined into a group)
and named `object.group` before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fill.set, stroke.set and node.set fill/fills/stroke/strokes reported ok
while normalize put the bound colour straight back, and node.set
{fills: []} brought the bound paint back. They now drop a paint's
colour, opacity or stop binding where the edit changed that value, as
selection.setPaint already did (its comparison moves to
lists::rebind_edited_paint, which also lets stop bindings follow a
removed stop). boundVariables applies last in props::apply, so a
binding given with the paints in one call stays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Grouping, framing, auto layout, masks, outline stroke and the boolean
commands share wrap(), which named `object.group` in every refusal
(`layout.add` on a page: "invalid parameters for `object.group`").
wrap() now takes the calling command's id.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>