Commit Graph
61 Commits
Author SHA1 Message Date
BFlat 3be354d227 Merge pull request #1461 from MortenHusted/fix/open-file-reload
M1: opening a file that is already open says when it changed on disk, and can read it again (#997)
2026-10-29 13:41:21 +07:00
bflatastic d1e96ea706 Merge main into #1461 2026-10-29 12:22:30 +07:00
bflatastic a51e815073 Merge main into #1460 2026-10-29 12:22:28 +07:00
bflatastic 1505681a54 Merge main into #1407 2026-10-29 11:39:50 +07:00
bflatastic f154011299 Merge main into #1461 2026-10-29 11:19:05 +07:00
bflatastic f99f01d57b Merge main into #1460 2026-10-29 11:09:30 +07:00
bflatastic fd81433a23 #1460: text.style lists the text-decoration and variation keys the Typography panel sends, paragraph spacing on selected characters goes to node.set, MCP builds its tool catalogue once, and a test for the params panels build 2026-10-29 11:09:09 +07:00
phonedu 8e6b3bf449 M12: MCP comment feedback and live change events
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.
2026-10-28 21:42:31 +08:00
MortenHustedandClaude Opus 5.5 3567bef862 #1460: commands and tools that pass their params on list what they pass
file.placeImage runs image.place's code and file.exportSvg file.export's,
so their docs now list the names those read (width, height, target, mode;
scale or size and the export options). MCP's create_node passes points,
closed and select to node.create, and pointer and key read ctrl, so their
schemas list them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:35:23 +01:00
MortenHustedandClaude Opus 5.5 40b9288f70 M1: opening a file that is already open says when it changed on disk, and can read it again
`file.open` on a path already open in a tab switched to it and returned
its index, even when another process had changed the file since, so the
caller kept looking at the old copy with nothing saying so (#997). The
tab's disk stamp from #1330 (`DocState::on_disk`) now tells:

- the result says `stale: true` when the file on disk differs from what
  the tab read or last saved;
- `reload: true` reads the file again into the tab, as Revert to saved
  does (refused while the tab has unsaved changes or is in a live
  session), and the result says `reloaded: true`.

MCP's `open_file` takes `reload` and passes it on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:19:56 +01:00
MortenHustedandClaude Opus 5.5 27c11858e1 M0.9: MCP tools and batch steps refuse names they don't take (#1413)
`run_command {"command": "inspect.code", "args": {...}}` ran the command
without its params, on the selection. MCP tool arguments are now checked
against each tool's input schema (and the objects it describes inside,
such as a batch's steps; open objects like `params` take anything), and
a step key other than `command`, `params` and `as` is refused in
command.batch, figcraft-cli scripts and the MCP batch tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:06:59 +01:00
8ae76d9b2a M8: bound attached MCP replies to 512 MiB (#1113)
The attached backend read each reply from the app's control port with read_line, which has no
limit. It now reads up to 512 MiB before the newline (an app window capture is at most 256 MiB as
base64) and stops at the first byte past that. An oversized or non-UTF-8 reply drops the
connection and is reported like a lost reply; the next call reconnects.

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>
2026-10-27 07:57:53 +03:00
bflatastic e469cc8135 Merge main into #840
# Conflicts:
#	apps/figcraft-cli/src/files.rs
#	apps/figcraft-cli/src/main.rs
#	crates/engine/src/cmd/file.rs
#	crates/engine/src/lib.rs
#	crates/ui-egui/locales/en.toml
#	crates/ui-egui/locales/es.toml
#	crates/ui-egui/src/export_panel.rs
#	crates/ui-egui/src/export_preview_tests.rs
#	docs/file-format-parity.md
#	docs/mcp.md
#	docs/roadmap.md
#	docs/ui-parity.md
2026-10-26 23:23:07 +07:00
bflatastic 07a654959a Merge main into #1021
# Conflicts:
#	docs/file-format-parity.md
2026-10-26 09:12:39 +07:00
BFlat 09d2d96588 Merge pull request #1353 from storytold/fix/sonar-new-code-refactor
refactor: clear SonarCloud new-code issues (S3776, S107) and exclude generated coverage tree
2026-10-26 09:04:36 +07:00
Dylan M. Taylor 7dc5d3b377 refactor(mcp): group with_marker's cut sizes into a struct (SonarCloud S107)
The depth and wire-size numbers describing a cut subtree are now one Cut
value, so with_marker takes 5 parameters instead of 8. Output is unchanged.
2026-10-25 21:11:43 -04:00
Jindřich ŠaršonandClaude Opus 5.5 b8c120f30c M6: Sketch import: name .sketch in the file.open, file.import and MCP open_file docs
The command parameter docs (CLI `commands`, MCP list_commands, the Actions
palette) and the MCP open_file tool description listed .fig but not
.sketch; docs/mcp.md and docs/cli.md already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:33:22 +01:00
bflatastic dbf57d30c2 Merge main into #1003 2026-10-24 14:21:15 +07:00
bflatastic b00d12b623 #915: get_node refuses a layer too large for maxBytes on its own; a window only an offset made says it is paging 2026-10-24 13:01:33 +07:00
bflatastic b6f5c29d19 #915: MCP get_node pages past a child too large for the limit and keeps the truncation marker 2026-10-24 12:58:10 +07:00
bflatastic 13eba6a3ea Merge main into #1003
# Conflicts:
#	crates/engine/src/lib.rs
2026-10-24 12:53:57 +07:00
bflatastic 1b41d129e6 Merge main into #915 2026-10-24 12:53:52 +07:00
a1a842af9a chore: resolve SonarCloud maintainability issues (#1241)
* chore: resolve SonarCloud maintainability issues

- Define SHA256_FMT constant in packaging script to remove duplicated string literal (shelldre:S1192)
- Remove redundant 'static lifetime annotations from const items (rust:S8863)
- Replace closures with method and function pointers (rust:S1612)
- Extract helper functions in synthetic document generation, bezier polyline flattening, and component restoration to reduce cognitive complexity (rust:S3776)

* fix(packaging): use verify_sha256 helper function to avoid shellcheck SC2059

* fix(engine): remove duplicate closing brace in large_document

* fix(engine): take &mut Document in restore_id_map; keep 'static on associated consts

restore_id_map allocates ids, so it needs a mutable document. Elided
lifetimes in associated consts trip elided_lifetimes_in_associated_constant,
so those annotations are restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Assign positional parameters to locals in sha256 verification

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
2026-10-23 23:28:13 -04:00
KameronandClaude Opus 5.5 9315e5e014 command.batch: named steps ($ref) and slash paths in field
A batch step can name itself with "as" and later steps refer to it with
{"$ref": "name"}, alongside positional {"$step": n}. "field" now takes a
slash path into the result as well as a key, so {"$ref": "copy",
"field": "ids/0"} reaches the first id edit.duplicate returned (the
convention variable.bind uses for fills/0/color; a leading "/" as in
JSON Pointer also works, and a key holding "/" still matches first).

Resolution moves to crates/engine/src/batch.rs (BatchStep, BatchScope),
shared by command.batch, the CLI script runner, the control channel and
the MCP batch tool, which now passes "as" through. Duplicate names fail
before any step runs; unknown, same-step or later names, bad paths and
out-of-range indexes fail the batch with the step, name and full path.
$ref is reserved like $step; nested batches keep their own names.

Fixes #994.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:19:47 -07:00
Hamza 1aa1d61bd4 MCP: get_node keeps to the limit too, and windows a wide node
inspect_document sends a shallower tree when the document's is too large
and points at get_node for the rest, so get_node was the way out of that
limit -- and it had none of its own. Measured against a document with a
frame of 15,000 children, through figcraft-cli mcp:

    inspect_document depth 64, allPages   14.29 MiB   (8.00 MiB cap not
                                                       applied before #915)
    get_node depth 64                     10.51 MiB

Both are over the 10 MiB buffer the report is about, and for get_node no
depth makes it smaller: the width is at depth 1, so depth 1 is already
9.12 MiB of text and depth 0 has no children at all.

get_node now takes the same maxBytes, cuts the subtree by depth as the
document's tree is cut, and then takes a window over the children when
even depth 1 is too large. The marker says how many children were shown
of how many, and the offset to ask for next; `offset` is a new argument
for reading the rest. After this:

    get_node depth 64                     8.00 MiB, children 11427 of
                                          15000 from 0, next 11427

The hint in inspect_document's marker no longer promises that get_node
has no size limit, since that was the hole: it now says a wide node
answers with a window and an offset.

The test pages through a windowed node and asserts every child is
reported exactly once, in order, with every page inside the limit.
Unbounded, the first page is 23,003 bytes against a 7,667-byte limit.
2026-10-21 00:29:14 +05:00
Hamza 3df5673627 MCP: maxBytes is the whole message, and depth 0 says when it is over
Two things the limit got wrong, both reported by CodeRabbit on #915.

maxBytes was compared with the escaped text block alone, but what goes
on the wire is that block inside a content object, inside the JSON-RPC
result, with the request id and the newline the transport writes: a
block measured at exactly maxBytes left as a larger message than the
caller said it could read. A kilobyte of the caller's figure is now kept
for that wrapping. About ninety bytes of it is fixed and the rest is the
request id, so the figure holds for any id up to roughly 900 bytes --
every id in practice, a number or a UUID. Sizing the real envelope here
would mean handing the transport's request id to every tool, and the
assumption is written down instead, in the schema and at the constant.

A depth: 0 request over the limit returned early, so the one response
that cannot be made any smaller was the one sent without the truncated
marker -- while a bisection that walked down to depth 0 sent the same
tree with it. Depth 0 now takes the same path as the bisection: the tree
goes out, over the limit, with the marker saying so.

Without the first fix the test reports a limit at the exact size of the
tree as no truncation; without the second the depth-0 marker is absent
(left: (None, None), right: (Some(0), Some(0))).
2026-10-20 20:56:08 +05:00
Hamza b1f0eafbf7 MCP: inspect_document answers within what a client can read
MCP stdio is newline-delimited JSON, so one tool result is one message
and has to fit the client's read buffer whole; there is no streaming of
part of one. The TypeScript SDK's stdio transport defaults to 10 MiB and
hosts rarely raise it, so a document whose tree went past that killed
the server: the client stopped reading, the pipe broke, and every later
call in the session failed.

The tree is now reported at the deepest depth that fits, found by
bisection, with a `truncated` object giving the depth asked for, the
depth used, both sizes, the limit and a pointer to get_node for reading
one subtree deeply. `maxBytes` lets a caller that knows its own buffer
say so; the default is 8 MiB, leaving the envelope 2 MiB of the usual
10.

The size is measured as the transport sends it -- pretty-printed and
then escaped inside the text block, which adds about a tenth (342,136
bytes of tree became 379,954 on the wire in the fixture). A kilobyte is
reserved for the marker itself, which is added after the measurement.

A limit below even the shallowest tree still answers, at depth 0, with
the marker saying so: there is nothing smaller to send, and an error
would leave the caller with nothing at all.

The tool description now says that each level of depth multiplies the
response and points at get_node, which the report notes would have been
enough on its own to avoid the crash.

Fixes #912.
2026-10-20 16:30:03 +05:00
Filip Strajnar eae9546878 M6: export as Android Vector Drawable (XML)
ExportFormat::VectorDrawable ("XML", .xml) and ExportSetting.vd_compat
(bestEffort / exact / ignore, stored only when not the default) wire the
figcraft-vectordrawable writer into file.export, file.exportLayers and
export.add/update: a vdCompat param (junk refused), svgIdAttribute writes
android:name, and the results carry the writer's warnings (per file for
export layers). The Export section lists XML with an "Android Vector
Drawable" tooltip and its id attribute and compatibility options, File >
Export... gets an XML preset, and the toasts say when content was changed
or left out. CLI: --format xml / .xml outputs, --vd-compat, warnings as
note: lines. MCP: format xml returns the XML as text plus its warnings.
A .fig export writes XML settings as SVG with a note.

Docs: file-format-parity (row and revision), ui-parity and roadmap
revisions, architecture (the crate), cli and mcp.
2026-10-18 20:39:46 +02:00
bflatastic 057bf9a289 #801: create_node and mcp.md say width/height scale a vector's path 2026-10-18 17:54:25 +07:00
bflatastic 03bb164b5c M4: Esc after a pen path stays on the same vector layer (#760)
Escape or Enter while the pen draws ends the path and keeps its vector in
edit mode with the pen, so the next path joins the same layer; another
Escape leaves edit mode. Entering edit mode on a layer that already is a
vector no longer records an empty undo step.
2026-10-17 23:05:03 +07:00
bflatastic e39d24a32e Merge fix/749 2026-10-17 20:48:35 +07:00
BFlat ac4b98dbd7 M8: confine MCP file access with --automation-read-root/--automation-write-root (#715) (#746)
- `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, `--flag=DIR` works too). When either is given, every file an agent's tool call reads must lie inside a read root or a write root, and every file it writes inside a write root. Anything else is an ordinary tool error (`automation path rejected: … is outside the folders that may be written: …`); nothing is written and the server keeps serving. Without the flags nothing changes.
- The desktop app takes the same flags (`figcraft --control PORT --automation-read-root DIR …`) and confines every control request and every frame of every window to them, so `figcraft-cli mcp --attach` drives a confined app. `--attach` together with the flags is a usage error, because the app runs the commands and only the app can confine them.
- New engine module `crates/engine/src/file_access.rs`: `Roots` (resolution and comparison), a thread-scoped `confined(roots, f)`, and the file primitives (`read`, `read_capped`, `open`, `metadata`, `read_dir`, `write` (atomic), `create_dir_all`, `exists`, `is_dir`). Every engine file access now goes through it. `crates/engine/clippy.toml` denies `std::fs::*`, `File::open/create`, `OpenOptions::open`, `Path::exists/is_file/is_dir/metadata/read_dir/canonicalize` and `figcraft_format::{write_file_atomic, save_file, load_file}` everywhere else in the engine, so a command added later is confined without a check of its own.
- `ui.screenshot` and `ui.render` return the PNG as base64 `data` when no `path` is given (before, `ui.screenshot` wrote `figcraft-screenshot.png` in the working directory and `ui.render` required a path). The attached MCP backend uses that, so it no longer writes and deletes temporary PNGs, and a confined app needs no exemption for them.
- `initialize`'s `instructions` name the folders the agent may read and write.
- `figcraft-cli` options may be given more than once (`Args::values`); single-valued options keep the last value, as before.
- Docs: `docs/mcp.md` › Confining file access (options, semantics, coverage, resolution, limits), `docs/control-protocol.md` › Confining file access and the optional `path` of `ui.screenshot` / `ui.render`, `docs/cli.md`, `figcraft-cli --help`, and the Automation line plus a revision row in `docs/target-app-parity.md`.

Fixes #715, fixes #481.
2026-10-17 19:56:20 +07:00
bflatastic adde034a7c Merge main into #603 2026-10-17 19:40:01 +07:00
Skyfuller1303 d4394e87cc M1: save a copy as .fig
Save as now offers .figcraft and .fig as separate file types. A .fig
destination, chosen in the dialog or passed to file.saveAs and file.save,
writes the existing experimental .fig export of the document that is shown
and reports how much was left out. The file's own path, unsaved changes,
versions, branches and undo history are not touched, and a shown earlier
version stays shown while the copy is written or a Save as choice is
pending.

Plain Save no longer writes over a file whose saved path ends in .fig;
earlier builds could write native data under that name. It asks for a
destination instead.
2026-10-16 02:03:46 +02:00
Edivan TeixeiraandClaude Opus 5.5 8cccc48516 node.set: apply auto layout keys to the frames and name the rest; resolve fontStyle like text does
- Several ids: auto layout keys go to the frame-like layers, the others get the remaining keys
  and are named in `warnings`. Refused only when no target can have auto layout.
- fontStyle is never refused: it draws in the nearest face, as a text layer resolves it.
  `warnings` says so when that face's weight or slant differs from what the name asks for.
- set_properties passes `warnings` on to the agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 19:43:52 -03:00
Edivan TeixeiraandClaude Opus 5.5 c8ba7b4a4b node.set: refuse auto layout keys off frames, and font styles the family lacks
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 18:26:08 -03:00
bflatastic dcb36e376f Merge branch 'pr-495' into batch/t5
# Conflicts:
#	contributors/people.toml
2026-10-16 01:35:28 +07:00
bflatastic 6d9024e0d0 Merge main and the community batch into #400 2026-10-15 18:27:37 +07:00
NetterSilverandClaude Haiku 5.5 61bc027072 Fix MCP layer screenshots exceeding maxSize
Fixes #445

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:56:32 +02:00
Edivan TeixeiraandClaude Opus 5.5 13691c1cc0 Let batch steps reference ids created by earlier steps
Inside command.batch, `{"$step": n}` anywhere in a step's params is
replaced by the `id` in step n's result before the step runs
(`{"$step": n, "field": "..."}` picks another field). A nested tree,
such as a frame with children, now takes one batch and one undo step,
not one round-trip per level with ids copied between calls by hand.

Malformed references (forward or self, negative or fractional index,
missing or non-string field, extra keys) fail the batch with BadParams
naming the step, and the existing group machinery rolls it back. The
params walk is depth-capped. Steps are journalled with resolved params.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 23:43:31 -03:00
Piettro CastroandClaude Opus 5.5 f3cd0509cb fix(engine): read the documented gradient shorthand on every route (#252)
`{"type":"linearGradient","stops":["#hex",…]}` was only expanded by the MCP
tools, so `figcraft-cli run` and the control channel's `engine.execute`
refused it ("`fills` must be paints or colours"). The expansion now lives in
the engine (`params::normalize_paint`, used by `paint_of`), so node.set,
node.create props, fill.add/set, strokes and paint styles all take it; MCP
calls the engine's copy instead of its own.

Tests: engine (every paint command, MCP-equal defaults, junk still refused),
control channel `engine.execute`, and `figcraft-cli run` end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:02:45 -05:00
Euisuh Jeongandbflatastic 8b7435890e fix(mcp): prevent replay after lost attached replies (#211)
The attached MCP backend no longer re-sends a request when its reply is lost: the app may already have applied it, so the error says the operation may have completed and to inspect the document before retrying; the next call reconnects, and a failed reconnect says the request was not sent. Fixes #194.

Also: a connection the app already closed (it drops ones idle past its socket timeout) is noticed before the next request is written and replaced then, so a pause between calls is not an error.

Co-authored-by: bflatastic <bflatastic@users.noreply.github.com>
2026-10-14 05:34:33 +07:00
Euisuh Jeong e8d7dc24f2 fix(control): bound frames and expire queued requests (#213)
MCP and control-channel lines are bounded while they are read (32 MiB; an oversized frame gets an error and the stream closes without being drained). The loopback control listener caps connections at 8, applies 60 s socket read and write timeouts, caps both request queues at 8 and discards requests whose client stopped waiting. Fixes #201.
2026-10-14 05:18:40 +07:00
bflatastic a2a0f80252 M2: auto layout: wrapped-line alignment 2026-10-13 13:36:54 +07:00
bflatastic 29e31c2054 M1: rotation origin and place-with-Enter shortcuts
The last two documented shortcuts the parity table listed as missing, and a stale toast.

- **Alt+R: rotation origin** (both platforms). `transform.rotationOrigin {on?}` shows (or hides) a
  handle at the selection's rotation origin: a ring with a cross through it, drawn in code. Drag it
  to move the origin (it snaps to the selection box's corners, edge middles and centre within the
  snap distance); double-click it to put it back at the centre; Escape hides it before it deselects,
  and Alt+R again hides it too. `transform.setRotationOrigin {x, y} | {reset: true}` does the same
  from the CLI, MCP and the control channel. The corner rotation handles, the rotation field
  (`node.set {rotation}` on the selection: each layer's point under the origin stays put, so flipped
  layers work too), Rotate 90/180 and `transform.rotate` without `cx`/`cy` turn about it. The
  origin is stored on the document state per selection, in the first layer's own coordinates, so it
  follows the layers as they move and turn; it is dropped when the selection changes and is not
  saved in the file (the public documentation doesn't say it persists; pinned in the parity row).
- **Enter places an object with the chosen tool.** With a shape or frame tool chosen, Enter on the
  canvas, or Enter again on the toolbar's chosen tool (after picking it from the toolbar with the
  keyboard), places what a click with the tool makes (100x100; lines 100 long; sections 400x300, the
  existing click sizes, now one `ToolId::click_size`) centred in the view, selected, as one undo
  step; the canvas then takes the keyboard. New `tool.place {tool?, x?, y?}`; MCP's headless `key
  Enter` does the same. The documented crosshair that the arrows move before placing is not built.
- **Keyboard box selection toast** (and its screen-reader text) now names both ways to size the box:
  "Shift+arrows or Cmd+arrows" (Ctrl on Windows and Linux). The changed key is renamed in every
  catalog with its translation updated.
- **Shortcuts panel labels:** a label too long for its column was measured in a smaller font than
  it is drawn in, so long ones (several Spanish rows) ran into their keycaps; it is now measured in
  its own font, ends with "..." before the keycaps and shows in full on hover. The new Tools row
  reads "Place a shape or frame (its tool chosen)" with an Enter keycap.
- **Parity table:** the two rows move from Missing to bound (Alt+R -> `transform.rotationOrigin`;
  Enter -> a Tools-tab gesture row, the keymap's Enter staying `select.children`); counts 129
  documented, 128 bound, 1 moved, 0 missing, 2 kept by a platform, 48 pinned.
- New command labels in Spanish (`tool.place`, `transform.rotationOrigin`,
  `transform.setRotationOrigin`), new UI strings in English and Spanish (es-419 overlays "Enter").
- Docs: ui-parity.md (selection and transform rows, shortcut counts, header, revision row),
  mcp.md (the new commands).
2026-10-12 07:53:24 +07:00
bflatastic c420721597 M6: experimental .fig export
An experimental, best-effort `.fig` export, so work done in FigCraft can be handed back as a `.fig` file. It is written from the same public format write-ups the importer uses and is the inverse of the importer's mapping. Only round trips through FigCraft's own importer are verified; other apps may not open every file.

- **Encoder** (`crates/format/src/fig/kiwi.rs`): the kiwi encoder that test fixtures used is now shipped code. It never panics and checks every value against the schema: unknown fields or enum names, missing struct fields, wrong types, out-of-range numbers and non-finite floats are errors, and NULs inside strings are dropped. Varints and floats are written exactly the way the decoder reads them. `Schema::parse` reads the compact text schema form, and the importer's tests use it too.
- **Writer** (`crates/format/src/fig/export.rs`): writes the zip (`canvas.fig`, `meta.json`, `thumbnail.png`, `images/<hash>`). `canvas.fig` holds the fig-kiwi header, then a raw-deflate binary schema and a raw-deflate message (flate2's pure-Rust backend, already in the tree; no new dependencies). The schema covers only the fields we write. The canvas, images, blobs, node count and nesting depth are capped, and floats are made finite before they are written.
- **Mapping:** pages; frames, groups, sections, rectangles, ellipses (arcs), polygons, stars, lines and vectors (vector network blobs with per-point radius, mirroring and caps, plus fill outlines); text with styled runs (UTF-16 indexed) and list types; slices; boolean groups; solid, gradient and image paints (image bytes included); strokes; effects; corners; constraints; masks; auto layout, including grids with tracks and cells; child layout; layout grids; export settings; components, component sets and their properties. Instances are written as their main plus the fields their layers change, by id path. This covers nested instances and swaps, and fields the main sets but the instance clears. Prototype interactions (triggers, navigation and overlay actions, transitions, easing), scrolling and flow starts are included. Everything skipped is reported in a warnings list: style links, variables, comments, Dev Mode data, plugin data, guides, slots, brush and width-profile strokes, pattern fills, image adjustments, variable and conditional actions, and more.
- **Import fixes found by the round trips** (`map.rs`): ellipse arcs, boolean operation types and component set descriptions are now read. Durations are read as whole milliseconds, because seconds stored as 32-bit floats used to truncate by 1 ms.
- **File writes:** a failed write no longer leaves its `.tmp-figcraft` file behind. The junk-parameter sweep found this.
- **Commands and UI:**
  - `file.export {format: "fig"}`, or any path ending in `.fig`, writes the whole file and returns `warnings`. Without a path it returns base64 `data`.
  - File > Export... has a new "The whole file" section with a ".fig (experimental)" row. A note under it says other apps may not open every exported file. After export, a toast reports how many kinds of content were left out.
  - `figcraft-cli export --format fig` (or `-o x.fig`) and `convert` to `.fig` print the warnings as `note:` lines.
  - MCP's `export` accepts `format: "fig"` and passes the warnings through.
  - New UI strings have English and Spanish entries.
- **Docs:** file-format-parity.md has a new export section covering what is written, what is skipped, and that only round trips through our own importer are verified. Also updated: mcp.md, cli.md, gaps.md (gap 1), target-app-parity.md and ROADMAP.md. Grading is kept low. Import/export goes to 86%/58% and file formats to 60%. The file-exchange discount stays at x0.85, because nothing shows that another app opens these files.
2026-10-12 07:19:57 +07:00
bflatastic 8d5f8a0978 M1: keyboard shortcut parity suite and bindings
- A shortcut parity table (`crates/engine/src/shortcut_parity_tests.rs`): one row per shortcut in
  the public documentation (help-centre feature articles: keyboard layouts, text properties,
  alignment, frames, components, prototypes, plugins, keyboard navigation), with the macOS and the
  Windows/Linux keys, what handles it here (keymap command, held key/gesture listed in the panel,
  vector edit tool key, or missing), and a note in our own words. Rows the documentation is silent
  on pin today's keys, so every default binding is in the table.
  - 129 documented rows: 126 bound (110 before), 1 moved off a documented conflict (Run last
    plugin on Windows: the docs also give Ctrl+Alt+P to pixel preview; the plugin keeps
    Ctrl+Alt+Shift+P), 2 missing (moving the rotation origin with Alt+R; placing an object with
    Enter after choosing a tool from the toolbar), 2 kept by a platform (Ctrl+Space with some CJK
    input methods; F6 in browsers). 48 undocumented bindings pinned.
  - Tests: every row fires its command on both platforms and sits in the right shortcuts-panel tab;
    every default binding has a row; no binding uses a chord the OS keeps (Cmd+H/M/Q/Tab/Space,
    Ctrl+Space, screenshot keys on macOS; Alt+Space/Tab/F4, Ctrl+Esc, Ctrl+Alt+Delete, Linux
    workspace and VT keys); macOS keycaps show Cmd/Option glyphs only and Windows/Linux keycaps
    Ctrl/Alt words only; vector edit tool keys; counts pinned for the docs.
- Newly bound: A (Frame tool), Shift+' (pixel grid; Cmd+' kept, the articles disagree),
  Alt+Cmd+\ / Ctrl+Alt+\ (others' cursors in a live session), Ctrl+F6 (toolbar focus on
  Windows/Linux), Shift+Cmd+U (link text; Cmd+K kept), Cmd/Ctrl+] and [ (list levels while
  editing), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection; F8 kept,
  Cmd/Ctrl+arrows draw the box out), Ctrl+Shift+A / Ctrl+Alt+Shift+A (suggest auto layout),
  Ctrl+Alt+Cmd+K / Ctrl+Alt+Shift+K (go to main component).
- New commands: `view.multiplayerCursors {on?}` (View menu item, menu breadth 207/207),
  `view.pan {dx, dy, big?}` (arrows pan the view by 20 screen px, 200 with Shift, when nothing is
  selected; the canvas and MCP `key` both use it), `text.listIndent` / `text.listOutdent`.
- Behaviour: Control held while dragging suspends snapping on macOS too (Cmd/Ctrl already did);
  the presenter takes Space and N for the next frame, C for comments (Shift+C kept) and F for full
  screen, after the prototype's own key triggers; the paint bucket's tooltip shows Shift+B (B still
  works).
- Keyboard layouts: shortcuts follow the character typed, named as on a US keyboard. A symbol
  another layout types with Shift (`/` is Shift+7 on German keyboards) now counts without the
  Shift (canvas, text editing and F6 navigation share one lookup). Characters egui has no key for
  already arrive as the key at that position.
- Keyboard shortcuts panel: new rows for panning with the arrows, suspending snapping, moving along
  the toolbar, and the presentation keys, in the tabs the documentation groups them in (en/es
  strings).
- Docs: ui-parity.md gets a Keyboard shortcuts section with the counts; target-app-parity.md,
  ROADMAP.md, gaps.md (gap 4 landed list), mcp.md, parity-checklist.md (regenerated).
2026-10-12 06:49:02 +07:00
bflatastic a5a706674c M4: vector multi-edit and edit tail
Vector edit mode can now edit several vector layers at once, and the rest of the vector edit tail
from the public documentation landed. A behaviour suite came first
(`crates/engine/src/vector_multi_behaviour_tests.rs`, 5 behaviour tests with about 30 table rows and
steps, plus a junk-parameter sweep of every `vector.*` command run in multi-edit mode).

- **Multi-edit.** Enter, a double-click or `vector.edit.begin {ids}` with several shapes selected
  puts them all in edit mode, converting them in one undo step. The clicked or first one is the
  active layer. Pressing another layer's point, handle or segment makes that layer active. Shift-click,
  marquee and lasso select points in any layer. Dragging a selected point, the arrows, Delete,
  delete and heal, mirroring, smoothing, corner radius and the new cap and join commands reach every
  layer's selected points in one undo step. The pen, bend, paint bucket, cut, eraser, join and
  position work in the active layer. A click away from every edited layer leaves edit mode.
  `vector.edit.state` now reports `layers`, `others` and `handles`.
- **Several handles.** Shift-click Bezier handles to select more than one. Dragging one moves the
  others by the same amount. Also available as `vector.edit.select {handles}` and
  `vector.edit.moveHandles`. Selected handles are drawn filled.
- **Rounding next to curves.** A sharp point with a corner radius rounds even when one of its
  segments is curved. The curve is cut where the arc meets it and keeps the rest of its shape, and
  the arc meets each side smoothly (`VectorNetwork::rounded`).
- **A cap and a join per point.** `Vertex` gets optional `cap` and `join` values. When unset, the
  layer's apply. The commands are `vector.edit.strokeCap` and `vector.edit.strokeJoin` (`null`
  clears). The Design panel's Point section gets End point and Join dropdowns, each with a "Same as
  layer" option. The stroke is drawn run by run and each point's own join is drawn over the break
  (`figcraft_pathops::stroke_outline_of`, new `points.rs`). This applies to the canvas, PNG and PDF,
  and to SVG as outlines. Outline stroke follows it, render bounds count arrowheads from points, and
  the stroke cache key includes the points' styles.
- **Pen outside edit mode.** Clicking one of the path's own points (other than the first or last)
  connects there and ends the path, as a vector network. With an open vector selected, clicking one
  of its end points continues that path in edit mode.
- MCP: headless `key` Enter on shapes now enters vector edit mode, as in the desktop app. The new
  commands have Spanish labels, and the new UI string has en and es entries.
- Docs: target-app-parity (area 4: 98%/72% to 99%/73%, 2-4 h to 1-3 h; area 20: 80%/55% to
  88%/60%, 2-5 h to 1-3 h), ROADMAP, gaps (gap 9 is down to tablet pressure on macOS and Linux; the
  gap 13 note is updated), ui-parity and mcp.md. Totals are recomputed by the doc's method, because
  rows added since the last recompute had left them stale. Feature breadth is now ~83% and ready for
  real work ~52%. Mainstream stays at ~49% (49.7%, rounded down). Full parity goes from
  ~306-595 h to ~294-572 h.
2026-10-12 02:52:18 +07:00
bflatastic 8b2607de8e M7: export and inspect behaviour suite and fixes
A behaviour suite for export and inspect, written from public documentation of design tools'
export settings and developer handoff modes (`crates/engine/src/export_behaviour_tests.rs`,
`crates/engine/src/inspect_behaviour_tests.rs`: 19 tests, 139 table rows and asserts, each with a
one-line derivation; rows where the docs are silent say so and pin today's behaviour). 47 cases
failed on `main`; this PR fixes them in shared code.

**Fixes**
- Export bounds are what a layer paints (`Document::world_render_bounds`, used by every export):
  a drop shadow adds room only in the direction it falls (it was a symmetric margin of blur +
  largest offset on every side); strokes on rectangles, frames and ellipses reach their real width
  (the mitre factor made a centre stroke of 4 add 8 a side); hidden children take no room; a mask
  group exports the mask's area.
- Slices export everything visible in their area (they exported an empty image).
- Slashes in layer names export into folders (`icons/close` → `icons/close.png`, folders created;
  `.` and `..` segments dropped).
- Copy as PNG copies the whole selection (only the first layer was copied), now from shared code
  (`cmd::file::copy_png`).
- Inspect positions are measured from the frame around a layer; groups aren't frames.

**Added (documented options that were missing)**
- Export settings: contents only (off draws every layer showing in the area) and include bounding
  box (the layer's own box); SVG: outline text (off writes `<text>` with a `<tspan>` per line),
  id attributes (unique), simplified strokes (stroke attributes for centred strokes, and for inside
  and outside strokes on boxes and ellipses). Stored per export setting, taken by `file.export`,
  `export.add` / `export.update`, `file.exportLayers` and MCP `export`; a "…" popover on each row of
  the Export section.
- Inspect: colour formats hex / RGB / CSS / HSL / HSB (`inspect.properties {colorFormat}`, a Color
  format dropdown), a Blend row, CSS in rem (`inspect.code {unit, rootFontSize}`, px/rem dropdown),
  CSS for rotation, blend modes, gradient directions and kinds (linear angle, radial, conic), text
  alignment, case and decoration; icons and images in the asset list (`kind`), exported with
  `file.exportLayers {assets: true}`.
2026-10-12 01:32:04 +07:00
bflatastic 2a54adb762 M4: shapes and vector behaviour suite and fixes
**A shapes and vector behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (drawing with Shift and Alt, corner radius and which layers take one, point counts and star ratios, arcs, stroke joins and the miter angle, caps per end, boolean results and the look they take, flatten and outline stroke; entering and leaving vector edit mode, the pen, selecting points and segments, deleting vs. delete and heal, handle mirroring, the bend, paint bucket, cut, join and eraser tools, nudging and undo steps, what converting a shape keeps) is a table row or a step with what the rule implies and a one-line derivation; where the documentation is silent the row says so and pins today's behaviour.

**Fixes**
- **Rounded vector points were stored but never drawn**: a point's corner radius (edit mode's Corner radius field, `vector.edit.cornerRadius`) now rounds the corner between two straight segments on the canvas, in hit testing, booleans and every export (`VectorNetwork::rounded`, used by the fill and stroke paths); the radius shrinks to fit half of each segment, like a polygon's corners. A vector layer's own corner radius rounds every point. The points stay where they are in edit mode.
- **A boolean group's look follows its operation**: union, intersect and exclude take the top layer's fills, strokes and effects, subtract the bottom layer's (it was always the bottom one).
- **Alt draws a line or an arrow from its centre**, like the other shapes (it was ignored).
- **Stars keep to 3–60 points** (they went to 120; polygons keep 120, the documentation is silent); a junk `pointCount` is refused instead of becoming 5.
- **Delete and heal recomputes the curve**: the healed segment keeps its neighbours' tangents and passes through where the deleted point was (it reused the neighbours' handles, so healing over a circle cut deep inside it). Corners between straight segments heal straight, as before.
- **Shift-click on a selected point at a corner of the points' transform box takes it out of the selection** (it started a scale); a Shift-drag there still scales in proportion.
- **Outline stroke** turns the stroke's colour style into the outline's fill style, and the outline keeps the layer's opacity, blend, effects, constraints and visibility (they were lost).
- **The stroke panel's Miter angle shows degrees** (it showed the ratio 4 under that name; the default is ~28.96°); `node.set {miterAngle}` sets it and `get_node` reports a non-default join or angle.
- **Individual corners only for rectangles and frames**: polygons, stars and vectors have one radius (the toggle showed for them and set corners that weren't drawn).
- **Option/Alt+Shift+F flattens** (Cmd/Ctrl+E still does).
- **MCP `key` in vector edit mode reaches the edit tools first**, as the canvas does: tool letters, arrows, Delete / Shift+Delete, Cmd/Ctrl+J, Enter (Delete removed the whole layer; `X` was unknown).

**Missing documented behaviour, added**
- **Corner radius and star ratio handles on the canvas**: a handle inside each corner of a selected rectangle or frame, one inside a polygon's or star's top point, a ratio handle on a star's first inner point (`shape_handles.rs`). Dragging along a corner's bisector sets the radius of every corner (Alt: only that corner of a rectangle or frame, documentation silent), stopping where the corners meet; the ratio handle sets the inner radius. A readout shows the value while dragging; the handles hide under 48 px on screen, on locked layers, with several layers selected and in edit mode. One undo step per drag; `inspect_ui` lists them under `tool.shapeHandles`.
- **Count and Ratio fields** in the Design panel for polygons and stars (there was no way to change them in the UI).
- **Cut tool drags**: dragging across paths cuts them where the line crosses; parts that come apart move to layers of their own, each keeping its piece of the fill (`vector.edit.cut {from, to}`). A click still splits at a point or segment.
- **Paint bucket drags**: a drag fills or unfills every area it crosses the way the first one went (`vector.edit.paint {points}`), one undo step.

New strings go through `tr(...)` (the new fields reuse the existing Count / Ratio / Radius / Star / Polygon entries in `en.toml` and `es.toml`); the new fields have accessible names (an icon lead with its label). Each gesture and edit stays one undo step.
2026-10-12 01:32:04 +07:00