color_alpha is the part of a solid paint's opacity that is its typed colour's alpha, not a flag:
binding a colour variable takes only that part out, unbinding puts the variable's alpha back in,
and REST import records it. A 50% paint bound to a 50% variable stays at 25% bound or not.
A design-system .fig file (54 pages) stopped at 244 248 layers, the rest
of its pages skipped, and most of its instances came in as frames "of
components that aren't in the file", with the cap note shown twice.
- The layer budget follows the compressed canvas at 8 bytes a layer, not
32: design systems are mostly instances, which pack tightly. Files under
about 1.6 MB keep the 200 000 floor and the 2 million ceiling stays
(measured on synthetic files: about 5.4 KB and 19 µs a layer).
- The library copies the pages use (the file's hidden internal canvas) are
mapped before the pages: at the cap the pages' last layers are skipped,
not the mains their instances link to. The REST stage keeps building
pages first (its ids are first-wins) and allows for the wrapper frame of
each library copy, the one layer the mapper doesn't count; that layer
tripped the REST stage's own cap and repeated the note.
- Instances and swap defaults whose main is in the file but past the cap
say so instead of "aren't in the file" (en and es catalogs).
- The .fig import gives a note the mapper already gave only once.
Tests in fig/layer_cap_tests.rs: the budget for a #981-sized canvas, library
mains ahead of the pages at the cap, the note for a capped local main, and
a whole 210 000-layer file with one cap note and its library links kept.
All four fail on the previous code.
A .fig import's notes were deduplicated and left uncounted, so each kind
was named once with no sign of how much of the file it applied to: a
file whose every text layer lost a setting read exactly like one where a
single layer did. That is what made the loss in #467 invisible -- the
reporter saw one line while three kinds of data went, one of them on all
83 text layers.
The mapper now collects its notes through the same `Warnings` the REST
importer already uses, which keeps the first-seen order and the cap and
appends `(×n)` where a note was met more than once. Nothing else
changes: the note text stays the catalog key it was, and the import
report already reads a `(×n)` suffix off before translating the base
(`ui-egui`'s `note_text`), so a counted note is still translated.
`figcraft-cli info` prints these notes, so it now names each kind with
its count.
With the count dropped again, the test reports the two notes plain:
["connector layers import as static frame/vector snapshots ...",
"connector label was skipped: depth limit prevents nested ..."]
Part of #467.
gridColumnsSizing and gridRowsSizing are CSS grid-template strings: px
tracks are fixed, fr tracks fill, auto/min-content/max-content hug,
minmax(_, Nfr) fills and repeat(n, ...) expands; other tracks stay equal
(1fr). Tracks past the axis's own count are left out. Files from .fig
keep reading the tracks from their source.
An auto layout frame a .fig records no primary-axis sizing for hugs on
that axis (auto layout sized it by its content before that axis could be
fixed) instead of turning fixed; an instance that records no auto layout
takes its main's; REST frames whose sizing modes are left out hug (the
documented default); text hugs by its resize mode (auto width both axes,
auto height its height) instead of coming in fixed.
A layer inside an instance can now override its resizing (hug, fill or
fixed, with min and max sizes; override group `resizing`) and its size.
They were refused, and imported ones were dropped at the first relayout.
Kept through rebuilds, undo, save, .fig and REST import, .fig export and
detach; reset per group and with Reset all changes. Position, constraints
and absolute or grid placement stay the main's.
Image filter warnings name fields of the file, so a crafted schema could add any number of
distinct warnings (each checked against all earlier ones). The mapper keeps at most as many as
the REST importer does.
Both carry the same image filter value, which the public docs describe as green when lower and
magenta when higher; a positive native tint adds green. Both readers flip it now (the .fig path
did, the REST path didn't). The .fig test message no longer names the product.
Image paints that carry a current filter container now import their exposure, contrast, vibrance (as saturation), temperature, tint, highlights and shadows as native image filters. Neutral values leave the paint untouched. The older color-adjust container alone, both containers together, filters that have no native equivalent (brightness, detail, vignette and unknown fields) and malformed or non-finite values are skipped and reported as import notes. A positive tint in .fig data moves toward magenta, so it is flipped once at the .fig boundary; public REST image filters keep their sign and are not reported as approximated. Imported filters use the approximate native renderer, which the import says once.
Supported TEXT_PATH layers import as editable native text on their vector path, including mixed text styles, a start inside a later segment and reversed text direction. The importer converts the segment parameter stored in the file to the native distance along the complete path.
The mapping accepts a connected, unbranched path with one recognized start-segment marker and the default placement above the path. Damaged or unsupported path data keeps the text readable off its path and adds an import note. Other vertical placements, multiline text and ambiguous or disconnected networks are not converted. Underline and strikethrough remain a native path-rendering limitation and are reported. Public REST TEXT_PATH layers and the experimental .fig writer are unchanged.
The public docs don't give a default for skip ink; they say the value is unset when the text
isn't underlined, and the exporter always wrote it on. So the export keeps writing on for text
without an underline and the real value under one (that's what the canvas draws), and the import
leaves a value on text without an underline out of the decoration look, so plain text keeps the
default look and round-trips unchanged. The native default stays off, so existing documents
draw as before.
The .fig export now writes a numbered list's start on each of its lines,
so a reader that takes it per line or per list counts the same. Bullets
no longer keep a start. The field is named start_offset (it holds the
offset: 1 shows "2."). get_node reports listStartOffsets. The parity doc
lists what's still not read from .fig lists.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Figma stores where a numbered list starts counting on each text line
(listStartOffset; a list shown as "2." has offset 1). The import dropped
it, so every list counted from 1.
A paragraph's list settings now carry a start number (written only when
set, so older files open and save unchanged). Numbering counts on from
it where a list starts; items that go on counting ignore it. The .fig
import reads it, the .fig export writes it on a list's first line, and
plain paragraphs drop it. Toggling a list off ignores differing starts.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A real 705 MB .fig file (401 644 nodes, 894 004 layers once instances are
expanded) didn't open: "message: too many values". Lifting that cap alone
made it import only its first 200 000 layers, and lifting that one took
41 GB of memory.
The global caps stay as they are. The .fig import gets budgets from the
size of its compressed canvas instead (counted up to the file's own size),
not from the message it unpacks to, since chunks inflate up to 512 MB:
- the kiwi value budget is one value per 2 bytes of the canvas, at least
the old 20 million (kiwi is only read for .fig files);
- the layer cap is one layer per 32 bytes of the canvas, at least the REST
import's 200 000 and at most 2 000 000. A small or crafted file can't
expand its instances into millions of layers, however far it unpacks; the
REST JSON import keeps 200 000.
Memory:
- The mapper no longer passes the document through `json!`, which serialises
its arguments: the whole tree was deep-copied twice.
- Vector networks are built once per distinct network and handed to the REST
importer beside the JSON (`vectorNetworkRef`), not written into it as JSON
objects for every instance that shows them.
- Decoded kiwi objects are trimmed, and each import stage's input is dropped
as soon as the next stage is built.
On that file: peak footprint 41 GB -> 11.3 GB, 67 s -> 25 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki
Slot properties of a component, the frames that reference them and the content that instances assign to them now come across from .fig files. A slot definition becomes a native slot property. The referencing frame of an expanded instance keeps following the frame of its main, and when the instance assigns slot content (including an explicitly empty slot) the frame carries that content as its own, independently owned children. Nested instances inside assigned content keep their own source links. An assignment on an outer instance wins over one made by an instance nested in the main, and a slot without an assignment keeps the default content of its main.
Assignments that are malformed, use the empty-GUID sentinel or point at a layer that is not slot content are skipped with an import note. Preferred component keys cannot be mapped to nodes of the file and are dropped with a note, as are slot settings the editor cannot represent. Public REST snapshots, the experimental .fig writer and property resources are not changed.
Grid columns and rows keep fixed pixel sizes, Hug sizing and fractional Fill weights when the minimum and maximum sizing definitions agree. Track GUIDs join the sizing records to the ordered tracks, so reordered records do not change cell placement. Unsupported min/max pairs, invalid sizes and duplicate records fall back with a reported note, and imports are bounded to 1,000 tracks per axis. Public REST snapshots ignore the private track metadata.
A text layer that links a shared text style keeps its own, possibly stale, copy
of the typography. The style's typography now beats that copy, a case,
decoration, paragraph spacing or paragraph indent set on the layer itself beats
the style (so "original", "none" and zero clear the style's value), and an
override row of an instance beats both. An explicit "no style" on a text layer
unlinks it even when the older style field still names one.
Letter spacing given as a percentage of the font size stays a percentage. A text
property value on an instance no longer inherits the character styles written
for the main's text, which count other characters.
Override rows of an instance can name a layer of its main by the key the layer
had before the main was edited. The key now resolves to the layer's current id
when it is unambiguous and the current id does not exist already. Rows of an
enclosing instance win over those of the instances nested in it, whatever order
the wire lists them in, and an explicit "no style" on an override detaches the
style instead of falling back to the older field.
Paints bound to variables take the colours of the active modes when a document
loads, before the first edit, without touching other pending changes.
A stroke-only vector that imports as the outline of its stroke now moves the
stroke's shared style and colour variables to the fill it becomes, and vectors
whose single segments carry their own stroke style say so in a warning.
A .fig import now links the layers of an instance to the layers of the
component they came from by the ids the file stores, instead of leaving it to
the engine to guess from names and places. Renamed slots and layers, siblings
that share a name and swapped mains keep their source link and the override
they carry.
Components that live only on the file's hidden internal pages (cached copies of
library components) are kept in the hidden library store when a visible layer
uses them, so their instances stay instances. The read-only guard for library
copies now compares the copies themselves rather than the dirty set, which an
import leaves full until the first edit.
Rebasing an instance (after detaching an outer instance or restoring a main)
follows the source chain inside the right enclosing instance, so contextual
links survive it. The last change of a GUID wins and a removed change deletes
it. Ordinary file JSON never reads the private provenance.
Importing such a file into an open document, or pasting its layers into another one, copies the cached components along (import) or leaves them behind as a missing main (paste), so no link points at a layer of the target.
A hyperlink longer than 200 characters came back as a different 200-character
URL -- one that still looks like a working link -- and the import reported
nothing. It applied to a text layer's base style and to a character style
override alike.
The destination went through `clip_str`, which exists so that names and labels
from a file stay readable when they are quoted in a warning. Every other caller
is a name, a description, a style id or a layer type; a hyperlink is data, and
clipping it silently rewrote it. Two things say so: the FIG importer already
keeps the URL whole, so the two importers disagreed, and `rest::props` bounds
numbers and list lengths but no string beyond that label helper -- the text
content itself is not clipped either.
The reporter's integration test is included, with its assertions unchanged. Its
helper is restructured to return what it found rather than unwrap: the crate
denies `unwrap_used` and `panic`, and `clippy.toml` allows them only inside a
`#[test]` function, so the original shape failed the gate before it ran. Its
failure message now reports the input and output lengths.
A variable width is written only as the same reach on both sides of the path (a
point with unequal sides drew nothing elsewhere). A stroke wider on one side, on a
shape that shows nothing else, is written as a vector filled with the area it
paints, with the stroke itself kept as plugin data that the import puts back.
Bound layer and text fields also name the number of the field they set. Docs: text,
effect and grid styles and symmetric variable widths are checked; non-colour
bindings come across as values.
Instances write what they change from their main as an override addressed by the
main's id (size, name, paints), what follows from that for their layers as derived
data (size, place, outline, colours their variables give), and a scale factor when
they were scaled as a whole; layers of a nested instance swapped for another
component keep their overrides. Variable collections, modes and variables are nodes
on a hidden page; paints, gradient stops, effects, layout grids and layer fields
name the variables bound to them, and frames and pages the modes they set. Shared
styles are nodes there too, linked from the layers that use them. Variable-width
strokes say their reach on each side of the path. The import reads all of it back
(style definitions and unused styles, variable order and scopes, bindings on paint
lists, stops, effects and grids, page modes, text bindings, width profiles).
- The document and its pages now say they are visible and opaque, and a page carries its background opacity and enabled flag. Left out, a reader took them as hidden and drew nothing: every frame was blank and the page background transparent.
- The schema written into the file uses the format's own names, kinds (struct or message), field ids and enum values for everything the export writes. The schema text parser takes `NAME=n` and `type field=n`.
- Rectangles are written as rounded rectangles with their four radii. Frames, components, instances and sections carry their fill outline; plain strokes carry their stroke outline (a ring around boxes and whole ellipses, the widened path of other centred strokes).
- Text carries its layout settings and one line entry per paragraph. Font styles are written under their standard names ("SemiBold" becomes "Semi Bold", "Regular Italic" becomes "Italic"); the import reads both spellings as the same style.
- A line height that is a share of the font size is written as a raw multiplier: percent units count from the font's own line height, so 140 % was laid out taller than in FigCraft.
- Masks keep their type (alpha, outline, luminance), written and read back.
- The import reads per-side stroke weights whose zero sides are left out (a bottom-only border no longer comes back on all four sides).
- `meta.json` gives the page colour and the thumbnail size; archive members are in the usual order. Texture effects are written under the format's grain type; WebP export settings are written as PNG with a warning (the format has none).
- Docs: `docs/file-format-parity.md`, `docs/gaps.md` (gap 1), `ROADMAP.md`, `docs/target-app-parity.md` (wording only; no number moves).
An experimental, best-effort `.fig` export, so work done in FigCraft can be handed back as a `.fig` file. It is written from the same public format write-ups the importer uses and is the inverse of the importer's mapping. Only round trips through FigCraft's own importer are verified; other apps may not open every file.
- **Encoder** (`crates/format/src/fig/kiwi.rs`): the kiwi encoder that test fixtures used is now shipped code. It never panics and checks every value against the schema: unknown fields or enum names, missing struct fields, wrong types, out-of-range numbers and non-finite floats are errors, and NULs inside strings are dropped. Varints and floats are written exactly the way the decoder reads them. `Schema::parse` reads the compact text schema form, and the importer's tests use it too.
- **Writer** (`crates/format/src/fig/export.rs`): writes the zip (`canvas.fig`, `meta.json`, `thumbnail.png`, `images/<hash>`). `canvas.fig` holds the fig-kiwi header, then a raw-deflate binary schema and a raw-deflate message (flate2's pure-Rust backend, already in the tree; no new dependencies). The schema covers only the fields we write. The canvas, images, blobs, node count and nesting depth are capped, and floats are made finite before they are written.
- **Mapping:** pages; frames, groups, sections, rectangles, ellipses (arcs), polygons, stars, lines and vectors (vector network blobs with per-point radius, mirroring and caps, plus fill outlines); text with styled runs (UTF-16 indexed) and list types; slices; boolean groups; solid, gradient and image paints (image bytes included); strokes; effects; corners; constraints; masks; auto layout, including grids with tracks and cells; child layout; layout grids; export settings; components, component sets and their properties. Instances are written as their main plus the fields their layers change, by id path. This covers nested instances and swaps, and fields the main sets but the instance clears. Prototype interactions (triggers, navigation and overlay actions, transitions, easing), scrolling and flow starts are included. Everything skipped is reported in a warnings list: style links, variables, comments, Dev Mode data, plugin data, guides, slots, brush and width-profile strokes, pattern fills, image adjustments, variable and conditional actions, and more.
- **Import fixes found by the round trips** (`map.rs`): ellipse arcs, boolean operation types and component set descriptions are now read. Durations are read as whole milliseconds, because seconds stored as 32-bit floats used to truncate by 1 ms.
- **File writes:** a failed write no longer leaves its `.tmp-figcraft` file behind. The junk-parameter sweep found this.
- **Commands and UI:**
- `file.export {format: "fig"}`, or any path ending in `.fig`, writes the whole file and returns `warnings`. Without a path it returns base64 `data`.
- File > Export... has a new "The whole file" section with a ".fig (experimental)" row. A note under it says other apps may not open every exported file. After export, a toast reports how many kinds of content were left out.
- `figcraft-cli export --format fig` (or `-o x.fig`) and `convert` to `.fig` print the warnings as `note:` lines.
- MCP's `export` accepts `format: "fig"` and passes the warnings through.
- New UI strings have English and Spanish entries.
- **Docs:** file-format-parity.md has a new export section covering what is written, what is skipped, and that only round trips through our own importer are verified. Also updated: mcp.md, cli.md, gaps.md (gap 1), target-app-parity.md and ROADMAP.md. Grading is kept low. Import/export goes to 86%/58% and file formats to 60%. The file-exchange discount stays at x0.85, because nothing shows that another app opens these files.
The part of gap 15 that doesn't need video, from the public documentation of prototype triggers, plugins/widgets and prototype expressions.
- **Controller buttons on key/gamepad triggers.** A key trigger's key can be a controller button: `Gamepad A` ... `Gamepad Home`, the standard gamepad layout's 17 buttons (face buttons by position, shoulders, triggers, View/Menu, stick presses, d-pad, Home). The engine keeps its input-agnostic model (a key trigger with a key name; `figcraft_doc::proto::GAMEPAD_BUTTONS`, `gamepad_button`, `is_gamepad_button`, `uses_gamepad`). The interaction editor gets a Controller menu beside the key field; the trigger list shows the button's own name.
- **Reading controllers.** A new `Services::gamepad_held()` (bitmask of held buttons; default none). The player polls it only while the presented prototype has a controller trigger and the window has the focus (about 30 times a second), plays each press once (held buttons don't repeat), and fires the first matching key/gamepad trigger on the screen like a key.
- Desktop, Windows: Windows.Gaming.Input (Xbox-compatible controllers) through the `windows` crate the app already depends on (one more feature flag, `Gaming_Input`; no new crate).
- Browser: the Gamepad API through `web-sys` (features `Gamepad`, `GamepadButton`, `GamepadMappingType`; standard-mapped controllers only).
- macOS, Linux, FreeBSD: no controllers yet (documented gap).
- Control channel: `ui.input {events: [{gamepad: "A" | "Gamepad A" | 0}]}` presses a button in the prototype playing (presenter, else preview pane); an error when nothing plays.
- **REST import** maps controller triggers (`device` other than `KEYBOARD`) to the button at the code's standard-layout index instead of skipping them; two buttons at once or an unknown code is skipped with a warning (`.fig` goes through the same path).
- **Widgets react to clicks in a presented prototype.** A click on a widget's clickable part in the presenter or the preview pane (when no interaction on the layer takes it) calls the plugin back through the canvas's own callback with the same sandbox limits, on the player's copy of the file (`widgets::click_on`, a scratch session holding the copy and the installed plugins): the widget changes in the prototype only, the file and its undo history stay untouched, restarting starts the widget over. Failures (plugin missing or failing) toast; toasts now also show over the full-window presenter.
- **Variable mode lookups.** Lookups (`accent[Dark]`, `mode(Theme)`), set-variable-mode affecting later expressions in the same interaction, and every operator the documentation lists already held; they are now behaviour cases. New `proto.evaluate {expression | expr, id?}` (MCP and agents) computes an expression where a layer's interaction would run, without changing anything. No other cheap expression gap found: every documented operator (numbers, text join, booleans, comparisons, negation, mode lookup) already existed.
- Docs: prototyping-parity (new rule rows, features, revision), target-app-parity area 13 and totals, ROADMAP, gaps.md gap 15, roadmap.md M5, mcp.md, plugins.md, control-protocol.md.
Dependencies: none added. Windows uses the existing `windows` 0.62 dependency with the `Gaming_Input` feature; the browser uses the existing `web-sys` with three more features. The usual Rust controller library (gilrs) was not taken: its Linux backend links the system's libudev (a C library, needing headers on CI and the FreeBSD job), which breaks the pure-Rust rule.
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.
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 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
**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.