* M6: .fig connector labels without a saved snapshot are kept, centred on their line
A connector with its own label text but no saved snapshot imports as a frame holding its line (a vector with the connector's stroke and ends) and the label as text centred on the line's middle by length, in the connector's fill paints or black; its background isn't known and is left out, as the import report says. The connector branch of layer_filtered moves into connector_layer. Gap 1.
* Docs: drop a status item the merge listed twice
* M6: a pasted image the clipboard doesn't carry shows as missing and is named (#1646)
The placeholder an import stores for an image it lacks is now striped, not flat grey; the fill keeps the image's name; .fig and paste give a fixed, translated note per image; edit.pasteDesignLayers returns missingImages, the paste's message names those layers, and a paste with notes opens the import report.
* M6: the missing-image placeholder's stripes stay clean when scaled, and a .fig gives one note (#1646)
* M6: pasted layers keep the colours of their shared paint styles (#1638)
A clipboard links a style by the guid it has in its library, which it doesn't hold, and by its library asset, whose copy it does hold. Mapper::resource_id now takes a guid only when the file holds a node by it, else the asset, so .fig import and paste agree; a link to a style that really isn't there keeps the layer's stored colours, with a note.
* M6: Swedish catalog: keep both new import notes after the merge
Builds on #1618. Variables::get and Variables::collection take an exact id before a name; stored mode ids go through Collection::mode_by_id (expressions, the prototype's mode switch); font family variables and the colour picker, prototype editor and panels read stored ids exactly. Ported from fix/stored-ids-exact.
The board connector router moves, unchanged, from the engine into figcraft-doc (route_points, RouteEnd) so the .fig importer can use it: an elbowed or curved connector saved without its path runs in right angles or one curve from the side of each layer it joins (the file's magnet, else the edge its point is on, else the side facing the other end). Straight connectors are unchanged. Gap 1.
The CSS export left out a colour calculation that resolves to nothing but still wrote an alias to
it as var(--name); where no block defines that property, whatever reads the alias is invalid.
CSS (and Tailwind) and Penpot now share one rule, Variables::flattener: an alias that leads to a
calculation some mode of its collection leaves out goes out as the value it shows in the mode,
or is left out when it shows nothing.
A colour calculation that resolves to nothing in a mode is left out of that mode's token set, but
an alias to it still went out as a {name} reference to the missing token. Such an alias, and any
alias leading to one, now goes out as the colour it shows in the mode, or is left out with the
calculation's warning when it shows nothing. A calculation left out in only one mode of its
collection counts too: a reference to it from another collection would dangle whenever that
mode's theme is active.
A colour variable's value in a mode can now be {"alpha": {"variable", "opacity"}} - that
variable's colour with its alpha replaced - or {"mix": {"from", "to", "t"}}, a per-channel blend
of two colour variables or literals over the stored values (in the document's colour profile),
alpha included. Calculations resolve lazily like aliases, so bound layers, mode switches,
instances and undo follow their sources; they compose, and a loop, or a calculation that reads
more than 32 variables in its collection's modes (other collections in their default modes), is
refused when it is set. Operands are stored by id and read by exact id, as stored references
are. Files keep them; REST, CSS, Penpot and .fig exports write the colour they show in each mode;
deleting a source leaves its readers on the colour they showed; library import and swap
re-point their operands. The Variables panel shows them with their formula and can swap one for
an alias or detach it to its colour.
The .fig import gave a file one decoded value per byte of its compressed canvas (at least 20M, at
most 128M). Dense legitimate files decode past that, further since every scalar field counts, and
the whole import was refused as "not a valid FigCraft file: .fig file: message: too many values".
The budget is now four values per compressed byte; the floor and both ceilings (128M, 32M on 32-bit
targets) are unchanged, so a small file that unpacks past the floor is still refused and no file
decodes more values than the ceiling allowed before. Generated 21 MB and 15 MB canvases (220,044
and 108,012 layers) and a 49.7 MB one budgeted at the ceiling (530,106 layers) now open whole
without warnings; the 49.7 MB one's test peaks at about 9 GB.
The REST import's layer walk (also the .fig import's second stage) took about 4 KB of stack a level, a megabyte at the 256-level cap, and overflowed a 1 MB thread. It now runs on a work list in the same order, fitting groups once their children are in. The .fig mapping stays recursive at about 1.8 KB a level (455 KB at the cap). Both 256-deep imports now run on a 1 MB stack; the cap stays 256 everywhere.
Each area (document, images, library, versions, branches) keeps its own cap and now also draws from one total per file open, 6 GiB (2 GiB on 32-bit targets), so a file can no longer make the app unpack about 10 GB. Read in that order, the document and the images up to their own cap fit, so a file whose images pass 4 GiB (#1313) keeps as many as before; images left out by the total stay in the file like those past the image cap. What passes the total is skipped (the document refused) with a note.
Variable and collection nodes are left out of the layer tree (variables are read from the node list), so an instance's expansion no longer walks them again for every instance outside the layer cap. A nested instance takes only the override rows addressed below it, found by range, and shares its outer instances' property values and slot contents instead of copying them. Crafted files: 13.8 s -> 21 ms and 40.5 s -> 98 ms; normal files map as before.
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* M6: FigJam .jam files open like .fig files (#1342)
A .jam is the same archive as a .fig (its canvas header reads fig-jam.), which the .fig reader already took; File > Open and its filter, drag and drop, file.import and the command line now take .jam too, and the home screen shows its thumbnail. FigJam layers come in as the .fig reader makes them (stickies and shapes with text as frames with their generated layers, other FigJam layers as frames or vectors with a note, unknown types left out with a note). Tests use a synthetic fig-jam. canvas and a .jam made from our own export.
* M12: a FigJam .jam file opens as a board (#1342)
The .fig reader marks a canvas with FigJam's fig-jam. header as FileKind::Board, so a .jam opens with the board toolbar, the dot grid and the board panels and saves as a board; a .fig stays a design file, and importing a .jam into an open file leaves its kind alone. Its objects stay ordinary layers (frames and vectors with the existing import notes). Builds on the .jam opening branch (#1586).
Properties were listed by name, so a rename moved one. Components now keep prop_order (the names as they were made; a rename keeps the place) and every listing follows it. Make slot opens a name field under the frame's Slot row; Enter makes the slot, Escape cancels, a taken name keeps the field open.
The header check from #1491 moves to figcraft_format::jpeg so the format crate and the renderer share it, and decode_thumbnail now refuses a progressive JPEG that samples a component more finely than its first (zune-jpeg 0.5.15 asserts on it; in the browser a panic aborts the page). A crafted file's thumbnail on the home screen and in recent files shows no picture instead of crashing. Regression test through decode_thumbnail and archive_preview.
Style links that name a library style by its asset key and version resolve to the file's copy of the style, as variable links already did. The linked text style beats the copies of its values below the row that links it; rows above that one still win. Override rows that restate a nested level (overrideLevel) apply at that level instead of in wire order.
* M6: .fig import keeps the file's saved line breaks while the text and its box are unedited (#1236)
The line breaker already keeps trailing spaces and the last glyph's letter spacing out of the fit test and doesn't round box widths; imported text wrapped a word early or mid-word because local fonts measure slightly differently from the file's. The import keeps the saved line starts (derivedTextData) on the text with the box width and a stable key of its layout content; layout follows them while both match and no line overflows its box by more than 3% (+1 px).
* M6: local check that imported text wraps at the line breaks its file saved (#1236)
Runs only when FIGCRAFT_FIG_SAMPLES names a folder of .fig files: each fixed-width text with saved breaks wraps there once imported, or falls back by the overflow slack; prints the counts.
- The entry size cap is figcraft_doc::ImageData::MAX_BYTES (256 MiB), the
cap the .fig, .sketch and REST importers already read images with (now
one constant), instead of 32 MiB: an imported image larger than 32 MiB
made the document unjoinable and its server snapshot unrestorable.
- History written before the checks (a restored or joined snapshot, a
replayed server log, a file published to a server) applies without the
image entries they refuse (docprops::without_refused_images), instead of
the file not opening or a restart dropping every edit after the image.
- A key that already holds an image can't be given other bytes: producers
key images by their bytes (the REST importer's placeholder keys always
hold the same picture), and merge treats a key's image as never
changing.
- REST import: an unresolved imageRef shaped like a content hash shares
the rest-image-missing placeholder (the placeholder's bytes don't hash to
it); ImageData::claims_content_hash is shared with collab.
- Tests: collab image_tests (4 new, size test uses a small cap), format
image_tests (1 new).
- Docs: collab-protocol.md, collaboration-parity.md, file-format-parity.md.
Image entries applied bytes to any key with no checks (audit COL-9): a
collaborator could poison the shared content-addressed image store, claim
absurd dimensions, balloon memory, or name an image `../../x`, which a
saved file then wrote as the archive member `images/../../x`.
- Every key must be 1–128 ASCII letters, digits, `-` or `_`:
`figcraft_doc::ImageData::is_valid_key`, promoted from the format
crate's private `is_valid_hash` so the file reader and collab share one
rule. The reader now also takes `-` and `_`, so the REST importer's
`rest-image-missing` placeholder survives a save and load.
- The REST importer's side of the same attack: an unresolved `imageRef`
that can't legitimately be an archive member name (path text, an
over-long ref) shares the `rest-image-missing` placeholder instead of
being keyed by its raw name
(`image_tests::a_path_like_rest_image_ref_never_names_an_archive_member`).
- A key with the shape of a content hash (64 hex chars) must be exactly
the lowercase sha256 of the bytes, the form `image_hash` writes; an
uppercase look-alike is refused. Other keys (svg FNV-128, REST
placeholders) claim no content address and only meet the rules above.
- A side is at most 65 535 px; decoded bytes at most 32 MiB, with the
base64 text capped before decoding. 0×0 stays valid: the format crate
stores it for sizes it can't probe.
- Refusals are Error::Invalid and reject their batch, so every replica's
store stays clean; catch-up snapshots apply through the same check.
- Docs: collab-protocol.md, collaboration-parity.md and
file-format-parity.md (rows, status lines, histories).
- Tests: collab image_tests (8), format image_tests: round-trip and the
REST placeholder gate.
A page kept its ruler guides in `NodeKind::Page`, and since frame guides
(#70) every layer also has its own `guides` field. Both are written as
`guides`, and on reading the layer's own field takes the key, so:
- a reopened page's guides landed in the layer field and were not drawn;
- saving that page wrote `guides` twice (the page kind's list, always
written, then the layer's), and the file no longer opened ("duplicate
field `guides`").
Every layer, pages included, now keeps its guides in the one layer field;
the key in the file is unchanged, so existing files open with their guides.
The field reads every `guides` list a layer has, so files already saved
with the key twice open again with both lists, in file order. This also
covers collab `Create` payloads from older peers.
Regressions: JSON round trips of page and frame guides, a page with two
lists, a bad list still refused (doc); save, reopen, save, reopen through
`.figcraft` and a document.json with the key twice (format). Checked in the
app over the control channel: guides drawn after two save/reopen cycles, and
a file with the key twice opens with all its guides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w