Commit Graph
64 Commits
Author SHA1 Message Date
bflatastic 789fa33444 Merge main into #634 2026-10-29 12:25:06 +07:00
bflatastic a5e8330aee Merge main into #1417 2026-10-29 10:29:08 +07:00
BFlat 4a0bd79797 Merge pull request #1488 from ChristopherLinnett/issue/1086-image-validation
fix(collab): image entries must match their hash and sane bounds (#1086)
2026-10-29 09:49:20 +07:00
BFlat baaafc381b Merge pull request #1485 from StrajnarFilip/claude/fix-1476-guides-reopen
M1: page ruler guides survive save and reopen (#1476)
2026-10-29 09:29:31 +07:00
Christopher Linnett 949d74f7ca fix(collab): image checks keep documents shareable and history replayable (#1086)
- 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.
2026-10-29 08:55:03 +08:00
Christopher Linnett f1c2ee81e4 fix(collab): image entries must match their hash and sane bounds (#1086)
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.
2026-10-29 08:11:13 +08:00
Filip StrajnarandClaude Opus 5.5 652bf35b19 M1: page ruler guides survive save and reopen (#1476)
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
2026-10-28 22:47:24 +01:00
Filip StrajnarandClaude Opus 5.5 d5ce10f0be M8: repair and library manifests check membership by set lookups (#1078)
Repair re-listed unlisted pages with a scan of the page list per page, and
reading a published library kept manifest styles, collections and variables
with a scan of the snapshot's lists per entry: a file with many pages or a
long manifest made both quadratic. Both now look ids up in a set built once.
validate() already indexed its parent/child edges and page list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6E2qDnjtMzfPVuEP7iH
2026-10-28 22:10:28 +01:00
Skyfuller1303 c785e96b1c Merge remote-tracking branch 'origin/main' into feat/fig-vector-region-fills
# Conflicts:
#	crates/format/src/fig/export.rs
#	crates/format/src/fig/map.rs
2026-10-28 10:12:00 +01:00
NetterSilverandClaude Haiku 5.5 f1264cf13e Reject '+' sign in NodeId::from_str
Found while testing crates/doc/src/lib.rs:64-71 (impl FromStr for NodeId), reached through crates/engine/src/automation/params.rs:78 (id_of)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 08:35:02 +00:00
Skyfuller1303 a4076caaf4 Merge main and preserve vector region paints through path operations
Retain regional paint stacks and coordinate frames through simplify, offset, flatten, bucket normalization and Shape Builder. Preserve legacy fill semantics and synchronize importer warning catalogs. Verified with cargo xtask ci on Windows and Ubuntu/WSL, native UI screenshots, and CLI round-trip/export checks.
2026-10-26 19:50:00 +01:00
bflatastic de826e14cf Merge main into #650
# Conflicts:
#	crates/ui-egui/locales/es.toml
#	crates/ui-egui/locales/pl.toml
#	docs/prototyping-parity.md
2026-10-26 23:24:22 +07:00
BFlat 4fad29a66e M12: board timer and voting sessions (#1361)
* 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)
2026-10-26 12:28:59 +07:00
bflatastic 2fd59da54c Merge main into #1275
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-26 09:15:49 +07:00
Dylan M. TaylorandClaude Sonnet 5.5 9f78875338 security: linear vector import, stroking and rounding, validation, and cyclic dev snapshots (#1306)
* security: linear vector import, stroking and rounding, validation and a cycle check for dev snapshots

Closing a subpath, rounding corners and chaining stroke runs each rescanned the whole
network per step, and Document::validate searched a parent's children per child. They now
update only the current subpath's last segment, rebuild each region loop in one pass, walk
per-vertex adjacency lists, and check edges against a once-built index. A dev snapshot whose
layers contain a cycle under the root is refused instead of reconstructed.

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

* fix(doc): address review findings for snapshot children and 2-segment rounded loops

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 21:17:36 -04:00
Ridzzz0Alam 4da6a11bad M1: render bounds stop at the depth the renderer draws (#1062)
world_render_bounds and children_render_bounds recursed once per level
with no limit, so a crafted chain of ~20,000 nested groups overflowed the
stack and aborted the app on PNG/JPG/WebP export, rasterize, copy as PNG
and thumbnails. A stack overflow is an abort, not a panic, so the guard
can't catch it.

The walk now carries a depth and stops descending at 256, the depth
draw_layer draws to (MAX_EXPORT_DEPTH): bounds match what is drawn and
shallower trees are unchanged.

Test: render_bounds_stop_where_drawing_stops_on_very_deep_chains (aborts
on the old code).
2026-10-24 11:26:27 +02:00
JimKnoxxandClaude Opus 5.5 545cd630fd M5: overlay positions relative to the layer that opens them
From the Figma forum request "Set overlay position relative to element,
not to page": an open overlay action takes anchor: screen (default) |
triggerInside | triggerOutside. With a trigger anchor the position preset
is measured on the layer that opens the overlay instead of the screen.
Outside puts top presets above it and bottom presets below it with the
edges lined up (a dropdown is Bottom left + Outside); Center centres it
on that layer. Manual is unchanged.

The interaction editor gets Relative to (Whole screen / Trigger layer)
and Inside / Outside. The player, the command (and so MCP and the
control channel) carry it; the command's params doc lists it. Figma has
no such setting, so .fig export writes it as a manual offset. Old files
and JSON without anchor read as screen and write unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 09:19:33 +02:00
BFlat e3e39bbc4d M6: Dev Mode measurements kept on the canvas, a read-only layers panel (#1248)
* M6: Dev Mode measurements kept on the canvas, a read-only layers panel

Measurements placed between two layers, along a side or to the layer
around one (devMode.addMeasurement / removeMeasurement / measurements),
stored on the layer they start from (saved, synced, undoable; old files
open without them), drawn in Dev Mode as labelled redlines that follow
the layers and listed, placed and removed in the Inspect tab. In Dev Mode
the layers panel only selects and expands.

* M6: Dev Mode measurements: explicit imports, a measurement row of its own (Sonar)
2026-10-24 13:33:26 +07:00
bflatastic 46b02d44e6 Batch r2-1 review: paste_nodes refuses two nodes mapped to one new id 2026-10-24 10:31:17 +07:00
bflatastic ef6a6c49b2 Merge #1185: Make is_ancestor fail closed on deep or looping parent chains (@Silvertree2010)
# Conflicts:
#	crates/doc/src/tests.rs
2026-10-24 09:21:37 +07:00
bflatastic 82004c6e9c Merge #1187: Cap the issue list returned by Document::validate (@Silvertree2010) 2026-10-24 09:21:24 +07:00
bflatastic 00074d0041 Merge #1186: Stop stale next_id from making pastes overwrite existing nodes (@Silvertree2010) 2026-10-24 09:18:16 +07:00
BFlat 8167f13a6a M12: Boards: a collaborative whiteboard file kind (#1229)
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).
2026-10-24 04:18:55 +07:00
NetterSilverandClaude Haiku 5.5 c857f8d0f3 Cap the issue list returned by Document::validate
Fixes #1058

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:30:11 +02:00
NetterSilverandClaude Opus 5.5 8aa456b198 Stop stale next_id from making pastes overwrite existing nodes
Fixes #1064

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:23:47 +02:00
NetterSilverandClaude Haiku 5.5 5a53425f51 Make is_ancestor fail closed on deep or looping parent chains
Fixes #1066

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:14:57 +02:00
MortenHustedandClaude Opus 5.5 be9e72f74d M3: instances follow the variable modes their main component sets
An instance copied its main's variable modes when it was made and kept
the copy as modes of its own. Switching it to another variant kept the
old variant's modes, and a mode set on the main later never reached it.

Instances now take the modes their main sets through the link
(`Document::inherited_modes`), closer than any frame around them. Modes
set on the instance itself still win and stay through variant switches.
Bindings are re-applied for the instances of a main that chose another
mode and for an instance that switched mains. Detaching keeps the modes
the instance took from its main, and the Variable modes panel shows them
as the instance's Auto value.

Instances made before this change keep their copy as modes of their
own; setting one back to Auto lets it follow its main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:15:34 +02:00
Andrii VolkovandClaude Opus 5.5 52eb736241 perf(render): look each layer up once per render
Node lookups in the document's B-tree were still a third of a render on a
900 000-layer file: a small subtree was walked for its bounds, again to
see whether it could be splatted, and again to splat it, and each child
of a drawn layer was looked up by paint_runs, by the mask check and by
draw_node.

- The render's table also keeps the layers it looked up, keyed with a
  multiply hash instead of SipHash (ids are counters), and so do the
  bounds it keeps.
- draw_children looks each child up once, works out the paint runs from
  those (Document::paint_runs_of) and draws the child it has.
- Whether a layer can be splatted is kept like its bounds: layers with
  children only, answers the nesting cap decided aren't kept, and kept
  answers are read only where their levels fit under the cap.

Ctx gets a second lifetime, for the document the kept layers come from.

Single-threaded cycles per 1440x900 render fitted to the page: 0.39 ->
0.34 G on a page of 246 204 layers, 0.26 -> 0.22 G on one of 118 358.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 13:20:53 +03:00
Andrii VolkovandClaude Opus 5.5 4484c2d1b0 perf(render): work out paint bounds once per render
Culling a layer asked for its paint bounds, which walk its whole subtree,
and then did the same for each child: every layer's subtree was walked
again at each level above it, and once more in each parallel band. The
bounds worked out during a render are now kept for it (layers with
children only; bounds the 64-level cap cut short are not kept, and kept
ones are read only where their levels still fit under the cap, so culling
is exactly as before).

Every render also looked through every layer of the document for the
largest background blur, to size the overlap of its bands: 900 000 layers
on one large file, on the main thread before the bands start. The
document now works it out when first asked after a change, like whether
it has masks.

A 1440x900 render on a 900 000-layer file, page of 118 358 layers: at
100% 13.0 -> 0.6 ms, at 25% 16.5 -> 3.4 ms, fitted to the page 56 -> 31 ms;
page of 246 204 layers: 12.6 -> 2.5 ms, 12.1 -> 2.0 ms, 69 -> 62 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
2026-10-16 12:31:55 +03:00
Skyfuller1303 4ecf6e2f39 M4: preserve vector region fills across .fig import and native rendering
A filled area of a vector network can now carry its own paint stack. A region without paints of its own keeps drawing with the layer's fills, and a region with an explicitly empty list draws nothing, so the two cases stay distinguishable. The canvas, SVG and PDF output, boolean and hit-test geometry, vector editing and region operations all honour the per-region paints and keep them when regions are split, merged or reordered.

The .fig importer resolves the individual paint stacks of a layer's filled areas through its normal paint mapping and hands them to the document builder as private mapper data. Public REST vectorNetwork JSON never consumes that data and its region paints are dropped, so only validated .fig input can set them. Invalid entries are skipped with an import note. Live style and variable links on individual regions and writing region paints in the experimental .fig exporter are not part of this change.
2026-10-16 05:07:05 +02:00
NetterSilverandClaude Haiku 5.5 a4f4869443 Point duplicated page navigation at the copied frames
Fixes #419

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:10:00 +02:00
midasmatterhorn 245837ff27 doc: name counters stop at u64::MAX instead of overflowing
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).
2026-10-14 22:52:15 +03:00
rcoenenandClaude Opus 5.5 6267ae5638 Vector edit mode: hover, hatch, select and drag filled regions (#243)
With the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour (1.4 px), clipped by the area's own fill rule so holes stay clear. The hatch goes when the pointer is 3.5 px from a point or 2 px from a segment (screen pixels at any zoom); between those clearances and the 6 px hit radius the area wins. Bezier handles, the transform box's handles and other edited layers' parts come first. Line spacing follows octave folding: 8 px at every power-of-two zoom, growing with the zoom and stepping back at sqrt(2) * 2^n. The pattern is anchored to the vector's origin, so it stays on the shape when panning, and several selected areas share one pattern. The last area listed wins where areas overlap, and only vectors with a visible fill have areas.

A click selects an area with its points; they move with it (drags, nudges, the Design panel) but keep the unselected look and show no handles. Shift adds an area or takes a selected one out (its own points go, points selected on their own or shared with another selected area stay). A press on a selected area drags everything selected, as one undo step; Escape puts it back. Drags show no hover hatch. Any other press without Shift drops the areas and the points selected only through them, and a click on an empty spot such as a hole clears them and stays in edit mode. Inside the transform box around points selected on their own, the box keeps its drag unless the area is already selected. Area indexes are dropped when the network's topology changes; moving points keeps them.

The network gets region walking, an inside test per region by its own fill rule, the region under a point, a region's points and a topology fingerprint (vector_regions.rs). The engine does hit testing and lays out and clips the hatch in screen space (vectoredit/regions.rs); the canvas only draws the lines. vector.edit.select takes regions, and vector.edit.state reports regions, regionVertices and regionHover. Tests: vector_regions_tests (doc) and regions_tests (engine), covering fill rules, overlap, curves, reversed segments, clearances at 100% and 400%, selection, drags, undo and cancel, and the hatch spacing at seven zoom levels, its direction, anchoring, coverage and clipping.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:17:17 +07:00
d61ccc633a fix(doc): reject and repair layer cycles and repeated children (#205) (#233)
validate() now reports children listed twice, pages with a parent, and
nodes not reachable from a page (cycles, detached subtrees). repair()
drops repeated children, clears page parents and removes every node not
reachable from a page in one linear pass, replacing the orphan loop.

Co-authored-by: Justin John <babyjjbabyjj549@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:31:03 +07:00
bflatastic 626a9eac08 M1: layers and assets panels behaviour suite and fixes
A behaviour suite for the layers panel and the Assets panel written from the public documentation
(table rows with one-line derivations; rows the documentation is silent on say so and pin today's
behaviour), and the fixes it found.

- Engine suite `crates/engine/src/panels_behaviour_tests.rs` (6 tests, about 50 rows): reordering
  and moving layers into frames, groups and auto layout, refused drops, renaming several layers,
  show/hide and lock rules, pages and dividers, Assets folders, hidden-from-publishing names, drops.
- Panel suite `crates/ui-egui/src/layers_behaviour_tests.rs` (5 tests, driven through egui
  headless): click / Shift range / Cmd-Ctrl toggle, Alt-click expand, Collapse layers, sweeping
  across eyes and padlocks, the Rename layers dialog, dragging pages, dividers.

Fixes:
- Several layers dropped together in the layers panel land together at the drop point (layers
  from apart split around it before).
- Collapse layers (Alt+L) keeps the rows leading to the selection open (it closed everything).
- The eye and padlock no longer vanish when the pointer reaches them (the row lost its hover to
  them, so clicks could miss); dragging across eyes or padlocks sets every row passed in one undo
  step. Layers inside a locked frame show a faint padlock.
- Several layers selected + Cmd/Ctrl+R / F2 / Rename open a Rename layers dialog (Match, Rename to,
  Current name / Number up / Number down buttons, start value, preview) instead of renaming the top
  row only.
- `object.batchRename`: `$n` counts up and `$N` down (they were swapped), `$nnn` pads, `start`
  sets where numbers start/stop, `regex: true` matches a regular expression with `$1`..`$9`,
  `` $` `` and `$'`, and codes work in the replacement too. Returns the new names.
- Pages: dragged up and down the list to reorder; a blank page named with a leading hyphen is a
  divider (drawn as a line, never opened, holds no layers); duplicates are named "Copy of ...";
  the last page that isn't a divider can't be deleted. `page.list` reports `divider`.
- Assets panel: components filed in folders by page, the frames holding them and the slash parts
  of their names (folders fold away; open while searching), tiles show the last name part;
  `component.list` gives each component's `folder` and `label` for agents.
- Publishing: a leading `.` or `_` keeps a component (style, collection) out of a library even
  after it was published, and removing the prefix brings it back (the previous publish's choice
  won before).

The engine gains the `regex` crate (already in the lock file through env_logger) for the
documented regular-expression matching.
2026-10-12 03:47:34 +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 e917d3c2e6 M1: frames, groups, sections and masks behaviour suite and fixes
**A frames, groups, sections and masks behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (frames vs. groups when resized, constraints, Cmd/Ctrl-resize, clip content, Frame selection, grouping and ungrouping, group bounds and opacity, X/Y in groups, click targets, drawing a frame or section around layers, dropping into and out of frames, where sections may go, resizing sections, top-level frames as screens and exports, Use as mask, what a mask covers, alpha / vector / luminance masks, groups as masks, clicks on masked-out areas, a mask group's box and exports) is a table-driven case with the result the rule implies and a one-line derivation; where the documentation is silent the case says so and pins today's behaviour.

**Fixes (the suite failed on 7 tests before them)**
- **Masked-out areas don't take clicks**: hit testing and Select layer skip what a visible mask hides (the nearest mask below a layer; for a group or frame used as a mask, what its layers draw), so a click there reaches what's behind. Checked only once something is hit, and skipped in files without a mask (`Document::has_masks`, worked out once after each change), so hit testing costs what it did.
- **A mask group's box and exports are the mask's**: a group's bounds and export area count a mask for the layers it masks (layers under the mask still count), so the selection box, W/H, snapping and PNG/SVG/PDF exports crop to the mask instead of the whole masked layer (`Document::box_children`).
- **Sections stay on the page or in sections**: moving a section into a frame or group (`node.reparent`, refused at the root in `Document::reparent`), dragging one over a frame (it lands in the section around the frame, or on the page), grouping or framing a section, creating a section in a frame and pasting one into a selected frame (it goes on the page) no longer put sections where they can't be.
- **Drawing a frame or section around layers puts them in it**: the layers fully inside the drawn box (its siblings; visible and unlocked; a frame never takes a section; not in auto layout) move into it, in their order, without moving on the page; one undo step.
- **Resizing a section leaves its contents** where they are on the page (no constraints in sections), from a field, a handle (also the left/top edges) or `transform.resize` with any anchor.
- **Cmd/Ctrl while resizing a frame leaves its layers as they are**: #133 (merged in) keeps their places; this branch also keeps their sizes (the frame is resized without running constraints, so a left & right or scale child no longer stretches and is only moved back).
- **X/Y are measured in the frame, not the group**: groups and boolean groups have no coordinate space of their own, so a layer in a group shows (Design panel, `node.get`, `inspect_document`) and takes (`node.set`, `node.create` with a group parent) X/Y in the frame the group is in, or on the page (`Document::frame_relative`). Rotated groups too.

Already there and now covered by the suite: constraints when a frame resizes, groups scaling their layers, clip content (drawing, hits, export size), Frame selection's size and place, grouping/ungrouping keeping world transforms (rotated groups too), nested groups, empty groups going away, group opacity on the composite, click / Cmd-click / double-click targets (frames in sections count as top-level frames), dropping by the pointer and pulling out, group layers staying in their group when dragged, top-level frames as prototype screens and export boards, Use as mask (bottom layer, "Mask group", at the topmost layer's place, single-layer toggle), masks covering every layer above them, hidden masks, alpha / vector / luminance masks, a group as a mask, masks in SVG and PDF exports.

No new commands or UI strings (no i18n changes); each edit stays one undo step.
2026-10-12 00:57:43 +07:00
bflatastic b65cc2e4fe M5: prototyping behaviour suite and fixes
**A prototyping behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule on triggers, actions, overlays, animations, easing, scrolling and interactive components is a case with what the rule implies and a one-line derivation; where the documentation is silent the case says so and pins today's behaviour. The model rules are table-driven tests in the engine; the player's are driven through egui headless (real pointer, key and timer input into the presenter) or on the player directly.

**Fixes (the suite failed on these before them)**
- **On drag follows its animation**: with a move, push or slide animation, only a drag that way starts it (a drag another way can scroll instead), and the animation follows the pointer while the button stays down, then plays on from there when it lets go (it played by time, and any drag direction fired it).
- **Key combinations**: a key trigger can be a combination (`Shift+K`, `Ctrl+Alt+Delete`, other spellings of the modifiers accepted); the modifiers held must match, so `K` no longer fires for Shift+K. The interaction editor's recorder records the modifiers held, and REST files' modifier key codes import as the combination (they imported as the key alone, with a warning).
- **Overlay timers**: an overlay's after-delay timer starts when the overlay opens, and again each time it opens (it was timed from when the screen under it arrived, so a toast opened late closed at once, and it fired only the first time).
- **Interactive components**: every instance plays the interactions on the variant it shows; an interaction on the instance with the same trigger wins. Before, instances played only interactions copied onto them when placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown (`proto_run::play_interactions`, on the player's copy).
- **Change to on nested instances** changes the instance whose variant set holds the destination (`proto_run::change_to_target`), not the innermost instance (an icon inside a button).
- **Springs**: the presets are physical springs (mass 1; stiffness / damping gentle 100/15, quick 300/20, bouncy 600/15, slow 80/20; they were ad-hoc curves), and every spring plays until it settles instead of being squeezed into the duration field's time; the settling time accounts for over-damped springs. The interaction editor shows the spring's duration in place of the duration field, with a tooltip.
- **Smart animate overshoot**: matched layers follow spring and back easings past their end and back (they snapped to the end once the curve passed 1).
- **Fixed layers on top**: fixed layers draw and take clicks above the content scrolling under them, wherever they are in the layer list (the file's order doesn't change).

**Missing documented setting, added**
- **Scroll to offset**: `{type: "scrollTo", dest, offset: [x, y]}` stops the layer that far from the top-left of the innermost frame scrolling it (room for a fixed header); X / Y fields in the interaction editor; files carry it only when set.
- Already there and now covered by the suite: click / mouse down / mouse up / while pressing / while hovering / mouse enter / mouse leave / after delay, nested hotspot priority and click-through, navigate (closing overlays), back with the reversed animation, restart, overlay stacking / swap / close / close on a click outside, change to with smart animate, reversed directional transitions, easing curves.

New strings go through `tr(...)` with `en.toml` / `es.toml` entries. No new command ids. The player still draws nothing while idle: the drag-follow only runs during an animation, and fixed layers and instance interactions are worked out once per layout, not per frame.
2026-10-12 00:57:43 +07:00
BFlat d01d18cd88 M2: auto layout behaviour suite and fixes (#130)
A 64-case behaviour suite derived from public documentation (sizing, padding, strokes, gaps, alignment, wrap, absolute position, grid, stacking) plus command-level tests. Fixes: frames never shrink below their padding, only the frame's own inside stroke counts when strokes are included (now the default for new auto layout), auto gaps stop at 0, grid fr tracks never go below their content, and canvas stacking (reverseZ) now drives drawing, exports and hit testing. New settings: evenly/around auto gaps, vertical wrap with a column gap, per-child grid cell alignment, strokes included/excluded and canvas stacking in the Design panel.
2026-10-11 17:53:11 +07:00
BFlat 09a3b74466 M8: canvas fixes: boolean hit testing, group drags, frame outlines and slices view, a flaky idle test (#123)
Clicks on a boolean group test its computed result, so a click in a subtracted hole reaches what is behind it. Layers inside a group, boolean group or instance stay in their parent when dragged (one shared rule). View > Frame outlines and Slices now draw. The idle test's flake came from the render worker clearing its in-flight flag before waking the UI; jobs now carry sequence numbers.
2026-10-11 14:25:25 +07:00
BFlat f62cb4b1f6 M4: text on a path and pattern fills (#118)
Text layers can follow a path (start offset, flip, alignment; edited in place; canvas, PNG, SVG, PDF; SVG textPath imports as real text on a path). A new pattern paint tiles a source layer in grid or hex rows with scale, spacing and alignment, live as the source changes (SVG pattern, PDF vector tiling pattern).
2026-10-11 13:25:33 +07:00
BFlat 08d91869cb M4: live boolean editing, arc fields, shape builder on strokes, one-sided widths (#111)
Boolean operands are editable in place with live results (per-operand operations, cached prefixes so a drag recomputes from the edited operand up); a Design panel Arc section; the shape builder takes strokes and open paths as outlined areas; variable width points can be wider on one side.
2026-10-11 10:04:25 +07:00
BFlatandClaude Opus 5.5 1444754f66 M9: collaboration polish: unique ids for styles, variables and comments; live text carets (#107)
- Styles, variables, collections, modes and comment threads created in a live
  session take ids from the creator's client space (figcraft_doc::ids: a
  number from the node id allocator, client_id << 32 | counter); outside a
  session ids are made exactly as before. The server refuses a new entry in
  another connected client's space (removed entries can be put back by undo),
  keeps removed entries in its saved state, and starts client ids above every
  space used by any kind of id.
- Others' text carets and selected characters show on the canvas in their
  colour at the glyph positions, with their name above the caret (no blinking,
  no frames of their own). Typing in a layer someone else is typing in says so
  (toast and tag); collab.peers reports who types where.
- Toasts wrap at a readable width.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:29:30 +07:00
BFlatandClaude Opus 5.5 4924512f4e M9: collaboration core: ids, change sets, server ordering, convergence (#103)
* M9: real-time collaboration protocol (design)

docs/collab-protocol.md: WebSocket sessions with a server-ordered,
per-property last-writer-wins document sync, client-scoped ids,
fractional positions for children, ephemeral presence (cursors,
selections, follow), per-user undo, hosting from the app first and a
self-hosted server next, and the implementation plan. ROADMAP gains M9.

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

* M9: collaboration core: ids, change sets, server ordering, convergence

Step 1 of docs/collab-protocol.md, no networking:

- Client-scoped node ids: Document::set_client_id makes alloc_id hand out
  client << 32 | counter; single-user files allocate as before; the id
  counter is no longer part of document equality.
- crates/collab (layer 2): the synced object/property model (every saved
  Node field, plus document-level styles, comments, variables, images,
  dev snapshots, library data), Change batches, an O(changed) diff, strict
  and lenient apply, fractional sibling positions, snapshots.
- Server (ordering, validation, rejects with fixes) and Client (optimistic
  view, replay under remote batches, author-side repair of derived data)
  as pure state machines; binary (version byte + postcard, JSON values) and
  JSON message forms, fuzzed decoders.
- Engine: normalize as the recompute hook, collab.diff.
- Tests: round trips over random real commands, an N-client convergence
  fuzz test over delayed in-order links, junk batches.

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

* M9: fmt after merge

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:03:03 +07:00
BFlatandClaude Opus 5.5 abe5ff3a1a M6: Dev Mode compare changes, focus view, annotation categories (#97)
- Compare changes: a ready-for-dev frame or section keeps a snapshot of itself from just before
  its first change (Document::dev_snapshots, saved only while changed; dropped on resolve, re-mark,
  other status or delete). `dev.compare {id?, show?}` diffs it layer by layer: added/removed
  layers, and property changes formatted like Inspect (`width 120 → 136`, `fill #FF0000 →
  #E03030`, text, parent, order, component, auto layout, typography, effects). The compare view
  shows before / now side by side or overlaid with a slider, changed layers outlined, the list of
  changes and Resolve change.
- Focus view: `dev.focus {id? | direction}` / `dev.unfocus`: one frame drawn and hit alone
  (render Options::solo), a bar with name, status, Compare changes, previous / next ready frame and
  exit; Escape leaves.
- Annotation categories: defaults plus the file's own with colours (`annotation.categories`,
  `addCategory`, `editCategory`, `removeCategory`), `category` on annotations, coloured callouts,
  a category filter and editor in the Inspect panel; stored backwards-compatibly.
- Shared widgets::icon_button / canvas_banner / dropdown_lead; has_status moved to the engine.
- Tests: devcompare_tests, devfocus_tests, annotation_categories_tests, doc handoff_tests, render
  solo_tests, UI dev_mode_tests.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:29:17 +07:00
BFlatandClaude Opus 5.5 c662e56de4 M5: drag to reorder prototype actions; variable mode lookups in expressions (#94)
- Interaction editor: actions (top level and inside conditional blocks) reorder by dragging
  their row's grip (list_drag, now also for lists shown in model order), one proto.action.move
  per drop; the open action stays open where it lands; Escape cancels a row drag without
  deselecting or closing what holds the list. The ... menu keeps Move up / Move down.
- Prototype expressions: `accent[Dark]` reads a variable's value in one mode of its collection
  (Expr::InMode) and `mode(Theme)` is the name of the mode a collection is in where the
  interaction runs (Expr::Mode); parsed, type-checked, displayed, stored by id (names accepted in
  JSON), evaluated with the layer's modes. REST import maps VAR_MODE_LOOKUP over a variable and
  one of its modes.
- Overlays opened through commands close on a click outside unless told not to, as in the editor.
- Collection::find_mode / current_mode replace repeated lookups.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:21:53 +07:00
BFlatandClaude Opus 5.5 3c63e6d7c8 M3: components at scale; Alt-drag to swap instances (#91)
* M3: components at scale; Alt-drag to swap instances

- Synthetic design system (engine `synthetic_ds`): ~300 components (icons; button, input,
  card, nav and badge sets of size x state x type x theme variants), boolean/text/instance
  swap/slot properties, instances nested four deep, text and paint styles, light/dark
  variables bound across it, and 2,000 instances with overrides at every level.
- Propagation: value, size and added/removed-layer edits of mains reach their copies
  directly; other edits rebuild each affected instance once, inner mains first; rebuilds keep
  unchanged layers shared; bound variables no longer touch unchanged layers; auto layout
  re-flows only where sizes or places changed.
- Fixes: values from variables (theme switches, bound radii) are no longer recorded as
  overrides; a detached variable on an instance layer is an override that survives main
  edits; exposed nested instances stay exposed; swapped nested instances keep their layer
  names and the ids/overrides of layers their own main adds; renamed nested instances keep
  their name when detached out.
- Instance swap properties have preferred values. `instance.swapAt` / `library.swapAt` and
  Alt-drop from the Assets panel onto an instance swap it (highlighted while dragging).
- Instances pasted into other files follow their mains (library copies shared, published
  components used from their library, other mains left behind as a missing main).
- Assets panel lays out only visible tiles and renders thumbnails lazily with a cache
  invalidated per changed component; instance panel lists components once per revision.
- `figcraft-cli perf --instances`, UI frame bench and tests in components_scale_tests.

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

* M3: the swap perf case drops on a glyph the hit test reaches

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

* Merge origin/main into feat/components-scale; weighted depth 79%

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:07:43 +07:00
BFlatandClaude Opus 5.5 fe550fc14a M6: PDF print options (bleed, marks, CMYK) (#92)
PDF export gains print options, saved on PDF export settings and taken
by file.export, file.exportLayers, file.exportFramesPdf, export.add /
export.update, figcraft-cli export and the MCP export tool:

- a bleed (points or a length like 3mm / 0.125in, up to 2 in) that grows
  each page and shows what lies past the frame: its fills reach out to
  the bleed edge and its children are clipped there instead; a print
  page of one frame is trimmed to the frame's box;
- crop and bleed marks: 0.25 pt hairlines outside the bleed in
  registration colour;
- MediaBox / BleedBox / TrimBox;
- CMYK colour: fills, strokes, text, gradient stops, images and
  rasterised effects converted from sRGB with the naive, deterministic
  K = 1 - max(R, G, B) formula; DeviceCMYK without an output intent (no
  CMYK ICC profile can ship in the repository), soft masks stay RGB.

The Design panel shows a PDF row's print settings as a summary line that
opens them; the Export dialog has print options for its PDF preset.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:50:29 +07:00
BFlatandClaude Opus 5.5 70b2666c90 M3: progressive blur, inner shadows under strokes, noise and texture in SVG (#86)
- Inner shadows paint over the fills and under the children and strokes, as documented (canvas,
  PNG, PDF and SVG alike; text fills every run before the inner shadow and the strokes).
- Layer and background blur gain a progressive type: the radius ramps linearly from a start radius
  at a start point to the end radius at an end point (unit-box points, like gradient handles).
  Rendered by blending a few geometric blur levels, each blurred only around the pixels that use
  it; bands and partial renders match one piece. Design panel type menu and Start/End fields,
  on-canvas handles while the settings are open, effect.add/effect.set params, startRadius
  variable bindings, scale tool, REST import (blurType, startRadius, startOffset, endOffset),
  Inspect row; CSS leaves progressive blurs out.
- SVG export embeds renders for what filters can't match: noise grains over the vector layer
  (crisp, blended), texture and progressive layer blur whole, background blur and glass from what
  lies under the layer among the exported layers. PDF and SVG share the renderer's helpers.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:20:07 +07:00
BFlatandClaude Opus 5.5 599495eef5 M5: build conditionals in the interaction editor; hotspot hints; inspect in the presenter (#82)
- Expressions as text: Expr::parse (variables by name, literals, and/or/not, comparisons,
  arithmetic, text joins) with plain-English errors, type checks for conditions and set variable
  values; display reads back as the same expression.
- New commands proto.action.add/set/remove/move and proto.conditional.addBlock/setCondition/
  removeBlock edit single actions by path, inside conditionals too; `expression` text accepted by
  these and proto.interaction.add/set.
- Interaction editor: an action list (open, move, remove, add), Add action > Conditional, if /
  else if / else blocks with a condition builder (variable picker, operator, value or variable) or
  an expression field with inline errors, nested actions, set variable expressions.
- Presenter: hotspot hints toggle (Options menu, proto.presentOptions {hotspotHints}), hotspots from
  the engine (proto_run::hotspots) and in ui.inspect; an Inspect panel showing the hovered layer's
  properties ({inspect}).
- Fields keep the keyboard on the click that opens them, and the Enter that commits a field no
  longer reaches the shortcuts.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:00:02 +07:00