Commit Graph
24 Commits
Author SHA1 Message Date
Piettro CastroandClaude Opus 5.5 f3cd0509cb fix(engine): read the documented gradient shorthand on every route (#252)
`{"type":"linearGradient","stops":["#hex",…]}` was only expanded by the MCP
tools, so `figcraft-cli run` and the control channel's `engine.execute`
refused it ("`fills` must be paints or colours"). The expansion now lives in
the engine (`params::normalize_paint`, used by `paint_of`), so node.set,
node.create props, fill.add/set, strokes and paint styles all take it; MCP
calls the engine's copy instead of its own.

Tests: engine (every paint command, MCP-equal defaults, junk still refused),
control channel `engine.execute`, and `figcraft-cli run` end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:02:45 -05:00
fede9234 db55720c93 fix(engine): keep proportions when a linked width or height is typed (#222)
With a layer's proportions locked, setting only its width or only its height through node.set (the W and H fields) scales the other side by the layer's own ratio; setting both in one edit leaves them as typed, an unlinked layer is unchanged, and a zero side has no ratio to keep. Fixes #220.
2026-10-14 07:31:19 +07:00
bflatastic a2a0f80252 M2: auto layout: wrapped-line alignment 2026-10-13 13:36:54 +07:00
bflatastic bf82f7ebf9 M4: text lists, hanging punctuation and decoration styles
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.
2026-10-12 02:09:10 +07:00
bflatastic 2a54adb762 M4: shapes and vector behaviour suite and fixes
**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.
2026-10-12 01:32:04 +07:00
bflatastic e917d3c2e6 M1: frames, groups, sections and masks behaviour suite and fixes
**A frames, groups, sections and masks behaviour suite written from the public documentation, and fixes for every divergence it found.** Each documented rule (frames vs. groups when resized, constraints, Cmd/Ctrl-resize, clip content, Frame selection, grouping and ungrouping, group bounds and opacity, X/Y in groups, click targets, drawing a frame or section around layers, dropping into and out of frames, where sections may go, resizing sections, top-level frames as screens and exports, Use as mask, what a mask covers, alpha / vector / luminance masks, groups as masks, clicks on masked-out areas, a mask group's box and exports) is a table-driven case with the result the rule implies and a one-line derivation; where the documentation is silent the case says so and pins today's behaviour.

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

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

No new commands or UI strings (no i18n changes); each edit stays one undo step.
2026-10-12 00:57:43 +07:00
bflatastic 62db8d4e36 M3: components behaviour suite and fixes
**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.
2026-10-12 00:57:43 +07:00
BFlat cc30a261e7 M1: layers panel keyboard (box selection, F2 rename, context menu) and stroke styles (#135)
## 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.
2026-10-11 23:10:41 +07:00
BFlat f665765128 M4: text behaviour suite: metrics, line height, CJK (#134)
## 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.
2026-10-11 22:09:46 +07:00
BFlat d01d18cd88 M2: auto layout behaviour suite and fixes (#130)
A 64-case behaviour suite derived from public documentation (sizing, padding, strokes, gaps, alignment, wrap, absolute position, grid, stacking) plus command-level tests. Fixes: frames never shrink below their padding, only the frame's own inside stroke counts when strokes are included (now the default for new auto layout), auto gaps stop at 0, grid fr tracks never go below their content, and canvas stacking (reverseZ) now drives drawing, exports and hit testing. New settings: evenly/around auto gaps, vertical wrap with a column gap, per-child grid cell alignment, strokes included/excluded and canvas stacking in the Design panel.
2026-10-11 17:53:11 +07:00
BFlat cb5435d5ff M4: stretch and scatter brushes, pen pressure (#120)
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).
2026-10-11 13:43:48 +07:00
BFlat 08d91869cb M4: live boolean editing, arc fields, shape builder on strokes, one-sided widths (#111)
Boolean operands are editable in place with live results (per-operand operations, cached prefixes so a drag recomputes from the edited operand up); a Design panel Arc section; the shape builder takes strokes and open paths as outlined areas; variable width points can be wider on one side.
2026-10-11 10:04:25 +07:00
BFlatandClaude Opus 5.5 70ab71bee6 M2: auto layout keyboard reordering and section hide contents (#78)
* M2: auto layout keyboard reordering, nested auto layout fixes, section hide contents and dev status

- Arrow keys move auto layout children one place: along the flow (past hidden and
  absolute siblings), between wrapped rows, to the neighbouring grid cell, and into
  the neighbouring nested auto layout frame; no undo step when nothing moves.
- Sections: section.toggleContents (hide contents), object.wrapInSection, frames in
  sections are top-level frames (labels, flows, navigation, PDF export), navigating
  to a section shows its frame shown last.
- Ready for dev turns "Changed" when contents are edited; dev.resolveChange with a reason.

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

* M2: README and ROADMAP for auto layout arrow keys and sections

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

* M7: roadmap refresh after #68–#77

Parity table rows for canvas, selection and transform, layers, effects,
text, prototyping and comments now reflect the merged work; weighted
feature depth ~76%; README command count and hand-off features.

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

* M2: the dev change reason resolves only on Enter or Resolve

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:36:02 +07:00
BFlatandClaude Opus 5.5 340b673666 M3: text depth: RTL names, text style browser, axis previews (#72)
Right-to-left and mixed-direction names in the panels: layers, pages,
Assets, tab titles, the file header and canvas frame titles show in
visual order (Unicode bidi, joined Arabic letters, mirrored brackets),
elided at their end (the left end of a right-to-left name). egui shapes
each font run itself, so shown names are laid out a cluster at a time.
Rename fields keep egui's editing and draw the shown text over it: the
caret, selection, clicks, drags and arrow keys follow what's on screen.
System fonts are added for scripts the built-in UI fonts lack.

The Typography section's text style browser: list and grid views with
each style in its own font, search, folders, this file's and enabled
libraries' styles, apply, detach, edit the typography, create from the
selected chars, hover to preview on the canvas, and "Mixed" when the
chars use several styles. Text styles now link chars (style_id on text
styles), so a range can take a style; editing what a style sets
unlinks the chars edited; files from before link on open.

Live text previews (text.preview / text.endPreview): a hovered text
style, font style or named instance and a dragged axis slider show on
the canvas without an undo step; release commits one step, Escape
cancels. ui.input sends raw window input over the control channel.

Visual QA: list markers keep the paragraph's font, renames start with
the name selected and centred, Max lines shows while truncating.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:03:09 +07:00
BFlatandClaude Opus 5.5 1184dd4177 M4: variable width strokes (#58)
A stroke can carry a width profile: widths at positions along the path
(each subpath by length), interpolated smoothly (monotone cubic).

- doc: WidthProfile / WidthPoint / WidthPreset (uniform, wedge, taper both
  ends, eye, rounded, pinch) on StrokeProps, serde-defaulted so older files
  load; paint reach and hit testing follow the widest point.
- pathops: path measuring by length and the variable outline (offset along
  the normals, round/miter/bevel joins, caps, dashes keep their width,
  Inside/Outside clip as before; bounded samples and dashes, uniform
  fallback). Canvas, PNG, SVG (filled outline), PDF and outline stroke use it.
- engine: stroke.widthProfile {preset | points}, node.set widthProfile,
  inspect output; the Variable width tool (Shift+W) in vector edit mode:
  drag the path to add a width point, slide points, drag their handles,
  Delete removes the selected one; vector.edit.widthPoint add/set/remove.
  Every gesture is one undo step.
- UI: width profile dropdown with previews drawn in code and a flip button
  in the stroke settings; the tool in the edit toolbar (new icon drawn in
  code); width handles and a hover preview on the canvas; Position/Width
  fields for the selected width point.
- Tests: profile maths, outlines, render regressions, tool gestures,
  commands, exports and junk sweeps. ROADMAP, README, docs updated.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:12:21 +07:00
BFlatandClaude Opus 5.5 71c467879d M2: auto layout nesting: one-pass nested hug/fill, coherent sizing, drag across nested frames (#45)
Layout (crates/layout):
- Fill children take their size before the frame is sized, so wrapping fill
  text, frames that hug it and grid rows settle in a single pass (three
  levels deep included).
- Fill children share space like CSS flexbox: min and max violations are
  resolved in turn instead of freezing both at once.
- A single child with space-between sits at the start; a wrapped row of only
  fill-height children keeps their height; grids size fill-width children
  before their rows.
- coherent_sizing: Fill only in an auto layout flow on an axis the parent
  doesn't hug, Hug only on auto layout frames and text (a text hugs what its
  resize mode grows); anything else becomes Fixed at its size.

Engine:
- Setting a child to Fill turns a hugging parent Fixed on that axis; a parent
  that starts to hug, layers leaving auto layout and layout.remove fix the
  rest (applied in normalize).
- Moving layers: the deepest auto layout frame under the pointer wins (plain
  frames in a flow are items, not destinations; the dragged layers and their
  descendants are skipped); the frame they come from keeps them near its
  edge. They take the slot (or grid cell) there with the insertion line drawn
  in the gap and the receiving frame outlined; out of auto layout they become
  positioned children of the canvas or plain frame at the drop point. One
  undo step per drag. Slots skip hidden and absolute children.

Tests: crates/layout/src/nested_tests.rs, crates/engine/src/al_nesting_tests.rs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:41:11 +07:00
BFlatandClaude Opus 5.5 bf3a45d938 M1: sections and frames: resize to fit, section colours, ready-for-dev status (#23)
- Object ▸ Resize to fit (Alt+Shift+Cmd+R, `object.resizeToFit`): frames, sections and
  components shrink or grow to their visible contents without moving them on the page; auto
  layout frames switch to hug instead.
- Sections get colour swatches (light tints) in the Design panel.
- Frames and sections get a handoff status (`devStatus`: ready for dev / completed) set from
  the panel and shown as a badge next to their name on the canvas; `node.get` reports it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 04:20:23 +07:00
BFlatandClaude Opus 5.5 840d3ac5d2 M1: mask types: alpha, vector outline and luminance masks (#17)
- Layers gain `maskType` (alpha by default, so files are unchanged): a vector mask clips by the
  mask layer's outline whatever its fills and opacity; a luminance mask shows content where the
  mask is bright and hides it where it is dark.
- `node.set {maskType}` (validated), `node.get` reports it, and the Appearance section shows a
  Mask dropdown when the selection is masks.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:45:45 +07:00
BFlatandClaude Opus 5.5 b7910c81f8 M4: text depth: inline IME composition, variable font axes, range-aware typography polish (#8)
- Inline IME composition: egui Preedit events become a composition held in the text edit
  session (`TextEdit.preedit`), shown inline at the caret with an underline and the IME cursor,
  reflowing the text; the document only gets the committed text (`text.edit.insert`). Composing
  over a selection replaces it; moving the caret or ending the edit drops an uncommitted
  composition. The IME candidate window follows the cursor inside the composition.
  New command `text.edit.preedit {text, cursor?}`; `text.edit.selection` reports it.
- Variable fonts: faces read their fvar axes (tag, name, range, default, hidden) and named
  instances. Named instances are offered as styles and resolve in `find`; a style name with no
  instance implies weight/italic/slant on the axes and optical size follows the font size.
  Per-run custom axis values (`TextStyle.variations`, serde default, omitted when empty) are applied
  in HarfRust shaping (ShaperInstance) and skrifa outlines/advances (cached per coordinates).
  PDF export outlines non-default instances instead of embedding the default one.
- Commands/props: `fontVariations` / `variationSettings` props (whole layer or start..end),
  `text.setVariations {variations, replace?}` (merges; edited range while editing),
  `text.fonts {family, style?, axes: true}`; `node.get` and copy/paste properties carry the axes.
  Picking a named style (Bold/Italic toggles, weight steps, fontStyle/fontWeight) drops custom
  wght/ital/slnt values and keeps the others.
- Type settings ▸ Variable tab for variable fonts: a slider (default tick, double-click to reset)
  and a field per visible axis, "Mixed" across differing styles, live drag as one undo step,
  "Reset to style". Long style/family names stay on one line with an ellipsis and a tooltip.
- Tests: variable font shaping/outlines/clamping/instances (skipped without a variable font),
  serde backward compatibility, preedit lifecycle and undo, variation commands/props/undo/junk;
  junk sweeps extended with variation and preedit params.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 03:07:41 +07:00
BFlatandClaude Opus 5.5 4286a3b74b M2: auto layout depth: min/max UI, ignore auto layout toggle, text baseline alignment, grid tracks and cells (#3)
- Min/max width/height: the W/H sizing menus add min/max (at the current size) and remove them;
  limit rows edit or add the missing bound. The layout clamps Fixed, Hug and Fill children and
  the frame itself; a hugging text wider than its max width wraps at the max. min > max is kept
  ordered by node.set (the other bound follows); in the layout min wins.
- Ignore auto layout (absolute position): a toggle in the Position section; the child stays
  where it is, can't Fill, shows constraints and follows them when the auto layout frame resizes
  (by hugging, filling or a direct resize).
- Text baseline alignment for horizontal layouts ("Align text baselines"; crossAlign baseline):
  first-line baselines line up, other layers align to the top, nested auto layout frames use
  their first child's baseline. Measure gains text_baseline.
- Grid auto layout: column/row tracks (fixed px, hug, fr fill) via columnTracks / rowTracks or
  layout.setTrack, cells anchored by gridColumn / gridRow with gridColumnSpan / gridRowSpan,
  auto placement in reading order, rows grow to fit, hugging grids size to their tracks.
  Panel: column/row gap, track size rows, a child's cell and spans. Old files load unchanged
  (serde defaults; missing tracks are 1fr).
- Canvas handles: wrapped-row gaps and grid column/row gaps are draggable; a selected grid's
  tracks are outlined.
- The layout section moved from right.rs to layout_panel.rs; inspect reports min/max and a
  grid child's resolved gridCell; docs list the new props.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:23:43 +07:00
Brandon ThomasandClaude Opus 5.5 b943e12f3d M2: text depth: OpenType features, small caps, lists, title case across runs, truncation; font picker with previews, type settings; text editing selection and IME
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:15:36 -04:00
Brandon ThomasandClaude Opus 5.5 b26b449fe3 Fixes from agent reports: hex colours everywhere, camelCase in/out, unique variable ids, strict batch, ui.key modifiers, export rounding, Fit images, inner shadows on all edges
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:34:25 -04:00
Brandon ThomasandClaude Opus 5.5 b36fd771ab M0.9: MCP server + figcraft-cli, PDF export, web app, release/CI packaging, vector edit mode, workspace lint cleanup
- crates/mcp: JSON-RPC MCP server (18 tools, resources, prompts), headless and attached backends, end-to-end agent tests
- apps/figcraft-cli: info/export/convert/render/run/commands/mcp/perf
- crates/pdf: vector PDF export via krilla with embedded text; file.exportFramesPdf
- apps/figcraft-web + packaging/{macos,windows,linux,freebsd,web} + release/ci workflows
- engine vectoredit: edit points/handles/bend/add/delete-heal; canvas overlay and edit strip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:31:53 -04:00
Brandon ThomasandClaude Opus 5.5 56a312ba5f M0.5: layout (auto layout + constraints), format, svg, engine (commands, history, tools, components, styles, variables)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:40:18 -04:00