- A branch keeps its own version history. On a branch tab, `file.saveVersion`, `version.autosave` and the `version.*` commands (list, preview, restore, rename, delete, duplicate) act on the branch's versions; the main file's history is untouched. The Version history panel on a branch tab lists the branch's versions (its banner gives way to the version banner while one is viewed).
- Branch versions are saved in the `.figcraft` file with the branch (`branches/<id>/versions/index.json` plus one snapshot each, the same layout as the file's own history; images shared; damaged entries skipped and reported; the snapshot byte budget is shared across branches).
- Merging adds a version to the main file's history (`Merged "<name>"`, after an autosave of main when it had changed); the merged or archived branch keeps its own history and opens with it.
- Fills, strokes and effects merge item by item in `figcraft_collab::merge`: different items changed on each side, or items added at the end on one side (or the same ones on both), merge without a conflict; the same item changed both ways, an item removed on one side, or different items added on both sides still conflict as the whole list. Both sides' writes become the merged list, so merging and Update from main write the same result.
Feature breadth ~84% (core ~94%), weighted ready ~63%, core ready ~72%, mainstream ~50%
(weekly areas 72.3%): earlier rows had moved area scores without carrying them into the
totals. Hours unchanged until the next full re-measure.
The tail of the text area, from the public documentation of the target app's text settings, with a table-driven behaviour suite (each case derived in one line; "silent" cases pin today's behaviour).
- **Lists per paragraph** (`TextData::paragraphs`, one `ParaList {list, indent}` per paragraph; empty when the layer has one list style at level 0, so old files load and save unchanged). The list buttons, Shift+Cmd+8 / Shift+Cmd+7, `text.listStyle` and Type settings act on the paragraphs the selection touches while editing (the whole layer otherwise). The per-paragraph list follows edits: a new paragraph copies the one it split from, joined paragraphs keep the first one's.
- **Nesting** up to 8 levels: Tab nests a list item, Shift+Tab moves it out; Enter on an empty item and Backspace at an item's start move it a level out, then out of the list. Typing `- `, `* ` or `1. ` at the start of a plain paragraph starts a list. Numbers count per level (1. a. i., then round again), bullets go • ◦ ▪; a plain paragraph, a shallower item or the other list kind starts the count again. Each level indents by one marker gutter; a nested bullet a font lacks comes from a fallback font.
- **List spacing** (space between two items of a list, in place of paragraph spacing), **hanging lists** (the outer gutter outside the box, item text lined up with plain paragraphs) and **hanging punctuation** (opening quotes and brackets outside the start edge of left-aligned and justified lines, closing punctuation outside the end edge of right-aligned and justified ones; hung punctuation takes no room when lines wrap).
- **Decoration look** per character (`TextStyle::decoration_look`, boxed, None by default): style (solid, dotted, wavy), thickness and underline offset (auto, px or % of the font size), skip ink (underlines break around descenders) and colour (its own style index in the layout, so canvas, PNG and PDF paint it without renderer changes). Decorations are drawn from `Decoration::path()` (bar, dots or wave) on the canvas, in PNG, PDF and outlined SVG.
- `node.set` keys: `listStyle` and `indentation` (range-aware with start/end), `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`, `textDecorationThickness`, `textDecorationOffset`, `textDecorationSkipInk`, `textDecorationColor`; documented in `KEYS` and docs/mcp.md. `text.edit.key` documents Tab.
- Inspect: `listStyle`, or `lineTypes` and `lineIndentations` per paragraph; `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration keys. CSS: `text-decoration-style`, `-thickness`, `-color`, `text-underline-offset`, `text-decoration-skip-ink: none` (browsers skip ink by default). Dev Mode rows: decoration style, list spacing.
- REST import: `lineTypes` with `lineIndentations` per paragraph (they collapsed to one list style for the layer before), `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration style, thickness, offset, skip ink and colour; instance override names for them.
- Type settings ▸ Basics: decoration style, thickness and offset fields (with Auto), Skip ink, decoration colour; List spacing; Hanging punctuation and Hanging list checkboxes; the list control acts on the edited paragraphs. New strings in en/es.
- Components carry the new text fields in the characters and text-style override groups; scaling scales list spacing and px decoration lengths.
- `TextData::path` is boxed (`Option<Box<TextPath>>`) to keep `NodeKind` under clippy's enum size limit after the new fields.
A behaviour suite for comments and version history written from the public documentation
(`crates/engine/src/history_behaviour_tests.rs`, 15 tests, about 80 table rows plus stepwise checks),
each row with a one-line derivation; where the documentation is silent the row says so and pins
today's behaviour. 4 of the 15 tests failed on the old code. Fixes, all in shared engine code:
- **Live sessions: comment threads made in the session couldn't be used.** Their ids come from the
creator's id space (e.g. 4294967297) and every thread command (`comment.reply`, `edit`, `react`,
`resolve`, `move`, `delete`, `markRead`, `open`, `link`) refused them as "out of range".
Comment ids are now parsed as ids, not small indexes.
- **Restoring a version lost newer comments.** Comments are a conversation about the file, kept
apart from its design history: restoring now keeps the current comments; a comment on a layer
the version doesn't have stays where it was (on the nearest surviving parent, or the canvas).
- **Restoring now keeps the replaced design in the history** as an autosave first, unless a
version of it was just saved or restored (so work isn't only in the session's undo history).
The checkpoint marker is now "none yet" for a freshly opened file and compares contents when
normalising copied the document.
- **A titled autosave becomes a named version** (`version.rename` clears `auto`), so it is no longer
dropped when autosaves make room.
- **Merging a branch adds to the main file's history:** the main file as it was (an autosave,
when it changed since its last version), then `Merged “<branch>”`.
- `version.restore` refuses up front for a view-only live session (nothing goes into the history).
Pinned where the documentation is silent: pins go to the innermost frame under them (not to a
leaf layer); copies, duplicates and duplicated pages come without comments; replying reopens a
resolved thread; only authors delete their messages (no accounts); stepping past the last thread
stays on it; comments show on the canvas by default; versions stay with whoever saved them in a
live session.
`crates/engine/src/collab_live_tests.rs`: the test `Room` is `pub(crate)` so the suite can run a
live session. `crates/engine/src/cmd/props.rs`: one line reformatted by `cargo fmt` (it was
unformatted on the base, which failed the fmt check). `crates/render/src/effects_behaviour_tests.rs`:
the paint suite expected an offset drop shadow to grow the export on every side, but the later export
suite made exports grow only where the shadow falls (40 x 50 for a shadow 10 down); the base failed
the gate on it, so the expectation now says "grows" (wider or taller). No new commands.
Docs: collaboration-parity, target-app-parity (area 16 85%/60% -> 85%/62%, 2-5 h -> 2-4 h; full
parity ~306-595 h -> ~306-594 h; percentages unchanged at this rounding), ROADMAP (also corrects the
stale area 16 row, 80%/58%/4-8 h), gaps (gap 4's landed list), mcp.md.
A behaviour suite for canvas navigation, rulers, guides, layout grids, outline mode and measuring
(`crates/engine/src/view_behaviour_tests.rs`, 18 tests, ~120 table rows and checks), written from
the public documentation with a one-line derivation per case; where the documentation is silent the
case says so and pins today's behaviour. Plus the fixes it found:
- **Pixel grid** shows from 400% (it waited for 800%). The threshold, pixel preview's resolution
and wheel panning (`ViewState::pixel_grid_shown`, `pixel_preview_scale`, `scroll`) now live in the
engine; the canvas calls them.
- **Guides belong to the page or a top-level frame.** A guide dropped over a nested frame goes to
the frame holding it (`guides::drop_owner`), `guide.add` refuses nested frames, and a frame moved
into another frame keeps its guides but they stop drawing, snapping and measuring.
- **Selecting guides:** a click selects a guide (`guide.select`, new command, Spanish label added);
Delete/Backspace removes it, the arrows across it nudge it (Shift: big nudge), Escape deselects;
right-click opens the context menu with Remove guide (`guide.remove` without an index removes the
selected guide). A click without a move no longer records an empty undo step.
- **Alt-drag** on a guide drops a copy; **holding Alt while dragging** a guide shows its distances
to the nearest edges inside the frame it crosses (`distance::from_guide`).
- **Layout grids:** columns/rows with a fixed width and count 0 are **Auto** (as many as fit; the
Count field shows Auto); grids don't show or snap on a **rotated frame**.
- **Outline mode:** shapes are hit on their outlines, so a click reaches layers drawn behind
(`geometry::hit_outlines`).
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}`.
The `.fig` import's tail (gap 1):
- **Editable vector networks.** Vector layers now open as vector networks instead of their outlines: points, curved segments with their tangents, filled areas (loops) with their winding rule, per-point corner radius and handle mirroring, and end caps taken from the end points' own styles (an arrow drawn on one end keeps its arrowhead on that end only). The network blob is decoded defensively (`crates/format/src/fig/network.rs`: bounds-checked reads, capped counts, indices checked, floats finite) and scaled from its normalized size to the layer's size. When the blob is missing or damaged, or an open path shows a fill that regions can't hold, the outline import is used as before (damage is reported in the warnings).
- **Layer kinds that used to be dropped** now import as the closest editable layers, each kind with one warning: sticky notes, shapes with text, code blocks, tables and table cells, stamps, media, widgets and slide containers become frames with their paints and children, their text added as a text layer (notes top-left, shapes centred); a non-rectangular shape with text gets its saved outline as a vector under the frame; connectors, highlights and washi tape become vectors (the network or outline when saved, else a straight line between the connector's ends: free points or a point of a sibling layer) with the connector's end caps; text on a path becomes plain text.
- **Variable prototype actions:** set-variable (literal, variable or expression values), set-variable-mode and conditional actions (if / else blocks with expression conditions) are mapped onto the REST importer's existing support. A condition that can't be read makes the whole conditional skip with a warning rather than turning into an "else".
- The REST importer gained two small extensions the `.fig` mapping writes: `vectorNetwork` (FigCraft's own network JSON, validated: sizes capped, coordinates finite and in range, references sanitized, else the outline with a warning) and per-end `strokeCapStart` / `strokeCapEnd`.
- Docs: gaps entry 1, file-format parity, target-app parity, ROADMAP and the MCP guide's `.fig` section.
**A shapes and vector behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (drawing with Shift and Alt, corner radius and which layers take one, point counts and star ratios, arcs, stroke joins and the miter angle, caps per end, boolean results and the look they take, flatten and outline stroke; entering and leaving vector edit mode, the pen, selecting points and segments, deleting vs. delete and heal, handle mirroring, the bend, paint bucket, cut, join and eraser tools, nudging and undo steps, what converting a shape keeps) is a table row or a step with what the rule implies and a one-line derivation; where the documentation is silent the row says so and pins today's behaviour.
**Fixes**
- **Rounded vector points were stored but never drawn**: a point's corner radius (edit mode's Corner radius field, `vector.edit.cornerRadius`) now rounds the corner between two straight segments on the canvas, in hit testing, booleans and every export (`VectorNetwork::rounded`, used by the fill and stroke paths); the radius shrinks to fit half of each segment, like a polygon's corners. A vector layer's own corner radius rounds every point. The points stay where they are in edit mode.
- **A boolean group's look follows its operation**: union, intersect and exclude take the top layer's fills, strokes and effects, subtract the bottom layer's (it was always the bottom one).
- **Alt draws a line or an arrow from its centre**, like the other shapes (it was ignored).
- **Stars keep to 3–60 points** (they went to 120; polygons keep 120, the documentation is silent); a junk `pointCount` is refused instead of becoming 5.
- **Delete and heal recomputes the curve**: the healed segment keeps its neighbours' tangents and passes through where the deleted point was (it reused the neighbours' handles, so healing over a circle cut deep inside it). Corners between straight segments heal straight, as before.
- **Shift-click on a selected point at a corner of the points' transform box takes it out of the selection** (it started a scale); a Shift-drag there still scales in proportion.
- **Outline stroke** turns the stroke's colour style into the outline's fill style, and the outline keeps the layer's opacity, blend, effects, constraints and visibility (they were lost).
- **The stroke panel's Miter angle shows degrees** (it showed the ratio 4 under that name; the default is ~28.96°); `node.set {miterAngle}` sets it and `get_node` reports a non-default join or angle.
- **Individual corners only for rectangles and frames**: polygons, stars and vectors have one radius (the toggle showed for them and set corners that weren't drawn).
- **Option/Alt+Shift+F flattens** (Cmd/Ctrl+E still does).
- **MCP `key` in vector edit mode reaches the edit tools first**, as the canvas does: tool letters, arrows, Delete / Shift+Delete, Cmd/Ctrl+J, Enter (Delete removed the whole layer; `X` was unknown).
**Missing documented behaviour, added**
- **Corner radius and star ratio handles on the canvas**: a handle inside each corner of a selected rectangle or frame, one inside a polygon's or star's top point, a ratio handle on a star's first inner point (`shape_handles.rs`). Dragging along a corner's bisector sets the radius of every corner (Alt: only that corner of a rectangle or frame, documentation silent), stopping where the corners meet; the ratio handle sets the inner radius. A readout shows the value while dragging; the handles hide under 48 px on screen, on locked layers, with several layers selected and in edit mode. One undo step per drag; `inspect_ui` lists them under `tool.shapeHandles`.
- **Count and Ratio fields** in the Design panel for polygons and stars (there was no way to change them in the UI).
- **Cut tool drags**: dragging across paths cuts them where the line crosses; parts that come apart move to layers of their own, each keeping its piece of the fill (`vector.edit.cut {from, to}`). A click still splits at a point or segment.
- **Paint bucket drags**: a drag fills or unfills every area it crosses the way the first one went (`vector.edit.paint {points}`), one undo step.
New strings go through `tr(...)` (the new fields reuse the existing Count / Ratio / Radius / Star / Polygon entries in `en.toml` and `es.toml`); the new fields have accessible names (an icon lead with its label). Each gesture and edit stays one undo step.
**A paint and effects behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (fill stacking and opacity, paint vs layer opacity, every blend mode per paint and per layer, pass-through and Normal containers, the four gradient kinds and their handles, stops, image scale modes, rotation and adjustments, the colour models and hex entry, the document colour profile, drop and inner shadows, showing shadows behind transparent areas, layer and background blurs, effects on groups, effect order, what exports include) is a table-driven case with a one-line derivation; where the documentation is silent the case says so and pins today's behaviour. Scenes are small and synthetic; pixel cases use tolerances (blurs are checked against the normal distribution with sigma = radius / 2).
**Fixes (13 render cases and 8 command/export cases fail on the old code)**
- **Pass-through and Normal containers**: a group or frame set to Normal now blends its children among themselves first (it never isolated), and a **clipping frame no longer isolates** its children: they were drawn on a fresh layer, so a Multiply child inside a frame (frames clip by default) met neither the frame's fill nor what is behind it. The clipped layer now starts as a copy of what lies under it and goes back in its place through the clip (a memcpy for rectangular clips, so no slower); backdrop effects see the right composite, also for a clipping frame inside a faded group. Same in exports: SVG gives Normal groups `isolation:isolate` and puts a clipping frame's clip on what blends instead of a clipped `<g>` (a clipped group is a stacking context, which isolates); PDF wraps Normal groups in an isolated group (`Node::isolates_children`, shared).
- **Plus darker** (linear burn) is drawn by its formula on the canvas and in pixel exports for paints, layers, shadows and noise (it was multiply); tiny-skia lacks the mode, so `fx::plus_darker` composites it and `paint::composite` routes every layer, shadow and noise composite through it.
- **Shadows hidden behind the layer**: a drop shadow that doesn't show behind transparent areas is now hidden behind what the layer covers, translucent and 0% fills included (it was cut out by the layer's alpha, so it showed through a 50% fill anyway); groups and layers whose pixels a layer blur or texture moves still hide it behind their pixels. One `shadow::Caster::new` serves the canvas, PNG and the PDF's shadow rasters. **SVG** never cut shadows out: its filters now composite them `out` of an image of the layer's outline (or `SourceAlpha` for groups), and the filter region reaches as far as the effects (a fixed 200% box cut off a small layer's long shadow).
- **Fill images cover the box to its edges**: an upscaled Fill (or covering Crop) image faded to transparent over a source pixel at the box's edge.
- **"Normal" meant pass-through** when named: `BlendMode::from_name("normal")` found pass-through first (its CSS keyword is also `normal`), so `node.set {blend: "normal"}` and the blend menu's Normal set pass-through. Labels now win, and the menu sends labels.
**Added**
- A blend mode menu for each paint in the colour picker (no pass-through for paints), sharing `right::blend_menu` with the Design panel's layer menu; strings already translated.
- Inspect (`node.get`, MCP) reports a solid paint's `blend` when it isn't normal.
**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.
**A styles and variables behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (what a style holds, creating from a selection, applying, editing, detaching, deleting a used style, organising by folders and order, descriptions; variable types, modes and the default mode, aliases, scopes, mode inheritance, bindings by field and type, deleting a bound variable, code syntax, number variables in pixels) is a table-driven case with a one-line derivation; where the documentation is silent the case says so and pins the behaviour.
**Fixes (10 of the suite's 18 tests failed on the old code, counting the missing settings below)**
- **Editing a styled property unlinks that style**: a fill, stroke, effect or layout grid list edited away from its style (through `node.set`, a paint row, an added fill, or a variable bound to it) drops the link, as text chars already did. Before, the layer kept a stale link and the next style edit silently overwrote the user's edit. Moving or renaming a layer keeps its links.
- **Applying a style replaces variables bound to what it sets** (the fill list for a paint style, the typography for a text style); other bindings stay. Before, the variable won again on the next edit.
- **Text styles go on text layers only**: applying one to a mixed selection leaves the other layers alone (they used to get a stray text style link); a selection without text is refused.
- **Deleting a bound variable unbinds the layers using it**: they keep the value they show; variables aliasing it take its values per mode (docs silent on aliases). Before, the binding dangled. `variable.deleteCollection` now does the same for aliases from other collections into it.
- **Bindings are type-checked**: a variable of a type the field doesn't take (a colour on `width`, a number on `visible`) is refused; scopes still only filter pickers. Number variables can fill text content (shown as text).
- **Aliases are checked when a variable is created** (`variable.create {value: {alias}}` took any name, of any type).
- **Style names** are trimmed per `/` folder part; empty names are refused.
**Missing documented settings, added**
- **Organising styles**: `style.move {styles, before?, folder?}` reorders and moves into a folder (renaming the style to match), `style.duplicate` puts "<name> copy" right below the original, `style.renameFolder` renames a folder's styles. The page's **Local styles** list (nothing selected) is now grouped by type and folder with swatches and text sizes: click opens the style editor, right-click offers Edit style / Duplicate / Delete, a row dragged by its grip moves (into the folder it lands in), right-click on a folder renames it.
- **Modes**: Duplicate mode (`variable.addMode {from}`), Set as default and Move column left/right (`variable.moveMode {collection, mode, to}`); layers on Auto follow a new default, layers set to a mode keep theirs.
- **Code syntax** per platform (Web, iOS, Android): `variable.update {codeSyntax}`, three fields in Edit variable, carried by the variables JSON (import and export); generated CSS and the CSS variables export use the Web name (`var(--x)` or `--x`).
- **Number variable scopes**: font weight, paragraph spacing, paragraph indent and text content (and `fontWeight`, `paragraphSpacing`, `paragraphIndent` bind with their scopes).
New strings go through `tr(...)` with `en.toml` / `es.toml` entries, new commands have Spanish labels; the new rows, fields and menus have accessible names. Each edit stays one undo step.
**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.
**A components and variants behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule of instances (what an instance overrides and what it can't, how overrides persist when the main changes, reset), variants (which overrides survive a switch, the default variant, property order, duplicate combinations), swaps (instance menu vs Assets panel), exposed nested instances, detach / reset all / push changes / go to main, resizing and auto layout instances is a table row with the values the rule implies and a one-line derivation; where the documentation is silent the row says so and pins today's behaviour. Nested instance overrides stay covered by `nested_override_tests`, slots by `components_tests`, accepting library updates with overrides kept by `library_tests` / `components_scale_tests` (#91).
**Fixes (9 of the 10 new tests fail on the old code)**
- **Overrides that were silently lost**: auto layout **padding**, **gap** and **alignment** (three override groups, so a padding override still follows the main's gap), **layout grids**, **export settings** and **prototype interactions** on layers inside instances, and every **text property** (resizing, paragraph spacing and indent, lists, max lines, OpenType features) were reset by the next edit of the main. They are override groups now: kept when the main changes that property, carried by variant switches and swaps, pushed to the main, reset per group, and listed in the Overrides section. REST imports map the matching `overriddenFields`. An instance's own export settings and interactions stay its own, like its place (as before).
- **What an instance can't override is refused** (rolled back with a message, one place for every path: commands, MCP, gestures): moving, rotating, flipping, nudging or aligning a layer inside an instance (auto layout items included: their place is the layout's), resizing it (text boxes keep their main's bounds; text whose box follows its content still grows), changing its constraints or resizing (hug / fill / min / max / absolute), and an instance's auto layout direction or other auto layout settings (only padding, gap and alignment are its own). Before, these became `position` / `size` overrides or were lost on the next main edit. Resizing the instance itself still works and its layers follow the main's constraints. Existing `size` / `position` overrides in files are still read and kept.
- **Variant switches** keep an override only where the layer's name path matches *and* both variants had the same value for that property; text layers keep all their changes (by name and hierarchy). Before, every same-named override survived, even where the new variant differs (a hover background overridden back to the default one). The instance's own overrides follow the same rule. Undo brings the dropped overrides back.
- **Alt-drop swaps from the Assets panel** (`instance.swapAt`, `library.swapAt`) keep only text layers' overrides; the instance menu's swap (`instance.swap`) still keeps every override on same-named layers.
- **Default variant = the top-left one** (was the first in layer order): new instances of a set, the set's Assets thumbnail, generated code, `component.properties` defaults and the panel's default dropdown. Picking another default moves that variant to the top-left corner (one undo step).
- **Hidden exposed instances hide their properties** (`instance.exposed`).
- **Push changes from a nested layer** goes to the outer instance's main, never into the nested instance's own main; it reuses the override copy code, so every group above is pushed too.
**Added**
- **Variant property order**: `component.editProperty {name, index}` moves a variant property; every variant's name follows.
- **Duplicate combinations**: `component.properties` reports `conflicts` (variants sharing one combination of values), and the component panel warns until each has a unique one.
- **Design panel**: Position, Rotation, Constraints, Dimensions / resizing and grid cell fields are greyed out (with a tooltip) for layers inside an instance; the auto layout direction, wrap, strokes / canvas stacking and add / remove auto layout are greyed out or hidden for instances. New strings go through `tr(...)` with `en.toml` / `es.toml` entries; disabled controls are announced as disabled.
## What
**Images are colour managed from their own profile into the file's colour space.** Until now every placed image was taken as sRGB. Now an image is converted once, when it is decoded (cached by image hash, filters and file profile, as before), from the space it says it is in into the file's working space (sRGB or Display P3), so a wide-gamut or tagged photo looks right on the canvas and in exports.
- **Where the profile comes from**: the embedded ICC profile of PNG (`iCCP`), JPG (APP2), WebP (`ICCP`) and TIFF (ICC tag); a PNG without one uses its `sRGB` chunk, or its `gAMA` (+ `cHRM`) chunks: `gAMA` 1/2.2 with sRGB (or no) primaries means sRGB, which is what PNG writers put next to an `sRGB` chunk; any other gamma/primaries become a matrix/curve profile.
- **Conversion**: moxcms, a pure-Rust colour management crate the `image` crate already builds (now a direct dependency of `figcraft-render`, same version and features: nothing new compiles). It runs matrix/TRC profiles (v2 and v4: `curv` gamma and tables, `para` curves, chromatic adaptation), LUT-based RGB profiles and grey (`kTRC`) profiles, so sRGB, Display P3, Adobe RGB, ProPhoto and camera/phone profiles all work.
- **Fallback**: no profile, a profile that isn't RGB or grey (CMYK, Lab), a damaged one, one over 4 MB or one moxcms can't build a transform for: the image is taken as sRGB exactly as before (same pixels, same filter order).
- **Exports**: the original file stays in the document. PNG/JPG/WebP exports carry the converted pixels tagged with the file's profile (unchanged mechanism); PDF exports still embed the original file, with its ICC profile; SVG exports embed the original file.
- **UI**: the image fill's details in the paint picker show *Color profile: Adobe RGB (1998)* (the profile's own name), or *Unknown — treated as sRGB*; strings through `tr()` with `en.toml` / `es.toml` entries. No new command ids.
- **Agents**: `image.place` / `file.placeImage` report `imageProfile` (the name, or `null` when taken as sRGB); `docs/mcp.md` updated.
Untrusted input: profiles over 4 MB are ignored before parsing, moxcms's own caps on curve and LUT sizes apply, colorants must be finite and a transform must build before a profile is used; anything else falls back to sRGB without panicking. The parser is swept with 600 mutated profiles (byte flips, truncation, absurd tag counts and offsets) through the whole decode path.
## Verified
- **Desktop app** over the control channel (own target dir, separate `APPDATA`, stopped by PID): placed a synthetic 200×200 PNG with an embedded Adobe RGB (1998) profile built by a script (v2, `curv` gamma 563/256, published D50 colorants) next to the same pixels without a profile; the screenshot shows the tagged one more saturated (as it should be: its values mean wider-gamut colours), the untagged one unchanged. The paint picker reads *Color profile: Adobe RGB (1998)* for the first and *Color profile: Unknown — treated as sRGB* for the second; `image.place` returned `"imageProfile": "Adobe RGB (1998)"` and `null`.
- **MCP**: `figcraft-cli mcp` (headless) placed both images, exported each layer to PNG in an sRGB file and in a Display P3 file, and read the pixels back: the tagged image's four patches match the published Adobe RGB → XYZ → sRGB / Display P3 matrices exactly (sRGB: (0,201,67), (230,79,56), (0,90,174), (129,129,129); P3: (0,197,84), (213,90,67), (14,88,168), (129,129,129)); the untagged image exports its original values in the sRGB file and the usual sRGB → P3 values in the P3 file; the P3 exports carry the ICC profile.
- **Web**: `cargo xtask wasm` passes (part of the gate).
- `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm).
## Tests
- `render` (`image_profile_tests`, 9 tests, every ICC profile built in code from published constants; no profile files): Adobe RGB PNG into sRGB and Display P3 against the published matrices (alpha kept, untagged pixels untouched); the profile read from PNG, JPG, WebP and TIFF; `curv` table and `para` curves; a Display P3 profile staying put in a P3 file and matching the file conversion in an sRGB one; grey `kTRC`; CMYK, junk, truncated and oversized profiles falling back to sRGB; PNG `sRGB` / `gAMA` / `cHRM` (sRGB-equivalent, linear, Adobe primaries, nonsense values); profile names cached and cleaned of control characters; the junk sweep.
## Not in this PR
- PNG `cICP` without `iCCP`, and GIF/BMP profiles, are ignored (taken as sRGB); FigCraft's own P3 PNG exports carry `iCCP` too, so they re-import correctly.
- The `image` crate drops the ICC tag of very small TIFFs (16×16 lost it, 64×64 kept it in testing); real photos are far larger.
- A PNG that has only `gAMA`/`cHRM` (no ICC) is embedded as is in PDF exports, where viewers take it as sRGB; the canvas and pixel exports convert it.
- Image adjustments on a profiled image apply to the converted values (on an untagged image they still apply before the sRGB → P3 conversion, as before).
- Progress docs: area 5 (Paint) 87%/66% → 90%/67%, 4–7 h → 3–6 h; feature breadth, ready for real work and full parity hours unchanged at this rounding (target-app-parity); PNG, JPG, WebP and TIFF rows in file-format-parity.
## What
**Layers panel from the keyboard** (two of the three open items of area 3, plus long-list QA)
- **F2 renames** the selected layer (a second key for `object.rename`, next to Cmd/Ctrl+R) from the canvas, after a click on a row, or from a layer row that has the keyboard (F6 mode, where the canvas shortcuts are off, so the layers branch of `a11y::keys` runs it). When the rename ends the row gets the keyboard back (`a11y::refocus_row`); Escape in the field cancels and stays in the panel instead of jumping to the canvas.
- **Shift+F10 opens the context menu** (`view.contextMenu`, a new command that only asks the UI): at the focused layer row when the layers panel has the keyboard (F6 or a clicked row), else on the canvas just below the selection (the middle of the canvas with nothing selected). The menu takes the keyboard like the other menus opened from the keyboard (`a11y::popup`), and Escape gives it back to the row or the canvas. The Menu key can't be supported: egui 0.36 has no key for it.
- **Keyboard box selection** (`select.area`, F8): for keyboard-only users, a selection box appears around the selection (or in the middle of the view); the arrows move it, Shift+arrows move its far corner (10 screen pixels a press), Enter selects what it covers exactly like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter takes nested layers), Escape or a click drops it. A toast and the canvas's screen-reader description say the box's size and the keys. With a box (`{x, y, width, height, add?, deep?}`) the command selects directly, for agents. The marquee's rules (top-level layers, frames only when covered, the children of the frame the box starts in, the focus view, Shift toggling) moved into `Session::marquee_within` / `marquee_selection`, shared by the pointer marquee and the command.
- **Long lists (10k layers)**: a test that a layer near the bottom of a 10k-layer list is revealed (parents expanded, list scrolled) and that F6 lands the keyboard on its row and the arrows walk on from it; the UI bench (`perf_ui_frames`) gained rows for expanding every layer, arrow keys down the expanded list, revealing a layer at the bottom and collapsing everything. Release, 10k layers: first frame with every layer expanded 1.22 ms, an arrow key 1.04 ms, reveal 0.30 ms, collapse all 0.37 ms, idle 0.17 ms.
**Stroke styles** (area 6)
- What the public documentation describes for strokes was mostly already in: colour styles apply to strokes from the stroke section's style picker (`style.apply {target: "stroke"}`, linked, following `style.update`, one undo step, `style.detach {which: "stroke"}` keeping the colour), and the stroke weight binds to number variables. A test now covers that path.
- Missing piece, added: **each side's stroke weight binds to a number variable on its own** (`strokeTopWeight`, `strokeRightWeight`, `strokeBottomWeight`, `strokeLeftWeight`): `node.set` keys (setting or binding one side turns on individual strokes with the other sides at the weight), the stroke-weight variable scope, the stroke override group for instances, and REST `boundVariables` import. The Design panel's per-side fields are bindable, two to a row like the corner radii so a variable's name fits.
New strings go through `tr(...)` with `en.toml` / `es.toml` entries, and the two new commands have Spanish labels. Shortcuts are only in `keymap.rs`. Nothing redraws while idle (the box draws only when a key moves it). Selecting from the box leaves no undo step; each stroke edit is one.
## Verified
- **Desktop app** over the control channel (`target/debug/figcraft.exe` from this worktree's target dir, `--control <free port>`, separate `APPDATA`, keys sent as real `ui.input` key events, stopped by PID): F2 on a selected layer opens its name field in the layers panel (screenshot), typing and Enter rename it; F6 to the layers panel, Shift+F10 opens the menu at the Avatar row with Copy focused (screenshot), Escape gives the keyboard back to the row, F2 + typing + Enter renames it and the row keeps the keyboard ("Profile"); Shift+F10 on the canvas opens the menu for the selected frame with Copy focused (screenshot); F8 shows a box around the selected frame, Shift+arrows stretch it over a second layer (screenshot with the hint toast), Enter selects both; a rectangle with the colour style "Brand/Accent" on its stroke and its top weight bound to a number variable (screenshot: the style chip in the stroke section, the red stroke thicker at the top, the per-side field showing the variable).
- **MCP**: `figcraft-cli mcp` (headless): created a paint style, `style.apply {target: "stroke"}`, `object.rename`, bound `strokeLeftWeight` to a variable and ran `select.area` over the layer; `inspect_document` shows the name "Outlined card", `styles.stroke: "S:1"` with the style's colour on the strokes, `strokeSides [2, 2, 2, 6]`, `boundVariables {strokeLeftWeight}`, and one undo step per command.
- **Web**: `cargo xtask wasm` passes (part of the gate).
- **Performance**: release UI bench at 10k layers (numbers above).
- `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm), after merging #131 and #132.
## Tests
- `ui-egui` `layers_keyboard_tests` (3): F2 from the canvas and from a focused row (the row gets the keyboard back, Escape cancels without leaving the panel); Shift+F10 at a focused row, on the canvas below the selection and after a clicked row, the menu taking and giving back the keyboard; F8: the box around the selection, the screen-reader description, Shift+arrows sizing, Enter selecting, arrows moving it without nudging layers, Escape, the middle of the view with nothing selected, a click dropping it.
- `ui-egui` `perf_tests::a_layer_revealed_at_the_bottom_of_10k_layers_gets_the_keyboard_on_its_row`, plus the new bench rows.
- `engine` `select_area_tests` (2): marquee rules through the command (negative sizes, frames, starting inside a frame, deep, add toggling, empty spots), the UI request with no box, bad boxes refused (the junk sweep covers both new commands too).
- `engine` `stroke_style_tests` (2): a colour style on strokes (link, undo, style edits, detach); stroke weight and per-side weights bound to variables following the frame's mode, `node.set` per-side keys, scopes and override groups.
## Not in this PR
- The Menu (application) key: egui 0.36 doesn't deliver it.
- Enter doesn't rename: on the canvas and a focused row it already selects children / activates the row.
- The keyboard box doesn't pan the view when it is moved off screen (zoom and pan keys still work while it shows).
- Shift+F10 does nothing while a toolbar or properties control has the keyboard.
- Progress docs: area 3 95%/78% → 98%/82%, 2–4 h → 1–2 h; area 6 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; percentages unchanged at this rounding; full parity ~323–621 h → ~322–618 h (target-app-parity, ROADMAP); ui-parity rows (context menus, keyboard box selection, layers from the keyboard); `docs/mcp.md` documents `object.rename` without a name, `view.contextMenu`, `select.area` and stroke styles and variables.
## What
**A text behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (Auto / % / px line height and how the leading is split, letter spacing in % and px, paragraph spacing and indent, auto width / auto height / fixed / truncate with max lines, vertical trim, decorations from the font's metrics) is a table-driven case with the numbers the rule implies and a one-line derivation, read from the face actually used so it holds on any machine with a font. CJK line breaking follows UAX #14 and the CJK line-start / line-end rules; caret steps follow UAX #29 grapheme clusters. Where the documentation is silent the case says so and pins today's behaviour.
**Fixes (6 of the 13 text cases fail on the old layout; the break and caret rules are new functions)**
- **CJK line breaking**: lines break between ideographs, kana and Hangul and before or after a Latin word next to them (before: no break before an ideograph that follows Latin, none after `。`, full-width forms, CJK punctuation or Extension B ideographs), never inside a Latin word, with kinsoku: closing brackets and quotes, `、。,.!?`, small kana, the prolonged sound mark and iteration marks never start a line (the character before moves down with them), opening brackets never end one. The ideographic space hangs at the line's end like a space. One small break-class table in `crates/text/src/breaks.rs`, no new crate.
- **Fallback fonts no longer move the baseline**: a style's ascent, descent, line height and cap height come from its own font, not the fallback face that drew some of its characters, so a line with CJK or emoji keeps the height and baseline of a Latin line (with a fixed line height the mixed line's baseline used to shift by the CJK face's taller metrics; with Auto the line grew). Decorations likewise use the style's font and size, so an underline runs at one height and weight through mixed scripts (and isn't thinned by synthesised small caps).
- **Paragraph indent applies to left-aligned text only** (documented); right-to-left paragraphs keep it when right-aligned (their start). The Type settings field is disabled with a hint otherwise.
- **Carets by grapheme cluster**: Left / Right, Delete and Backspace step over a whole emoji ZWJ sequence, flag, skin-tone sequence or letter with combining marks (they moved one code point at a time, leaving the caret inside an emoji and deleting half of it). A cluster is also shaped with one font, so a joiner or variation selector no longer splits an emoji sequence across faces.
- **Words by script**: double-click and word moves (Alt on macOS, Ctrl on Windows and Linux) take one script's run in text without spaces (`日本語` | `の` | `テキスト`), Hangul words end at spaces, and a Latin word keeps its combining marks (before: a whole CJK sentence was one word, and `cafe` + combining acute split at the accent).
**Missing documented setting, added**
- **Vertical trim** (cap height to baseline): `leadingTrim: none | capHeight` (`CAP_HEIGHT` from REST JSON) through `node.set`, a Standard / cap-height segmented control in Type settings, `inspect_document` and Dev Mode properties, CSS `text-box: trim-both cap alphabetic` in generated code. The box runs from the first line's cap height to the last baseline (auto height shrinks, auto layout parents follow); truncation counts the trimmed bottom; exports reach past the box so descenders below the last baseline aren't clipped. Canvas, PNG, SVG and PDF all draw from the same layout.
New strings go through `tr(...)` with `en.toml` / `es.toml` entries; the new segmented control reuses the Type settings one (accessible toggle names). Each edit is one undo step. `unicode-segmentation` (already in `Cargo.lock` through egui and winit) became a direct dependency of `figcraft-text`.
## Verified
- **Desktop app** over the control channel (own target dir, separate `APPDATA`, stopped by PID): a 200 px auto-height box of Japanese wraps with no line starting with `、。」` and `「` never ending a line (`…テスト` / `を折り返す、テスト` / `です。「括弧」は行` / `末に来ません。漢` / `字とかなとカタカ` / `ナ。`, the last `ナ` carried down with `。`); a Latin / CJK / Latin paragraph at 36 px line height is 108 px with evenly spaced baselines; the same text with vertical trim is 89.45 px (cap height 17.45 + 2 × 36), its box running from the cap height to the last baseline, and the Type settings control turns it off (108 px) and undo brings it back in one step; one Right arrow from after `a` in `a👨👩👧b` lands after the whole family emoji and Shift+Right then selects `b`; a double click selects the katakana run テキスト.
- **MCP**: `figcraft-cli mcp` (headless) built the same three texts; `inspect_document` reports 108 px for the 36 px paragraph and 89.42 px with `leadingTrim: capHeight` for the trimmed one (cap height of the headless font), the PNG export wraps the Japanese by the same rules and keeps the trimmed text's descenders, and `inspect.code` emits `text-box: trim-both cap alphabetic`.
- **Web**: `cargo xtask wasm` passes (part of the gate).
- **Performance**: text layout of a 2,250-char Latin and a 2,000-char Japanese paragraph wrapped at 300 px (release, median of 7 × 30 runs) before and after on the same machine: Latin 0.70 → 0.42–0.50 ms, Japanese 0.46 → 0.37–0.47 ms (within noise or faster: line metrics are now computed once per style change instead of per glyph). All-ASCII text skips grapheme segmentation.
- `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm), after merging #131 and #132.
## Tests
- `text` (`behaviour_tests`, 13 tests): line height Auto / % / px; leading split above and below every line (positive and negative); letter spacing % / px / negative; paragraph spacing and indent by alignment; resizing and truncation with max lines; vertical trim (heights, first baseline, glyph spacing kept, truncation); decorations from the font's metrics through mixed scripts; fallback fonts keep line height and baseline (Auto, px, %); 22 break-opportunity cases; CJK wrapping with kinsoku, Latin words in CJK and Korean; the ideographic space hanging; grapheme steps over ZWJ families, flags, combining marks, skin tones and emoji presentation; one face per cluster. Font-dependent cases skip on a machine without fonts.
- `engine` (`text_behaviour_tests`, 5): arrows, Shift+arrows, Delete and Backspace by cluster; double-click and word moves by script (and through `keymap::text_motion` for Ctrl and Alt); IME composition with CJK wrapping and growing the box before commit; vertical trim through `node.set` (box size, PNG ink at the top, export bounds below the box, SVG and PDF, CSS, inspect, undo, junk refused, REST spelling); paragraph indent only on left-aligned text.
- `format` REST import test covers `leadingTrim: CAP_HEIGHT`.
## Not in this PR
- Documented but not modelled yet: list spacing, hanging quotes and hanging lists, underline style / thickness / offset / skip ink.
- Documentation silent, behaviour pinned: Korean wraps by syllable (no word mode); Han compounds next to each other are one word (no dictionary segmentation); letter spacing is added after the last character too; a fallback font's taller glyphs may overflow the line rather than grow it.
- `.fig` import doesn't read vertical trim yet (the field wasn't checked against real files).
- Spell check (on hold, #113).
- Progress docs: target-app-parity area 10 85%/64% → 87%/68%, 7–13 h → 5–10 h with the suite listed under text editing depth; feature breadth and ready for real work unchanged at this rounding; full parity ~323–621 h → ~321–618 h (target-app-parity, ROADMAP); ui-parity text row.
A selection and transform behaviour suite derived from public documentation, with fixes: Alt-resizing shrinks as well as grows; groups, Scale-constrained frames and multiple selections resize each layer along its own axes keeping rotation and flip; several layers flip together past an edge; a flipped layer reads back the typed rotation; resizes land on whole pixels with the pixel grid; presses inside tiny layers move them; locked layers get no handles; a marquee can start on a top-level frame's background; select inverse skips hidden layers. Adds equal-spacing snapping with labelled gaps and Cmd/Ctrl to ignore constraints while resizing a frame.
.fig files open through file.open, file.import, drag and drop, the dialogs, the CLI and MCP. A generic reader written from the public format description decodes the zip, the deflate/zstd chunks and the kiwi schema and message, then maps nodes by field name onto the REST importer: layer tree, geometry, paints with styles resolved, strokes, effects, text runs, vectors as outlines, auto layout including grid, constraints, components, sets, instances expanded from their main with overrides, styles, variables with modes and bindings, prototyping and image fills. Unsupported layers come back as warnings. Fuzzed; tested on fixtures generated in code. New dependency: ruzstd (pure-Rust zstd decoder).
Found and fixed in a Windows pass of everyday workflows: unsaved documents are now copied in the background (at most every 10 s) and offered back after a crash or forced quit (file.recover); Ctrl+arrow / Ctrl+Backspace / Ctrl+Delete text motion follows Windows and Linux conventions (keymap::text_motion); number-field scrubbing keeps its value; typing in a Tab-focused field works; default linear gradients and their handles behave; Escape closes dialogs without deselecting; clipped panel hints wrap; the unsaved-changes dialog is translated.
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.
True wide-gamut display is blocked upstream (egui-wgpu 0.36 always picks an 8-bit sRGB surface and draws sRGB-encoded UI), so the colour picker warns when a Display P3 colour is outside sRGB and View > Soft-proof sRGB stripes the canvas pixels sRGB can't show. Exports keep wide-gamut colours as before.
File > New window, Open in new window on a tab, and dragging a tab out open files in separate OS windows (egui deferred viewports, redrawn only when needed), sharing one session, preferences, libraries and clipboard. Closing a window asks about unsaved files (the main window now asks too); closing the last window quits. window.new / window.list / window.focus and per-window screenshots for the control channel and attached MCP. The browser keeps one window.
UI strings come from per-language TOML catalogs compiled in (English text as the key and fallback, named placeholders, one/other plurals, context suffixes); command labels are translated at display time while ids, CLI, MCP and control output stay English; accessibility names follow. Preferences > Language (System / English / Español, live; the system language from the OS or the browser), pref.language for scripts. A key check test guards the catalogs. The Spanish catalog is a machine draft awaiting native review; docs/translating.md explains adding a language.
The desktop app joins wss:// sessions (rustls with webpki roots, certificates always checked, clear errors; FIGCRAFT_CA_FILE for private authorities), with one TLS session shared by the reader and writer threads. figcraft-server stops gracefully on SIGTERM/SIGINT/SIGHUP/Ctrl+C, ships in Linux x86_64/aarch64 release archives, and docs/server.md gains a Deploy on a Linux server guide with a hardened systemd unit, Caddyfile, nginx config and Dockerfile in packaging/server.
Plugin ABI v2: a plugin can show a panel (text, number, checkbox, select, colour, list, notes, buttons) rendered with FigCraft's widgets, react to events with its state kept between calls (each editing callback one undo step, full sandbox limits on every call), and store per-plugin settings (64 KB). plugin.ui / plugin.event / plugin.close drive panels from the CLI and MCP; v1 plugins run unchanged. The Swatch grid example gains a Palettes panel that remembers the last palette.
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.
Components (or sets) can be linked per language to a real code component (import path, name, and a template whose {prop} and {prop ? "a" : "b"} placeholders take the instance's property values, escaped per language). Dev Mode's Inspect panel shows the connected snippet with its import for instances, a Connect to code editor with live preview, and read-only plugins. Links export to and import from figcraft.connect.json.
Strokes can use a brush: eight presets drawn in code (four stretched along the path, four scattered with spacing, size, rotation and offset jitter from a fixed seed), drawn by the shared stroke-outline code in canvas, PNG, SVG, PDF and outline stroke, with caps on copies and steps. Pointer events carry pen pressure; the Pencil records it as the stroke's width profile, which brushes follow (Windows pens and browser styluses; constant width elsewhere).
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).
A file's library can be published to figcraft-server and enabled in other files by a share link (view access is enough); placing, styles, variables and library updates with overrides kept work as for local libraries, with a background check while the Assets panel is open. library.swap re-points instances, styles and variables to another library by name (one undo step, unmatched items listed). The server keeps per-file usage reports (library.usage).
Branches live inside the .figcraft file (base and head snapshots, own id space). Update from main and merge are three-way merges over the collaboration change sets; a conflict (same property changed on both sides, or delete vs edit) must be resolved branch-or-main before merging, which is one undo step on main. The review dialog reuses Dev Mode's compare view; branch tabs show a banner; File > Create branch; branch.* commands for CLI and MCP.
Animated GIF image fills keep their frames (first frame on the canvas and in exports; the presenter and preview play them, capped frames and memory, idle when not playing). TIFF images open, import, place and drop (pure-Rust decoder already in the tree); SVG exports embed TIFF and BMP as PNG. HEIC and video stay out (non-Rust decoders).
The last three catalogued menu items got commands: File > Open recent (recent files kept in preferences, capped, missing files greyed and dropped when picked, Clear recent), outlines for the selected layers only, and mask outlines. Menu breadth 100%.
Library collections can be extended in a consuming file (inherited modes, per-mode overrides with reset, extra variables; library updates keep overrides). Variables export to the REST JSON shape or CSS custom properties and import back (merged by name, validated by the REST reader, one undo step). Gradient stops and variant properties bind to variables. Also fixes clicking a gradient stop adding a new stop.
figcraft-server hosts many files over the same hub the app uses for sessions, persists each as a checksummed snapshot plus an append-only log (synced before replies, compacted, damaged files skipped and reported) so restarts lose nothing confirmed, and gives per-file share links with edit or view access (hashed 128-bit tokens, revocable). The app can publish a file to a server and join server-hosted files; view-only tabs refuse edits. Protocol version 2.
The home icon's roof stays centred (unhinted outline) and its floor stops at the door; the moon's arcs stop short of the cusp so the tips no longer fold.
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.
- 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>
- Browser client (step 4): the web build joins sessions a desktop app hosts
through the browser's WebSocket API (web-sys, binary frames, the same
message encoding). Its connection logic is a target-independent state
machine (collab-net driver.rs): hello on open, changes and presence held
until the welcome, throttled presence, keep-alive pings, a silent host
counted as dropped, reconnect with backoff, final refusals. Tested natively
against a scripted socket and a real host over loopback. Hosting stays
desktop-only (LiveLink::can_host; collab.peers canHost). https pages are
told up front that they can only reach ws:// sessions on this computer.
- Shared join logic (join.rs: GuestConfig, outcomes, taking in host messages)
and transport limits (limits.rs) used by the native guest and the browser.
- Invite links: `figcraft figcraft://join?...` joins on start (a bad link
opens the Join dialog with it); file:// arguments are paths. Windows
registers a per-user figcraft:// opener (HKCU\Software\Classes\figcraft)
behind a Registry trait (tests use a fake): collab.registerLinks {on?} and a
Preferences checkbox, default on for installed builds, never for builds
from source. macOS Info.plist declares CFBundleURLTypes; the Linux desktop
entry declares x-scheme-handler/figcraft and passes %U.
- Join dialog: Escape closes it and no longer reaches the canvas (selection
kept); its field has the keyboard when it opens.
- Docs: collab-protocol (browser client, links, mixed content), mcp,
control-protocol, README, web hosting notes, collaboration wording in the
progress docs (numbers unchanged).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The 0.3.0 web release failed packaging/web/package.sh's 24 MiB gate: the wasm grew from
22.2 MB (0.2.0) to 27.0 MB once the plugin sandbox (wasmi + wasmparser, #101) and the
collaboration crates (#103-#105) joined the browser build, which still used the desktop
`release` profile (opt-level 3, thin LTO, 4 codegen units).
- Cargo.toml: new `web-release` profile (inherits release; opt-level "s", fat LTO, one codegen
unit, no debug info, stripped). opt-level "z" measured 16.9 MB; "s" keeps render speed.
- apps/figcraft-web/index.html: build with that profile, and pass the wasm features rustc emits
(bulk memory etc.) to wasm-opt, which otherwise rejects the size-optimised module.
- Version 0.3.0 -> 0.3.1. docs/releasing.md: the web profile and size history.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Release prep: bump to 0.3.0; add linux-riscv64 release job
- release.yml: new linux-riscv64 job (cross-compiled on ubuntu-24.04 against ports.ubuntu.com
riscv64 libraries, tar.gz only, CLI smoke-tested under qemu-riscv64), ported from GridCraft;
the release job needs it so SHA256SUMS covers its tarball.
- packaging/linux/package.sh: CROSS_ARCH / CROSS_TARGET / CROSS_COMPILE / EMULATOR support.
- Version 0.2.0 -> 0.3.0 (cargo xtask version set), Cargo.lock refreshed.
- docs/releasing.md, docs/hardware-parity.md: list the riscv64 tarball.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* CI: tell actionlint about the org's github-ubuntu-16core runner label
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M9: collaboration: per-user undo, rejections, reconnect
Step 3 of docs/collab-protocol.md, the parts that don't need the transport:
- Per-user undo and redo (figcraft_collab::Client::{undo, redo}): each undoable batch is a step
(values before/after by target, nodes created/deleted). Others' batches ordered after the step
take its targets for good; creates invert to deletes (not when others built inside), deletes
re-create the subtree. An undo or redo waits for every earlier batch and is remade from the
confirmed state when it goes out; derived data follows as a follow-up batch.
- Rejections: refused batches leave the history (with an undo waiting on them) and queue a
Notice; the engine shows it through Session::message (the UI's toast).
- Offline queue and reconnect: Hello.resume {session, client_id, seq}; the server keeps a
bounded log and answers with the missed batches or a snapshot, plus what it did with the
client's batches (processed {last_batch, refused}); queued batches then go out.
Server::state / restore persist the session (client-id counter, log, session id).
- Presence carries editing_text {node, caret, selection}.
- Engine: documents in a live session (LiveDoc, Session::collab_*): commands, gestures and
groups sync as one step each; edit.undo / edit.redo route to the per-user history; remote
edits show under a gesture in progress. Outside a session snapshot undo is unchanged.
- Sim: dropped connections, undo/redo, and a checker that no undo reverts a change by someone
else its author had seen. New tests in undo_tests, reconnect_tests, collab_live_tests,
collab_undo_tests.
- CLAUDE.md / AGENTS.md: collab in the L2 layer list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M9: collaboration wording in the progress docs after per-user undo and reconnect
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M9: protocol doc: what the engine's live-session tests cover
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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
* M9: live sessions: host from the app, invite links, live cursors and presence
Step 2 of docs/collab-protocol.md, networking and presence half:
- crates/collab-net (layer 6): WebSocket transport (sync tungstenite, a
reader and a writer thread per connection, no async runtime), the
session host the app embeds (runs figcraft_collab::Server as the
authority, carries its messages), invite links, presence throttling
(~30 Hz, latest wins), limits (sockets, hello/idle timeouts, message
size, rate, slow clients), keep-alive, clean close, reconnect with
backoff; a browser stand-in until the web-sys client (step 4).
- engine: live session state (status, peers, presence, follow) and the
collab.host/stop/join/leave/peers/follow/cursor commands; the network
is reached only through the LiveLink the app installs.
- ui-egui: remote cursors (pointer drawn in code, name tag, gliding),
selection outlines (once documents sync), avatars in the right panel
with follow, the Share popover (start, invite link + URL with Copy,
stop), File > Join live session.
- desktop app and MCP headless session install the link; xtask layers
keeps networking crates out of L0-L5.
- Documents don't sync between apps yet (follow-up).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M9: live sessions: document sync between apps
- The host's session runs figcraft_collab::Server seeded with the shared
document; every app (the host's too, through the hub without a socket)
is a collab Client. Guests open the host's file from the welcome as a
new tab; leaving keeps it as a local copy.
- Engine: after each local action (a gesture once it ends; nothing during
version or text previews) the session document is diffed into one
Changes batch; server batches go through Client::receive with
EngineRecompute and are installed with Session::apply_remote, which
never touches the undo history. Batches arriving mid-gesture wait.
- Transport: Changes/Reject/Welcome reach the engine as Event::Sync;
LiveLink::send carries batches in order; hellos are limited to 64 KiB,
joined clients to the core's message size.
- Tests: engine sync against a real Server (ids in the client's space,
one batch per gesture, remote edits skip undo, deferral, closing the
file leaves), three apps over loopback converging (create, move,
recolour, delete, rename) and a late joiner getting the server's state.
- Docs: collab-protocol, mcp, README, roadmap docs; the UI's
"cursors only" note is gone, replaced by the per-person undo caveat.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Progress docs per craftrules standards/progress-docs.md: target-app parity, gaps, roadmap with alpha gate, readiness by audience (full / mainstream / essentials) with Opus 5.5 hours, and auto layout, components, prototyping, dev mode, collaboration, format, hardware, UI and localization parity docs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* 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>
Sandboxed WebAssembly plugins whose API is the command registry:
- figcraft-plugin (new crate, L2): the figcraft-plugin.json manifest
(menu commands with parameters, read/write access, command allowlist)
and the sandbox: wasmi (pure Rust, no JIT, also in the web build) with
fuel metering, a memory cap, no WASI and seven host functions (call,
selection, reply_len, reply_read, log, notify, set_output).
- Engine: plugin.list / plugin.import / plugin.remove / plugin.run,
plugins.runLast (Alt+Cmd+P; Ctrl+Alt+Shift+P on Windows/Linux) and
plugins.manage. A run is one undo step (Session::grouped, which
command.batch now uses too) and is rolled back when it fails. Files,
other documents, the undo history, dialogs and plugins are off limits;
read-only plugins can't change the document. Installed plugins live in
the preferences; development plugins re-read their module every run.
- UI: installed plugins in the Plugins menu, a parameters form drawn
from the manifest, Manage plugins, Plugins > Development > Import
plugin from manifest.
- plugins/: a no_std Rust SDK and two example plugins (Tidy names,
Swatch grid), built for wasm32 by `cargo xtask plugins` (part of
`cargo xtask wasm`).
- Docs: docs/plugins.md (manifest, permissions, limits, ABI, SDK),
docs/mcp.md, docs/control-protocol.md, README, ROADMAP, parity.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* M8: no redraws while idle
An idle window redrew ~240 times a second: the window title was re-sent every
frame and each viewport command asks for another frame. It is now sent only when
it changes. With that gone, the frames that are still needed wake the UI at their
deadline instead of relying on the loop:
- toasts ask for the frame that hides them (shown-at time instead of summed dt);
- the autosave checkpoint wakes the UI when due;
- the presenter's "after delay" timers schedule and fire from the same layer
(an overlay over a frame with a pending delay no longer spins);
- the interaction editor's curve preview plays when it shows, changes or is
hovered, then rests;
- a control-channel screenshot keeps frames coming only until the window
delivers it (it hung on an idle window), expiring after 60 s;
- the system font scan wakes the UI when it finishes.
ui.inspect reports framesDrawn and repaintCauses (file:line of repaint
requests); ui.set {resetFrameStats} starts them over. Headless idle tests cover
a document, a selected text layer, the comments panel, the interaction editor,
the presenter, toasts, background renders, control requests and screenshots.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* M8: idle tests build the render job with the focus view's solo field
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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>
- 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>