Open and Revert to saved recorded the file's size and modification time after reading it: a change written in between would be stamped as already loaded and a later save would replace it. Taking the stamp first makes such a change refuse the next save instead (CodeRabbit on #1374).
With a file open in the desktop app, figcraft-cli could change it on disk;
the next Save (or auto-save) wrote the app's copy over it and said
"Saved", losing the CLI's changes.
Each tab now remembers its file's size and modification time when it
opens, reverts or saves it (the Stamp library files already use), and
Save and auto-save refuse while the file on disk differs, pointing to
Revert to saved or Save as. Scripts pass `file.save {overwrite: true}`.
Save as and saves to an explicit path are unchanged.
Existing text keeps the named instance's optical size, so documents render as before; the
Sketch import stores the size as the text's opsz value instead.
A native file whose images together pass IMAGE_BUDGET (4 GiB, or 20,000
images) opens without the images past it. They are intact in the file, but
saving it again wrote the archive from the images in memory, so every
save, autosave or `convert` onto itself deleted them for good. A 7.6 GiB
`.fig` converted to a 7.3 GiB `.figcraft` reopened with 1,312 of its 2,337
images: one save would have dropped the other 1,025.
Loading now tells images left out for the budget apart from damaged ones
(`Loaded::images_past_budget`). A tab opened or reverted from such a file
remembers the file, and saving over it is refused with a message that says
how many images would be lost and to save under another name; saving a copy
elsewhere works, and the original keeps its images. Auto-save skips the
tab instead of retrying and failing every minute. `figcraft-cli convert`
and `run --in X --out X` refuse to write over their input the same way.
The same file is matched by its resolved path, so `./a.figcraft` and
`a.figcraft` count as one (`file_access::same_file`).
The note for each image left out for the budget now says it stays in the
file, which can't be saved over without losing it. Damaged and missing
images keep the old wording: they can't be kept, and saving drops them as
before. The doc comment on report_lost_images said saving always drops
the images it reports, which is no longer true for budget ones, so it went.
This stops the data loss; it doesn't make such files editable in place.
Loading images on demand would, and is discussed in the issue this links.
Tests: the count and the note for both budgets; saving over the file
refused with the file unchanged, Save As then working, and saving back over
the original refused again; a snapshot encoded in the background refused
too; a tab without images left out saving as before. Checked by hand on the
7.3 GiB file with file.save, file.saveAs, convert and run --out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Conflicts were only in the Change notes of docs/file-format-parity.md and
docs/target-app-parity.md: main's notes kept, the `.sketch` note added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A slice exported every layer of its page that showed in its area, whatever
its Contents only setting said. With Contents only on (the default), a slice
inside a frame or group now exports only that frame or group and the layers
in it. With it off, or for a slice on the page or in a section, the slice
exports everything in its area as before. The Export section's preview of a
slice follows its first export setting in the same way.
Conflicts resolved keeping both sides: `.sketch` stays in `is_document`
(now via `OsStr::to_str`); ROADMAP and the parity, roadmap and
file-format docs keep main's headers and rows with the `.sketch` notes
and history rows added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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>
Board files (File > New board) with sticky notes, shapes with text, connectors that follow what they join (straight or elbow, arrowheads, labels), marker, highlighter and eraser, and stamps; a board toolbar and a contextual bar; the Design panel hidden on boards, Prototype and Dev Mode refused there. Boards are ordinary FigCraft files built from ordinary layers, so saving, undo, export, MCP and live sessions work unchanged. File tabs, recent files and Open files show a pen nib for design files and a note for boards (original icons drawn in code).
Import lines for connected components inside generated layer trees; Compare changes against any saved version; a Dev Mode behaviour suite with an inspect-only canvas (menus, shortcuts, paste and Revert to saved refuse design edits; a drag under way is dropped on entering), Changed in the ready list, statuses only on top-level frames and sections; read-only plugins' panels from the Inspect tab.
Review: " . .name. . " kept inner dots at the edges; trim spaces and dots
in one pass. docs/cli.md and safe_file_name's comment give the 100 and
200 character limits.
export into a folder replaced every character outside letters, digits,
'-', '_', space and '.' with '_', so a frame called
"Homepage - desktop (live + verbeteringen)" became
"Homepage - desktop _live _ verbeteringen_.png". The app's
file.exportLayers keeps those names and only replaces what file systems
refuse. The CLI now uses the engine's safe_file_name, and docs/cli.md
says which characters are replaced.
Also adds my own entry to contributors/people.toml.
- --no-system-fonts holds for .sketch imports too: the CLI opts the
process out, so the import's font matching doesn't scan.
- Shared text styles get the installed family and platform tracking
their linked layers get.
- Each text run writes its letter spacing, line height and font axes,
neutral when it has none, instead of taking the first run's.
- The unsupported-drop hint and its catalogs list .sketch.
- A test sends damaged .sketch data through file.open and file.import.
- Stack items are matched to their source layers through a map.
- Docs: .sketch is a convert input; the Carbon kit's Sketch 47 edition
is refused (the 68 and 83 ones open).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Texts an auto layout places keep the layout's position (the next layout
pass would take the baseline move back). A faded layer's group takes the
layer's id, so stacks, links and overrides still find it; faded masks
stay masks. figcraft-format no longer depends on figcraft-layout.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The font database knows each face's PostScript name; the Sketch import
finds the installed face a file's font names (CourierNewPSMT is Courier
New Regular) and reads the family from the name only when it isn't
installed. Text without a fixed line height takes the line Sketch draws:
ascent and descent rounded to whole pixels, the line gap left out, a
fifth more for classic Mac fonts, the baseline where Sketch puts it
(measured on sketchtool renders of 80 fonts).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Groups as frames and graphics, stacks as auto layout (instances laid out
by their stack), corners and per-side borders from the style, pins and
sizing, tints (layeringType, fillColor overrides), text colour and
visibility overrides, blur lists (progressive, glass), inner shadows in
the shadow list, angular gradients starting on the right, text sizing,
and texts an override changed measured again on open.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Compared with Sketch's renders of a Sketch 64 file. Resized instances lay
out their layers the way Sketch does (measured: pins share the change in
proportion, a fixed size keeps its centre's share, and files before format
110 just scale); masters' backgrounds show in instances only when Sketch
includes them; swaps to a missing symbol keep the original; artboards
don't draw a style they carry. Apple's system fonts are found under the
names designs use (SF Pro, SF UI Text and Display, SF Compact), the
optical size follows the text size for named styles too, and imported
text keeps the platform's trak tracking as letter spacing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Text layers keep their string and styled runs (font, size, colour, line
height, letter spacing, decorations, text transform; ranges in UTF-16
units, clamped), paragraph alignment and spacing, vertical alignment and
how the box sizes. Fonts named by PostScript name become family and
style; the engine then matches families to installed ones ignoring
spaces (PingFangSC → PingFang SC). Fonts embedded in newer files are
named in the import report.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.sketch archives (document.json, meta.json, pages/*.json) open through
file.open, file.import, drag and drop, the CLI and MCP; files saved by a
Sketch older than 48 are refused with a message saying how to convert
them. Layers come in the next commits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`file.saveAs {data: true}` marked the file saved before the UI handed the
bytes to `Services::download`. When the download was refused, the toast
said "Couldn't download" but the document was clean, so closing it no
longer warned and the edits could be lost.
The data result now leaves the save state alone. A new `file.markSaved`
(the bookkeeping a native save already did: the file and its branch tabs)
runs from `save_as_dialog` only once the download was delivered. It ends a
version preview like the other commands that act on the document itself.
Tests: the engine save test checks the file stays dirty until
`file.markSaved`; `a_refused_download_says_so` checks the document is
still dirty.
Cause: in the browser, exporting several layers started one download
per file. Chrome holds back every download after the first until the
site is allowed automatic downloads, so the other files didn't arrive.
Fix: file.exportLayers takes `zip: true` (without `dir`) and returns the
files together as one base64 zip `data`, written by a new
figcraft_format::zip_files (the workspace's existing zip dependency).
The UI asks for it when the platform saves by downloads and hands over
one `<document name>.zip`.
Test: ui-egui several_layers_download_as_one_zip (one download, a zip
holding both layers). It fails on the previous commit (two downloads).
Cause: in the web build no file has a path, so Save goes to Save as, and
Save as and every Export go through Services::pick_save / pick_folder.
WebServices keeps the trait's defaults there, which return no path, so
the callback never ran: no download, no message. Only Variables ▸ Export
used the platform's downloads (#368).
Fix: where services.downloads() is true, those routes run their command
without a path and hand the bytes to services.download():
- engine: file.saveAs takes `data: true` and returns the .figcraft file
as base64 `data` with its `name`, marking it saved (no path, nothing
remembered). Programmatic Save as without either still asks for a path.
- ui: FigApp::download decodes a result's `data` and says when nothing
arrived; Save as, one-off export (PNG/JPG/WebP/SVG/PDF), .fig export,
frames to PDF and multi-layer export (one download per file) use it.
Tests: engine save_as_data_hands_back_the_file_and_marks_it_saved;
ui-egui web_save_tests (save, export, refused download) with the fake
browser Services from variables_export_ui_tests. The UI tests fail
without the fix.
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.