The server's fixes for a refused batch walked every deleted layer's whole subtree again and
compared each document-level fix with all the earlier ones, so a batch deleting a deep chain took
quadratic time on the hub thread. Each subtree walk now stops at a layer already restored, and
document-level fixes are looked up and sent once per target. The fixes are unchanged.
A refused batch deleting a 20,000-deep chain plus 40,000 comment puts: 27.2 s before, 0.14 s after
(debug build).
Fixes#1063
* M8: large images on the canvas draw from a smaller copy (#1483)
An image shown smaller than its pixels draws on the canvas from a copy scaled down to 4096 or 8192 px on its longer side, cached beside the image in the same budget. Two 16000 x 10000 images (640 MB each) evicted each other on every frame that drew both; as copies they take 80 MB together zoomed out. Moving one over the other: about 2.1 s a frame before, 11 ms at fit and 15 ms at 30% after. Exports, thumbnails and views at 100% and closer draw from the image itself. Tests in large_image_tests; timings in large_image_bench_tests (ignored).
* M8: drop the ignored large-image benchmark; its numbers are in the PR (#1483)
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.
A missing family the suite's font store has (Google Fonts, Noto) gets a
Download button. The first one asks the user to allow downloading fonts,
then craft-libs' craft-font-store installs pinned, checksum-verified files
into the Craft apps' shared font folder on a thread of its own. The font
scan reads that folder; once installed the text fits the real font.
font.download / font.downloads drive the store as its agent: refused while
downloading is off, never able to turn it on. Desktop only.
The browser build's wasm was 25,982,540 bytes, past the gate raised for 0.12.1. It is now
23,915,213 bytes (22.8 MiB), 1.2 MiB under the restored 24 MiB gate:
- Catalog rows keep their plural forms behind a reference: Text is two words, not room for
four strings, across ~52,000 baked-in rows (-454 KB).
- The browser build compiles with wasm SIMD (apps/figcraft-web/.cargo/config.toml, wasm-opt
--enable-simd), so egui's glyph rasterizer leaves out its scalar SIMD fallback (-1.6 MB).
The plugin workspace is built from the repository root and stays without SIMD.
- Revert the gate raise (cb227c97); the hosting guide names the WebAssembly SIMD requirement.
* M3: shadows of images with transparent pixels follow their alpha (#1289)
A drop shadow on an image fill was hidden behind the layer's whole box, so a
transparent PNG's shadow only showed outside its bounding box. Fills that
paint their own alpha (patterns; images with transparent pixels or that leave
part of the box empty) now cast and hide drop shadows, and shade inner
shadows, by the layer's pixels, like groups. Opaque pictures keep their
outline. Each image is scanned for transparency once. Canvas, PNG, SVG and PDF.
* M3: the image shadow tests import their names explicitly (#1289)
* M1: Crop image from the Object menu, the right-click menu and the image fill row (#1464)
* M1: crop mode shows the image dimmed outside the layer and heavier crop marks (#1464)
* M1: the handle overlay draws gradients and crops in their own helpers (#1464)
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
Images carried inside a `.fig` message (`image.dataBlob` indexing the
message's `blobs`) were copied out before the message is dropped, one copy
per image hash. Nothing stops a file from pointing many hashes at one
blob: 100 hashes on a 250 MB blob made 25 GB of copies, an out-of-memory
abort from a small file.
The walk now records each hash's blob index, and `embedded_copies` makes
one shared copy per blob for every hash that names it, so the copies are
bounded by the message's own size. Each resolved image is still copied
once on its way into the document, where images are kept by content hash.
Regression: fifty hashes on one blob share one copy; a hash naming a
missing blob is left out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w
The Scale tool (and `transform.scale`) scales a layer's children whatever
their constraints. After the edit, overrides are recorded by diffing the
changed layers, and a size or place that changed while an ancestor resized
is read as what constraints give, so it isn't an override. For a scaled
instance that left its layers' scaled size and points unrecorded: the next
rebuild of the instance (a layer added to its main, say) put a 14 px
icon's triangle back at the main's 24 px size. Strokes already kept their
scaled weight, as a separate override group.
`scale_node` now marks `size` and `position` overrides on the layers it
scales below the scaled layer when they are layers of an instance and the
value changed. Undo takes them back with the rest of the edit.
The report's own sequence (clearing effects on the component holding the
icon) no longer reproduces on main; this covers the rebuild path that
still did. Regressions: a scaled instance through value edits and a
rebuild of its main; one scaled with the tool, with undo; a scaled icon
nested in a component and in an instance of that component, through edits
of both mains.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6Dyzof21BCrUsByhx2w
Translate 2508 interface entries from English, keep count-neutral plurals, and gate the web catalog on Arabic font readiness. Record AI assistance and pending native review.
AI-assisted-by: OpenAI Codex
A density now counts only within 0.005 of a whole 2x-4x multiple of
72 dpi. That covers the rounding of pixels per metre (144 dpi is stored
as 5669 or 5670) and rejects nearby resolutions: 141, 143 and 145 dpi
were read as 2x under the old 0.05.
Paste falls back to the decoded clipboard image when the clipboard's PNG
can't be read, instead of pasting nothing.
Also the line on what the objc2 crates are for, and the ROADMAP row for
the new placing default.
main split `codegen.rs` into `dev/codegen.rs` (the shell: `CodeLang`,
`code_for`, `Ctx`, the shared helpers) and one module per native target
(`css`, `html`, `swift`, `compose`, `arkui`), and added Vue and ArkUI from
other PRs. Flutter arrives as the tenth target, in its own module:
`dev/codegen/flutter.rs`, wired from `codegen.rs` (`CodeLang::Flutter`, `dart`
as an alias, rem left to the web targets), with `flutter_definition` /
`flutter_type` / `flutter_default` beside main's `arkui_definition`.
Every language list in the tree is a union of the two sides now: the
`inspect.code` and `inspect.componentCode` specs, `codeConnect` (parse, import
line, expression, template, path checks, messages), the playground CSS/HTML
assets, the 21 UI catalogues (main added eight after this branch: bn, fi, it,
ka, nl, pl, th, vi, each with Flutter in its own conjunction) and the docs.
Flutter is an extra code target; nothing main gained is dropped.
The reviewer's absolute-child bug (#1382-1403: a child that ignores its auto
layout parent fell through to `Place::Flow`) is fixed in `flutter_children`:
flow and absolute children are split, the absolute ones go in a `Stack` around
the Row/Column at the parent's own box (so their offsets stay the parent's and
the padding moves onto the line instead of insetting them), the line fills that
Stack (`Positioned.fill`, so `Expanded` still has a bounded width), and only
flow children now decide `CrossAxisAlignment.stretch` and `Expanded` — the
absolute child no longer joins the line's spacing or loses its offsets.
`flutter_lifts_a_child_that_ignores_its_auto_layout_parent_out_of_the_line`
covers both kinds of child in one auto-layout frame: a 50×20 flow child and a
30×30 absolute one asking for `fill` on both axes, in a parent with 4 pt
padding. It checks the offset, that the absolute child is out of the Row, that
no `Expanded`/stretch comes from it, and that the padding insets the line only.
ROADMAP and dev-mode-parity keep main's header (2026-10-26) and take this
branch's history row in date order (2026-10-16).
`cargo test`: engine 1391 pass, ui-egui 814 + 8 + 1 pass; fmt and clippy clean.
The one engine failure, `board_tables_tests::a_double_click…`, fails the same
way on a pristine `origin/main` here: this sandbox has no `craft-fonts`
checkout, so its text doesn't wrap and the row can't grow.
Branches of a file in a live session: create, edit, review, update from
main and merge while the file is in a session. The branch stays with its
author and makes ids in their own client space far above the main file's;
merging is one synced edit and one undo step; peers' edits merge against
the branch's own base and conflicts resolve as offline; a view link still
refuses merging; a branch made outside the session that adds layers is
refused with a reason. Branch history entries for creation and updates
from main. Fuzz and junk tests extended to export settings, guides and the
live case. Docs: gap 17, collaboration parity, ROADMAP, mcp, protocol.
* M8: widgets take text input and a property menu
Plugin API 4 (gap 6). A widget's text layer can be an editable field:
a double-click edits it like any text, and when editing ends the value
(cleaned, at most 1,000 characters) goes into the widget's state under
the field's name and its plugin redraws, as one undo step. A widget can
also carry a property menu of up to 8 actions, toggles and colour
choices, shown in a small bar over it while it's selected.
- widget.setText and widget.menu drive the same paths from scripts,
the CLI and MCP; widget.insert/list also report fields and menu
- descriptions, values and stored menus are validated as untrusted;
an API 3 plugin drawing a field or menu is refused, older hosts
refuse API 4 manifests; no new host imports
- SDK reads the new events; the Counter example gains a label field
and a menu (reset, steps of ten, a colour)
- tests for fields, menus, canvas editing, rollback, forged data and
live sync; junk sweep extended; labels in every command catalog
- docs: plugins, mcp, gaps, target-app-parity, ROADMAP, roadmap
* M8: widget property menu on egui 0.37
Id::new and Ui::id are deprecated in egui 0.37: the widget bar's area uses Id::unique and its buttons' interaction ids come from ui.scope_id().
* M11: a double-click a board object or a widget field takes, in a function of its own (Sonar)
* M12: board templates and tables
Five built-in board templates (retrospective, brainstorm, weekly planner,
flow chart, kanban board) placed as ordinary board objects in one undo step
(board.template, board.templates) from a toolbar picker with pictures, and
board tables made of auto layout frames (board.table, board.tableEdit; up to
50 x 20), typed into with a double-click and edited by row and column from
the bar over the selection. Docs: boards-parity, gap 24, ROADMAP, mcp.
* M12: board templates picker on egui 0.37
Id::new and Ui::id are deprecated in egui 0.37: the templates picker's area uses Id::unique, its buttons' interaction ids come from ui.scope_id(), and its test reads the board bar's area by Id::unique.
* M12: board timer and voting
A countdown everyone on a board sees (board.timer, board.timerState), synced as its end time or the
time left while paused, and voting sessions (board.vote, board.voteResults): votes per person, on
everything, stickies or the selection; clicks vote, your badge takes a vote back; your own votes
show while it runs, everyone's with a ranked list once it ends. Timer and vote sync as document
properties and ballots as one entry per voter; neither is an undo step. A pill at the top right
of the board, new Timer, Pause and Vote icons, labels and strings in every catalog, docs.
* M12: board timer and voting on egui 0.37
Id::new is deprecated in egui 0.37: the timer and voting area and its memory ids use Id::unique.
* M12: board.vote's start and ballot changes in functions of their own (Sonar)
* M12: boards dot grid, re-attaching connector ends, curved connectors
Board pages draw a dot grid in the canvas render, after the page colour
and under the layers (figcraft_render::Dots), thinned out when zoomed out
and coloured from the theme's ink against the page. A selected connector
shows its ends as handles; dragging one onto an object re-attaches it,
onto empty canvas leaves it free (board.reattach, one undo step, also
over MCP). Connectors can be curved, and elbows go around the objects
they join when the short way would cross them.
* M12: boards dot grid: blending a dot in a function of its own (Sonar)
class_name runs layer names through deunicode (new dependency: pure Rust
tables, zero deps, BSD-3-Clause, the slug crate's base) before its ASCII
filter, so Korean, Cyrillic, Latin-diacritic and CJK names become
readable ASCII classes in HTML/CSS/React/Leptos output, component roots
and data-* variant attributes: 로그인 화면 → rogeuin-hwamyeon, кнопка →
knopka, über → uber, 按钮 → an-niu (before, every one came out layer-,
layer--2, ...). Symbols-only names take the node's kind (.text, .frame)
instead of layer-, the 40-character cut lands on a word boundary, and a
leading digit keeps the layer- prefix. SwiftUI/Compose type names are
unchanged (their own sanitizer); ASCII-only names are byte-identical.
html_codegen_tests updated and extended.