Found while testing crates/engine/src/cmd/arrange.rs:327-330
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found while testing crates/engine/src/cmd/edit.rs:256 (effect.set), :305 (export.update), :317 (export.remove)
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The colour of new text in the app was chosen from the page alone, so
on a dark page text drawn in a white frame (a new frame's default) was
white and invisible. readable_text_fills now looks at what the text is
drawn on: the nearest layer from its parent out whose top visible fill
is a mostly opaque solid colour, else the page. The Text tool and
text.create / node.create pass the text's parent.
The two tests that asserted the page-only rule in a frame now expect
black text in a white frame; new cases cover dark, unfilled, nested,
faint and stacked fills, and the Text tool.
Found while testing crates/engine/src/cmd/edit.rs:975-981
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
opacity_from_digits multiplied and added on every character of its input
string before the one-or-two-digit check, so "9999999999" overflowed u32 on
the way (panic in debug builds, wrap in release). The scan now steps n with
checked_mul/checked_add and stops (None) at the first character the number
cannot take, so a long digit string is refused like any other bad input.
Test: opacity_digits (engine keymap) covers the ten-digit string and the
command's BadParams refusal.
Document::next_name summed max + 1 over numbers parsed from existing layer
names, and bump_name did the same for the duplicate-rename path: a document
(or peer, or node.set) holding a layer named "Frame 18446744073709551615"
overflowed both (panic in debug, wrap to a wrong name in release). next_name
now collects the numbers it sees and, when the counter is spent, picks the
lowest free number (mirrors new_style_id); bump_name only bumps when the
number can step, else falls to its existing " 2" branch.
Tests: next_name_never_overflows (doc) and
duplicating_a_layer_named_past_the_counter_does_not_overflow (engine panel
behaviour).
page.add gave every added page the interface theme's colour. Figma's
documentation says added pages inherit the current page's background;
the theme only decides a new file's page. Read the active page's colour
before creating the new page, in the command, so the app, CLI, control
channel and MCP agree.
Refs #295
Saving and exporting wrote a truncating std::fs::write to a deterministic
staging name (<path>.tmp-figcraft, <file>.figcraft.tmp) before renaming, so an
unrelated file or a planted symlink at that name was clobbered or written
through, and separate writers of one destination shared a staging file they
could corrupt. Both paths now go through figcraft_format::write_file_atomic: a
uniquely named sibling staging file is created exclusively (create_new) and
written through its own handle, renamed over the destination, and removed again
on any failure, so only the staging file a write created is ever touched. Tests
cover pre-existing staging files and symlinks, concurrent writers, and
injected rename and permission failures.
- A copy Alt-dragged off a layer snaps to that layer too: its edges,
centres and equal spacing (Targets::add_box with the originals' box).
Before, the originals were left out of the snap targets, so a copy
couldn't line up with or space evenly from the layer it came from.
- Duplicate (Cmd+D) repeats the last duplicate's page offset while its
copies are what it duplicates: Alt-drag a copy, or Duplicate and move
the copy, then each Cmd+D steps out the next one as far again. The
step is measured when Duplicate runs. It lasts as long as the
selection (undo reuses ids), copies placed by an auto layout set no
step, and explicit dx/dy win.
- measure_tests: an Alt-dragged copy between its original and another
layer now snaps to equal spacing (45/45 instead of 50/40).
Tests: duplicate_spacing_tests (snap to the original, equal spacing,
repeat after moving a copy and after an Alt-drag, only for the last
copies, undo then a new layer, rotated frame, auto layout).
Refs #256
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
One layer directly on the page has nothing to align to (one layer aligns
to its parent frame), so the six Alignment buttons looked active and did
nothing. They are now disabled there, with a hover explaining why.
- cmd/arrange.rs: align_target (unchanged rule) and pub can_align.
- right.rs: the align rows in add_enabled_ui + on_disabled_hover_text.
- The hover text in every catalog.
Tests: a_layer_alone_on_the_page_has_nothing_to_align_to (engine),
the_align_buttons_are_off_for_a_layer_alone_on_the_page (AccessKit).
Refs #256
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`{"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>
The Recents section shows recent files as cards: the picture each file
carries (thumbnail.png in .figcraft and .fig, an image file itself) fitted
on its first page's colour, a divider, and a footer with the file's icon,
name and "Edited ... ago" (or "File not found"); the full path on hover.
Pictures are read off the frame's thread, only for cards in view, cached
by path and modification time, capped in bytes and pixels; missing,
damaged or oversized ones give a placeholder. They fade in, and cards
show a skeleton until their file has been looked at.
A sort menu (Alphabetical, Date created, Last modified; either order) and
a grid/list toggle sit above them. The list is a Name, Last modified and
Created table whose headers sort. View (per section) and sort are kept in
the preferences and set with ui.set {homeRecentsView, homeRecentsSort,
homeRecentsOrder}. A click selects, a double-click or Enter opens, arrow
keys move the selection; a right-click offers Open, Show in folder and
Remove from recents (file.openRecent {remove}).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
A `.figcraft` image member that is damaged, too large or missing was skipped
without a word, and saving the file again dropped it for good. The load now
lists each lost image (up to 20, then a count) with its hash and the reason,
file.open puts it in `repairs` and sets a session message, and the app shows
that as a toast.
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
View > Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux,
Ctrl+Cmd+F on macOS) makes the window fill the screen, taskbar included,
and leaves it again. FigCraft's own menu item, translated in every catalog.
Tests: the key and the command toggle the window, junk parameters are
ignored; the shortcut table pins the new binding (49 pinned).
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: bflatastic <bombayapirat@gmail.com>
With a layer's proportions locked, setting only its width or only its height through node.set (the W and H fields) scales the other side by the layer's own ratio; setting both in one edit leaves them as typed, an unlinked layer is unchanged, and a zero side has no ratio to keep. Fixes#220.
file.revert is refused while the file is in a live session (hosting, joined, publishing or reconnecting): reverting replaced the document's identity and broke its session. The refusal changes nothing, and reverting works again after leaving. Fixes#198.
SVG and PDF exports check the visible subtree's depth before bounds or drawing and refuse, with an actionable message, a document nested past 256 levels instead of silently leaving the deep layers out; nothing partial is written, and an inner layer can still be exported. The CLI and MCP report the same error. Fixes#208.
Also: on Windows, figcraft and figcraft-cli link with an 8 MB main-thread stack (Windows gives 1 MB where Linux and macOS give 8), so an export at the depth limit no longer overflows it.
Co-authored-by: bflatastic <bflatastic@users.noreply.github.com>
edit.repeat no longer records itself as the last command, so repeating keeps applying the command it repeats instead of recursing, and it refuses when there is nothing to repeat. Fixes#181.
The recovery copy of one of your online files is no longer discarded when
the file reopens from its server. It is kept until the outcome is known:
- the server has everything in it: the copy goes;
- the copy has changes that never reached the server and nothing else
changed there: they are applied on top as one edit and sent to everyone;
- anything else (others changed the file, the server forgot the client):
the copy opens beside the file as "<name> (recovered copy)", with a toast;
- the server can't be reached: the copy opens as an unsaved document.
The note beside a copy is written after it and records who the app was in
the file and the server's last change it had. The app joins again as that
client (the first hello resumes; a new sync client numbers its batches
after the ones the server processed), so the layers it made are its own to
send. The old copy is removed only once this run has copied what it became.
collab.recoverFile; a tooltip on the tab mark; docs.
A file put on a server without the server's key stays its owner's after its
tab closes or the app quits. The app records it with the preferences (server,
id, name, owner token, the owner's links, what others join with; at most 200;
validated when read back) and never shows the token: not in command results,
ui.inspect, logs or debug output.
- Home > Online lists your online files: open one as its owner (a new tab, or
its tab comes to the front), start and stop sharing, copy the code or link,
rename on the server, remove from the server (confirmed), forget here. One
request per server when the section opens; offline and gone states.
- collab.files, collab.openFile, collab.forgetFile; collab.manageFile takes
its server and token from the record and gains share / unshare.
- Server: Admin owned (a compatible addition within protocol version 2) says
what became of the files a list of owner tokens owns. Older servers leave
the list as recorded.
- Tabs mark files in a live session (shared, or open only to you); toasts
about a file that isn't in front name it; after a crash a recorded file
reopens from its server as its owner.
- Draft catalogs (de, fr, ja, ko, pt-BR, zh-Hans) translate the 71 strings
and 5 command labels they lacked and the new ones; es and es-419 follow.
- Docs: collab-protocol, server, mcp, control-protocol, parity pages, gaps,
ROADMAP.
component.hideWhenPublishing {ids?, on?} keeps this file's components and
sets out of the library it publishes. The model's sign of it is the name,
so the command writes it: hiding puts a "." before the name, showing takes
the leading "." and "_" away (one undo step, saved and synced like a
rename). The Assets panel's context menu on a local component toggles it
(checked while hidden) and the tile shows a crossed eye; also under
Libraries in the main menu.
- Cursors: the engine names a cursor for every tool, modifier and hover
target (rotate turned to its corner, pen states, eyedropper, comment,
copy on Alt, not-allowed while an instance refuses the edit); the UI shows
the system's or draws its own; guides and handles keep their cursors;
ui.inspect reports it.
- Panels: each edge drags its panel within limits, a double-click resets,
widths are per window and kept in the preferences (view.panelWidths); a
small window keeps its canvas.
- Outline mode: Include hidden layers and Include object bounds
(view.outlinesHiddenLayers, view.outlinesObjectBounds), drawn and
clickable; light lines on a dark page; check marks in the View menu.
- A file or page made in the dark theme has a #1E1E1E page (sessions
without an interface keep #F5F5F5); text drawn on a dark page is white.
- Tests for Enter and Escape on a selected text.
Online, the file is its owner's: others get links and codes of their own, and
Stop sharing (collab.stop) revokes those on the server. Whoever joined with
them is disconnected at once and told sharing was stopped (the session's end,
with their copy); the owner stays in the file, which stays on the server, and
Start sharing gives the same file new links and codes. Removing the file is a
separate, confirmed step under Advanced.
The join field is the shared text input: as tall as the Join button beside it.
The Share popover is one flow: Online (the file is published to the server in
Preferences and the server names its links with short codes) or Local network
(the app hosts; the code carries its address and the session's secret), one
Start sharing button, the code large with Copy code and Copy link, who is here,
can edit / can view, and Stop sharing. Join with a code is one field in the
popover, on the home screen and in the Join dialog, for a code of either kind
or an invite link.
Protocol (within version 2, new variants last): Resolve / Resolved and Admin
code. The server keeps a stretched hash and the wrapped token, never the code,
and limits wrong codes per address (with a lockout) and for everyone. Older
servers leave the links in place of a code; older apps join by link.
collab.share, collab.join {code}, code and share in collab.peers; the server
preference defaults to the built-in server and can be reset or turned off.
Config-gated (open_publish): anyone publishes without the server key and
gets an owner token (128-bit, shown once, stored hashed) that manages only
that file. Per-address limits (X-Forwarded-For only from trusted_proxy,
bounded tables), global caps, idle expiry, reports, persisted blocks,
files list --sort, check, GET /healthz on the same listener, startup
warnings. The app asks the server first, publishes without a key when it
is open, shows the owner token once, the host/terms line, the expiry date
and a report button; collab.serverInfo, collab.manageFile, collab.report
and pref.publishServer for MCP/CLI. Protocol additions stay within
version 2, gated like signals.
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).
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.
- 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).