Every message (the first and each reply) gets `mentions`: {name, known,
you} for each @mention, by the rule the thread view highlights with
(figcraft_doc::mentions, now shared with mention_ranges). Names resolve
against the file's people and you; unknown names are kept with
known: false; a@b.com mentions nobody.
* M2: components can't contain their own instances (#1084)
Edits that would make a component hold an instance of itself, directly, through other components or through another variant of its set, are refused with the loop named. Files holding such a loop open with one instance per loop detached and a note; a rebuild never expands an instance inside a copy of itself, so a loop merged in a live session stops growing.
* M2: a variant may hold a sibling variant's instance; only true loops are refused (#1084)
Each variant counts as its own component: refusals and open-time repairs apply only to a component leading back to itself through instances (an instance-swap value included), so files with nested sibling variants open unchanged. sync_instance_depth is split into match_layers and copy_layers.
With map: true, edit.duplicate also returns map: each original layer's id, descendants included (nested frames, groups, instances and their sublayers), paired with its copy's id, so a batch can edit inside a copy through a step reference instead of inspecting it. Without map the result is unchanged.
Tools that act on a document, and the control methods engine.execute,
document.inspect, node.get and ui.render, take an optional `document`
(an open file's id from doc.list / document.inspect, or its path). The
call acts on that file and the tab in front stays in front, so an
attached agent keeps editing its own file when the user switches tabs.
Every command an MCP client runs acts as the client's name from initialize (clientInfo.title, else .name) with ' (agent)' after it, or 'AI agent' without one, carried to the app as engine.execute {agent}. Its messages, reactions and read state are its own, and its messages are marked (agent: true in the file and comment.list, an Agent label in the thread). The user's name and comments made in the app are unchanged; in live sessions the name and marker travel with the thread.
* M1: Crop image from the Object menu, the right-click menu and the image fill row (#1464)
* M1: crop mode shows the image dimmed outside the layer and heavier crop marks (#1464)
* M1: the handle overlay draws gradients and crops in their own helpers (#1464)
Dragging a guide, or moving one, now snaps to nearby layer edges and centres on the guide's own axis: within 5 screen pixels, gated by Snap to Objects and suspended with Cmd/Ctrl. Page guides see the page's top-level layers; frame guides see the frame's box, its layout grids and every visible layer inside it, in the frame's own space.
Snapping sits in the interaction path (canvas/guides.rs, not guide.add/guide.move) so the commands stay deterministic for the control channel and MCP. Tests: guide_snap_tests.rs in the engine and ui-egui crates; docs/ui-parity.md updated.
Signed-off-by: Ahmed Mahmoud <ahmedmhmouad41@gmail.com>
The Scale tool (and `transform.scale`) scales a layer's children whatever
their constraints. After the edit, overrides are recorded by diffing the
changed layers, and a size or place that changed while an ancestor resized
is read as what constraints give, so it isn't an override. For a scaled
instance that left its layers' scaled size and points unrecorded: the next
rebuild of the instance (a layer added to its main, say) put a 14 px
icon's triangle back at the main's 24 px size. Strokes already kept their
scaled weight, as a separate override group.
`scale_node` now marks `size` and `position` overrides on the layers it
scales below the scaled layer when they are layers of an instance and the
value changed. Undo takes them back with the rest of the edit.
The report's own sequence (clearing effects on the component holding the
icon) no longer reproduces on main; this covers the rebuild path that
still did. Regressions: a scaled instance through value edits and a
rebuild of its main; one scaled with the tool, with undo; a scaled icon
nested in a component and in an instance of that component, through edits
of both mains.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w
Session::execute ran a command under guard::run but normalised what it
changed afterwards, outside it: a panic in normalisation escaped execute
instead of becoming EngineError::Internal with the edit rolled back. Inside
a batch it unwound through Session::grouped, which then never restored the
undo limit it had lifted or ended the group. Normalising is now the last
step of the guarded result chain, so a panic there fails the command like
one in the command itself (and a batch step fails like any other failed
step); live text previews normalise under the guard too.
The other half of #1085, restoring grouped()'s state when its closure
panics, is left alone: #1419 rewrites that function.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6E2qDnjtMzfPVuEP7iH
Comments become the task queue for an attached or headless MCP client: comment.prompt returns a bounded Markdown brief, the Comments panel gains Copy for AI, and the MCP server exposes fix_comments, watch_comments, figcraft://comments and wait_for_changes. Mutating comment.* commands append to a bounded in-memory event log; the control channel's events.poll long-polls it without the UI thread, and a successful batch commits its events only if the whole batch lands. No model, network dependency, asset or file-format change.
A misspelt parameter name was ignored and the command reported success,
answering as if the parameter hadn't been given (`comment.list
{"filterr": "open"}` listed resolved threads). `Session::execute` now
refuses names the command's params doc doesn't list, naming them and
what the command takes, before the command runs, so every path into a
command (CLI, batches, control channel, MCP, plugins, the UI) gets the
check.
The names come from the existing docs, parsed once with the registry:
each top-level item's first word and the words joined to it by `|`, `/`
or `+`, bracketed text and what follows `:`, `=` or `→` being
description, alternative groups (`{x, y} | {points}`) included. `ids`
and `id` are always taken (`params::targets` reads either); node.set,
whose doc is its props list, keeps its own check.
The junk sweep runs each junk object as given and cut down to the names
a command takes, so the junk values still reach the parsers. Tests that
passed names their command never read (comment.resolve's author,
collab.host's link, node.get's depth) pass only what it takes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Shadows and layer blurs dominated PNG/JPG/WebP export time (gdb stack
sampling of the example files: about 45% in fx::box_blur_h, 13% in
fx::morph). Both now produce the same bytes, faster:
- fx::blur runs its three box passes per row while the row is in cache,
works on whole pixels instead of bounds-checked bytes, skips fully
transparent rows, transposes in cache-friendly column blocks, and
splits rows and transposes across rayon on desktop for pixmaps of
64K pixels and more, only when not already on a rayon worker (a band
of render_view stays on its thread: no nested parallelism).
- fx::morph (shadow spread on layers that aren't one outline) uses the
van Herk / Gil-Werman running max/min: constant work per pixel instead
of 2r + 1.
Integer arithmetic and pass order are unchanged; new tests compare both
against the previous implementations byte for byte, including sizes on
the parallel path.
An ignored timing test exports every top-level frame of examples/ as
PNG, JPG and WebP at 2x through export_file:
cargo test --release -p figcraft-engine --lib export_timings -- --ignored --nocapture
Ryzen, 16 threads, Linux; median of 7 (min):
PNG 2195 ms (2182) -> 992 ms (989) -55%
JPG 2880 ms (2560) -> 1550 ms (1535) -46%
WebP 1964 ms (1901) -> 1022 ms (1018) -48%
total 7035 ms (6677) -> 3567 ms (3555) -49%
All 80 exported files (PNG, JPG, WebP, PDF, SVG) are byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
node.create and node.set take {"$var": variable id or name} for fill and stroke: an exact id
first, then an unambiguous name or Collection/name. The binding goes through the same code as
variable.bind, is applied after literal values and style links, replaces other bindings of that
paint's colour and clears its style link, all in one undo step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6 Astra <noreply@openai.com>
A placed image was always fitted into 1000 px on its longer side, so a
screenshot of a 1440 px wide page came in at 1000 px and stopped lining
up with the design it was taken from. Figma places an image at its pixel
size and only scales down a side past 4096 px.
Placing now does the same, and reads the screen density a PNG records:
macOS screenshots carry 144 dpi (pHYs), so an 800 x 600 px capture of a
400 x 300 pt area comes in at 400 x 300, as in Figma. Only whole 2x-4x
multiples of 72 dpi count as a screen density; 300 dpi print scans and
other values keep their pixel size. The image data stays at full
resolution, so exports at 2x and up stay sharp.
On macOS, pasting reads the clipboard's public.png as is instead of the
decoded pixels arboard returns, which drop the density (and the colour
profile). Other platforms keep the decoded image.
Files dropped, Place image, paste and image.place all go through the
same path; an explicit width/height still wins.
node.find {root?, name?, type?, text?, component?, limit?} returns {ids, total} for the strict
descendants of root (or the current page) that match every filter given: name, text and main
component name as case-insensitive substrings, type as an exact type name. It changes neither the
selection, the page nor the undo history. Part of #996; fields/outline are left for later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: GPT-6.1 Sol <noreply@openai.com>
Co-Authored-By: GPT-6 Astra <noreply@openai.com>