From a0f6410397adc52e7ff004e3597892554fdc322a Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:03:04 +0200 Subject: [PATCH 001/208] M4: preserve stroke rings when outlining shapes --- ROADMAP.md | 831 +++++++-------- crates/engine/src/cmd/structure.rs | 1303 +++++++++++------------ crates/engine/tests/outline_stroke.rs | 174 +++ docs/gaps.md | 893 ++++++++-------- docs/roadmap.md | 249 ++--- docs/target-app-parity.md | 1405 +++++++++++++------------ 6 files changed, 2520 insertions(+), 2335 deletions(-) create mode 100644 crates/engine/tests/outline_stroke.rs diff --git a/ROADMAP.md b/ROADMAP.md index 100693e3..29de0e90 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,414 +1,417 @@ -# FigCraft Roadmap - -**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) - -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) - -FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, -native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through -the CLI, a JSON control channel and MCP. This page is the one-page summary; agents choosing work -start with [`docs/gaps.md`](docs/gaps.md). - -## Headline numbers - -| | Value | Kind | -|---|---|---| -| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | -| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | -| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) | -| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | -| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | -| Remaining to beta | **~155–280 h** one agent | estimated | -| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | - -### Readiness by audience - -| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | -|---|---|---|---| -| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | -| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) | -| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | - -Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour -per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see -[how the hours are calibrated](docs/target-app-parity.md#how-the-hours-are-calibrated)). Each audience's work is a subset of -the next. About 70–80% parallelizes across 4–6 agents on disjoint crates (roughly ÷4 in -wall-clock); the `.fig` reader, the multiplayer core and a plugin runtime each need one owner, and -native-speaker review, real-file licensing and screen-reader QA need people. - -Hours are Opus 5.5 agent wall-clock hours, calibrated from this repo's history: the app was built in -~25 wall-clock hours by 4–6 parallel agents (~110–150 agent-hours, ~1 agent-hour per 1,000 added -lines, a typical feature PR 1–2.5 h). Details and what needs a human: -[`docs/target-app-parity.md`](docs/target-app-parity.md#how-the-hours-are-calibrated). - -## Stage - -**Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components -with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but -FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked -against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs -~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured -fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h), -basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h); -native speakers' reviews of the six language drafts need people. - -## By dimension - -| Dimension | Ready | Hours | Doc | -|---|---|---|---| -| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) | -| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | -| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) | -| Stability | 55% | 20–40 | [gaps](docs/gaps.md) | -| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | -| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | -| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | -| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | -| AI features | 0% | 30–60 | [gaps](docs/gaps.md) | - -## Features - -| Area | Breadth | Ready | Hours | Doc | -|---|---|---|---|---| -| Canvas & navigation | 98% | 74% | 1–4 | [ui-parity](docs/ui-parity.md) | -| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) | -| Layers panel | 99% | 85% | 1–2 | [ui-parity](docs/ui-parity.md) | -| Shapes & vector | 99% | 73% | 1–3 | [target-app-parity](docs/target-app-parity.md) | -| Paint | 90% | 71% | 2–5 | [target-app-parity](docs/target-app-parity.md) | -| Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) | -| Effects | 100% | 75% | 2–5 | [target-app-parity](docs/target-app-parity.md) | -| Frames, groups, sections, masks | 97% | 80% | 1–3 | [target-app-parity](docs/target-app-parity.md) | -| Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) | -| Text | 90% | 70% | 3–7 | [target-app-parity](docs/target-app-parity.md) | -| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) | -| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) | -| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) | -| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) | -| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | -| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | -| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | -| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | -| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | -| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | - -## Languages - -| Language | Code | Strings | Status | -|---|---|---|---| -| English | en | 100% | full | -| Simplified Chinese | zh-Hans | 100% | draft (needs native review) | -| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) | -| Hindi | hi | 0% | none | -| Arabic | ar | 0% | none | -| French | fr | 100% | draft (needs native review) | -| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) | -| Indonesian | id | 0% | none | -| Japanese | ja | 100% | draft (needs native review) | -| German | de | 100% | draft (needs native review) | -| Korean | ko | 100% | draft (needs native review) | -| Vietnamese | vi | 0% | none | - -Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese -(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ -Language; Traditional Chinese has no catalog; CJK UI -text uses an installed CJK font, loaded only when needed; adding a language: -[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, -IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md). - -## Upcoming - -Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - -1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. -2. Real-file corpus and measured fidelity: 15–30 h. -3. Tool-by-tool interaction pass against Figma: 40–60 h. -4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). -5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. - -## Progress log - -- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. -- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. -- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. -- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. -- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged. -- **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged. -- **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged. -- **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged. -- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. -- **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. -- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. -- **2026-10-13** · Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. -- **2026-10-13** · Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. -- **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. -- **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. -- **2026-10-13** · Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. -- **2026-10-13** · `.fig` export: instance sizes, variables, styles (gap 1): instances write what they change from - their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as - derived data and a scale factor when scaled as a whole; variable collections, modes, variables and bindings, - the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the - import reads all of it back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; - number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged. - text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged. -- **2026-10-13** · Starter templates and first-run help: eight templates built in code (mobile app, landing page, - dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter), a - Templates tab on the home screen, `template.list` and `file.newFromTemplate` for scripts and agents, and a Getting - started card that can be closed and comes back from the Help menu -- **2026-10-12** · Your online files: a file shared or published online stays yours after its tab closes or the app - quits. The app remembers it (with its owner token, kept with the preferences and never shown) and Home ▸ **Online** - lists what you own: open a file again as its owner, start or stop sharing, copy its code, rename it, remove it from - the server or forget it here. Servers are asked once, when the list opens; tabs mark files in a live session; a - crash reopens such a file from its server and puts back the changes that hadn't reached it. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an - upgrade only to tell which files are still there. The six draft catalogs translate every string again -- **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge - preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and - object bounds, Hide when publishing in the Assets panel, a cursor for every tool and modifier, panel widths that are - kept, and dark pages (white text on them) for files made in the dark theme -- **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or - **Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that - carries the host's address); others type it under Join with a code (the popover, the home screen), where a link - works too. The server names links with codes, keeps them hashed and limits wrong ones; `collab.share`, - `collab.join {code}`; servers must be upgraded to hand codes out (older ones keep working with links) -- **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and - compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing - goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before - in the design-system example). One font family; real files still unchecked (gap 3). No number moves -- **2026-10-12** · Live sessions are per document: every open file has its own session or none, several run at - once (host one, join another, publish a third) and keep syncing behind the tab in front, the Share popover, - people, cursors, chat, spotlight and follow are the file in front's, `collab.peers` lists them all (`sessions`), - and closing a file ends its session only (before, one session followed every tab and starting another replaced it) -- **2026-10-12** · A public `figcraft-server`: `open_publish = true` lets anyone publish without the server key - (Share ▸ Publish to server asks the server first and shows who hosts it, its terms and how long unopened files are - kept), with an owner token shown once that manages the file later (`collab.manageFile`); limits per address, - idle expiry, reports (Share ▸ Report this file…), `block-ip` and `files list --sort`, `GET /healthz`, and - `packaging/server/install.sh` that sets up a Debian/Ubuntu machine with Caddy, ufw and backups - ([server.md](docs/server.md#running-a-public-server)). Numbers unchanged (hosting, not a Figma feature). -- **2026-10-12** · `.fig` export opens elsewhere (gap 1): written files were checked by opening them in other - software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. The - export now says that the document and its pages are visible and opaque, numbers its schema as the format does, - and writes outlines, text settings, standard font style names and line height units; effects such as glass and - noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers - unchanged. -- **2026-10-12** · Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig - (experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document: layers, - geometry, paints with their images, strokes, effects, styled text, vector networks, auto layout, components and - instances with their overrides, and prototype interactions; what's left out (style links, variables, comments, - Dev Mode data…) comes back as warnings. Checked only by reading the files back with our own importer. File - formats 60%. -- **2026-10-12** · Keyboard shortcut parity (gap 4): a table test with a row for each of the 129 - shortcuts in the public documentation, macOS and Windows/Linux keys each, and the 48 bindings it - is silent on pinned; 126 bound (110 before), Run last plugin moved off a documented conflict on - Windows, 2 missing. New: others' cursors toggle, list levels on Cmd/Ctrl+] and [ while editing, - keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, Control - suspending snapping on macOS, Space/N/C/F while presenting, the Frame tool on A, pixel grid on - Shift+', link on Shift+Cmd+U, ungroup on Cmd/Ctrl+Backspace, suggest auto layout and go to main - component keys; shifted symbols on other keyboard layouts. -- **2026-10-12** · Plugin directory on `figcraft-server` (gap 6): publish a plugin folder with the server key - (`figcraft-cli plugin publish`), browse a server's plugins in Plugins ▸ Manage plugins ▸ Browse, install after a - confirmation that shows the permissions it asks for, update when a newer version appears, remove; bundles are - checked in full on the server and again in the app, downloads must match their SHA-256, installed plugins live - per user in the same sandbox; `plugin.directory.*` for scripts and MCP; the operator moderates with - `figcraft-server plugins`. Plugins & widgets 60%. -- **2026-10-12** · Plugin widgets and the panel tail (gap 6, plugin API 3; older plugins run unchanged): a widget - plugin puts a layer of its own on the canvas (Plugins ▸ Widgets, `widget.insert`), described by the plugin, - validated and built with locked layers inside; clicking a part it named calls it back with the widget's state - (kept in the layer, so it saves, copies, undoes, exports and syncs) as one undo step, and it redraws; - `widget.click` and `widget.list` for agents; a Counter example. Panels of several plugins open side by side and - plugin colour fields open the colour picker. Plugins & widgets 55%. -- **2026-10-12** · Layers and Assets panels behaviour suite (gap 4): several layers dropped together land - together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers - at once, a Rename layers dialog for several layers (number codes as documented, regular expressions, a start - value), pages dragged to reorder, dividers, "Copy of" duplicates, Assets folders by page, frame and slash names, - and hidden-from-publishing names that decide both ways. -- **2026-10-12** · Vector multi-edit and the vector edit tail (gap 9): several vector layers in edit - mode at once (select, marquee, drag, nudge, delete, mirroring and radius across them), several - Bezier handles dragged together, rounded points beside curved segments, a stroke cap and join per - point, and the Pen connecting to its own points or continuing a selected open path. -- **2026-10-11** · Canvas navigation, guides and grids behaviour suite (gap 4): zoom, panning, the pixel - grid (now from 400%), rulers, guides (top-level frames only; select, Delete, nudge, Remove guide, - Alt-drag copies, Alt measures while dragging), layout grids (Auto counts; hidden on rotated frames), - outline mode (clicks reach layers behind) and measuring, each rule from the documentation. -- **2026-10-11** · Export and inspect behaviour suite (gap 4): exports cover what a layer paints (shadows - only where they fall, true stroke widths, no room for hidden layers, a mask group's mask), slices export what's under - them, layer names with slashes export into folders; export settings gained contents only, include bounding box and - the SVG options (outline text, ids, simplified strokes); Inspect shows colours in hex, RGB, CSS, HSL or HSB, CSS in - rem, blend modes and positions from the frame, and lists icons and images as assets. Dev Mode 70%. -- **2026-10-11** · `.fig` import's tail (gap 1): vectors open as editable vector networks with their - corner radii, handle mirroring and end caps (outlines when the network is missing or damaged); sticky notes, shapes - with text, connectors, tables and similar layers import as frames, text and vectors instead of being dropped; - set-variable and conditional prototype actions come across. File formats 58%, ready ~49%. -- **2026-10-11** · Paint and effects behaviour suite: every documented rule for fills, blend modes, - gradients, images, colour models, shadows, blurs and effect exports is a table-driven case. Fixes: Normal - groups isolate and clipping frames don't, plus darker is drawn, drop shadows hide behind translucent - layers in every export, Fill images reach the box's edges, "Normal" no longer means pass-through. - Paint 70%, effects 75%. -- **2026-10-11** · `.fig` import, best effort (gap 1): `file.open`, `file.import`, drag and drop, the CLI and - MCP open `.fig` files. A generic kiwi decoder reads each file's own schema, and the decoded nodes map by field name - onto the REST importer: layers, paints and styles, text runs, vectors as outlines, auto layout, components and - instances (expanded with their overrides and property values), variables, prototyping and images from the archive; - the rest is a warning. File formats 55%, ready ~49%. -- **2026-10-11** · Gamut warnings (gap 18): the colour picker of a Display P3 file flags colours - outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas. Showing P3 itself waits on - eframe/egui-wgpu exposing the surface colour space wgpu 30 already supports. -- **2026-10-11** · Several windows (gap 18): File ▸ New window, Open in new window on a tab or a - tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it - needs to and asks about unsaved changes when it closes (the main window too); the control channel - and MCP open, list, focus and capture windows. Hardware 60%. -- **2026-10-11** · Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls, - certificates always verified; `FIGCRAFT_CA_FILE` for a private authority), releases ship the server for Linux - x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` walks through deploying it on - a rented Linux server (systemd unit, Caddy or nginx, firewall, Docker, upgrades, backups). -- **2026-10-11** · Plugin panels and settings (gap 6, ABI v2; v1 plugins run unchanged): a plugin shows a panel of - fields and buttons drawn with FigCraft's widgets and is called back for each button or changed value (one undo step - per callback, same sandbox limits), `plugin.ui` / `plugin.event` / `plugin.close` drive panels from scripts and MCP, - and per-plugin settings are kept with the preferences; Swatch grid gained a palette panel. Plugins & widgets 45%. -- **2026-10-11** · Canvas fixes: clicks hit boolean groups where their result is (a click in a - subtracted hole reaches what's behind), a layer dragged inside a group stays in it, View ▸ Frame - outlines and View ▸ Slices draw, and a flaky idle test now waits for the render thread (gap 13). -- **2026-10-11** · Team libraries on `figcraft-server` (gap 10): Assets ▸ Libraries ▸ Publish to server keeps - the file's library on the server under the file (per-asset versions; the server's version is followed); other files - enable it by pasting a share link (a view link is enough), get "Library updates available" from a background check - and accept with overrides kept; the server counts which files use which components (Show usage, `library.usage`); - `library.swap` re-points instances, styles and variables to another library by names. Team libraries 70%. -- **2026-10-11** · Animated GIF fills (first frame on the canvas and in exports, played in the - presenter and the preview, waking the window only for the next frame) and TIFF images (gap 12). -- **2026-10-11** · The last three menu items got commands: File ▸ Open recent (kept in the - preferences, missing files greyed, Clear recent), outlines for the selection and mask outlines - (gap 21). Menu breadth 203/203. -- **2026-10-11** · Extended variable collections (a library collection's modes and variables in a - collection of the file, overridden per mode with reset, following library updates), variables - import/export (the REST local variables JSON, merged by name; CSS custom properties), gradient - stops and variant properties bound to variables (gap 14). -- **2026-10-11** · Live boolean editing: operands stay editable layers (double-click or Cmd/Ctrl-click - selects one, moving, resizing or editing its points redraws the result during the drag with every - operand outlined, per-operand operations from the boolean controls, prefix-cached recompute); - Design panel arc fields; the shape builder on strokes and open paths; one-sided variable widths - (gap 13). Feature breadth ~79%. -- **2026-10-10** · WASM plugins (#101: sandboxed wasmi runtime, manifest permissions, Plugins menu, - Run last, Manage, Rust SDK and two examples), docs refresh (#102), the multiplayer core without - networking (#103: client-scoped ids, change sets, server ordering and validation, client - reconciliation, convergence tests). Menu breadth 200/203; ready for real work ~47%. -- **2026-10-10** · Full re-measure against Figma desktop 126.10.7 under the craftrules progress-docs - standard: ready for real work ~46%, feature breadth ~76%, stage alpha; progress docs split out - (`docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, the per-area parity - docs); `docs/parity.md` renamed `docs/parity-checklist.md`. -- **2026-10-10** · Day 2 (PRs #1–#99): vector edit mode depth (transform box, point snapping, arc - handles, shape builder, variable width), text depth (variable fonts, bidi, RTL names, style - browser), auto layout nesting and keyboard reordering, slots and library variables, components - at scale (2,000 instances), effects fidelity (glass, noise, texture, progressive blur), - prototyping (conditionals, expressions, device frames, responsive presentation), REST import of - prototypes and variables, SVG import fidelity, PDF print options, Dev Mode (code, annotations, - ready for dev, compare changes), comments polish, version history, keyboard shortcut parity, - accessibility, retained canvas and 50k-layer performance, no redraws while idle; v0.2.0 - released for every platform; multiplayer protocol designed. -- **2026-10-09** · Day 1: foundation (13 crates, layering, command registry, renderer, engine, - UI3 frontend, control channel), CLI and MCP, web build, PDF export, packaging, first depth - passes on text, components, prototyping and comments. - -## Revision history - -| Date | Change | Summary | -|---|---|---| -| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | -| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. | -| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | -| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | -| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | -| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | -| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged | -| 2026-10-14 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen and leaves it again; FigCraft's own item. Numbers unchanged | -| 2026-10-14 | minor | Colour picker: wrapped rows of page and recent swatches stay inset on both sides instead of running past the picker's right edge. Numbers unchanged | -| 2026-10-14 | minor | Server snapshots (#188): figcraft-server never replaces a readable snapshot with one too large to be read back, and reads snapshots up to twice the file cap (recent batches kept for reconnecting clients may take a file over it). Numbers unchanged | -| 2026-10-14 | minor | Preferences owner-only on Unix (#200): `state.json`, which holds online owner tokens, is written with mode 0600 from the first byte, and an older world-readable file is narrowed. Numbers unchanged | -| 2026-10-14 | minor | ruzstd 0.9.1 (#182): `.fig` import requires the release that fixes RUSTSEC-2026-0344 (a zstd decompression bomb). Numbers unchanged | -| 2026-10-14 | minor | Layer cycles (#205): `validate` reports children listed twice, pages with a parent and layers not reachable from a page (cycles, detached subtrees); `repair` drops them in one linear pass. Numbers unchanged | -| 2026-10-14 | minor | AppImage CLI: `figcraft.AppImage cli ` runs the bundled `figcraft-cli` (MCP server included) on Linux without a second download. Numbers unchanged | -| 2026-10-14 | minor | Boolean outline depth (#193): nested boolean groups share one outline depth budget instead of each level getting its own. Numbers unchanged | -| 2026-10-14 | minor | Command batches roll back as one: a failed `command.batch` (MCP `batch`) restores every open document and its state, and puts the tab in front back. Numbers unchanged | -| 2026-10-14 | minor | Desktop builds no longer rebuild the text crate on every build when the craft-fonts manifest is missing. Numbers unchanged | -| 2026-10-14 | minor | Direct3D 12 on Windows: the desktop app asks wgpu for Direct3D 12 there instead of Vulkan, so it starts without Vulkan loader errors on machines with stray overlay layers. Numbers unchanged | -| 2026-10-14 | minor | Plugin metering (#192): every host call and every byte copied between a plugin and the host costs fuel, so a plugin can't stall the app through cheap-looking host calls. Numbers unchanged | -| 2026-10-14 | minor | SVG/PDF export depth (#208): explicit preflight refusal beyond 256 descendant levels, propagated through public APIs, commands and CLI, with boundary/content and destination-preservation regressions. Numbers unchanged. | -| 2026-10-13 | minor | MCP/control transport hardening (#201): frame limits during reads, bounded connections and both UI request queues, socket inactivity timeouts; percentages and estimates unchanged | -| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | -| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | -| 2026-10-13 | minor | Repeat last command (M1, #181): keep the underlying command and parameters across repeats, reject self-reference, and retain one undo step per repeated edit. Numbers unchanged. | -| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | -| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | -| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | -| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. | -| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. | -| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | -| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | -| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | -| 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | -| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials ~57% → ~59% (discoverability ×0.92 → ×0.94), its hours ~60–110 h → ~55–100 h; other numbers unchanged | -| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged | -| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains | -| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged | -| 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged | -| 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged | -| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged | -| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | -| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder mirroring the decoder and the schema of the fields written; what's left out is a warning; verified only by round trips through our own importer. File formats 59% → 60%; import/export 84%/57% → 86%/58%; file exchange stays ×0.85; mainstream hours ~130–244 h → ~120–229 h; feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta (~155–280 h) and full parity (~265–523 h) unchanged at this rounding | -| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding | -| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h | -| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h | -| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h | -| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h | -| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35%, 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h | -| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h | -| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h | -| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page until they move their own. Ephemeral presence the host validates and rate-limits, passed only to clients that read it (older clients keep working). Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; collaboration 13% unchanged, 49–98 h → 46–92 h; feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h | -| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes (several layers dropped together, Collapse layers, eye and padlock sweeps, Rename layers dialog with the documented number codes, page dragging, dividers, "Copy of" duplicates, Assets folders, hidden-from-publishing names both ways). Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | -| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | -| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | -| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | -| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h (the Features table here still showed 80%/58%/4–8 h from before branches landed) | -| 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) | -| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h | -| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h (this table still showed 80%/58%/4–8 h from before branches landed); percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | -| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h (this table still showed 95%/72%/3–6 h from before the outlines landed); percentages unchanged at this rounding; full parity ~320–615 h → ~319–614 h | -| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53% (the row still showed the figures from before `.fig` import); Dev Mode 90%/64% → 93%/70%; mainstream weekly areas 68.1% → 68.4%; percentages and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | -| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation and its fixes: rounded vector points are drawn and exported, a boolean group looks like its top layer (a subtraction like the bottom one), Alt draws lines from the centre, stars keep to 3–60 points, delete and heal follows the curve, Shift-click on a point at a corner of the points' box deselects it, outline stroke keeps the stroke's colour style, opacity and effects, the miter field shows degrees, individual corners only for rectangles and frames, Option/Alt+Shift+F flattens, MCP `key` reaches the edit tools. Added: corner radius and star ratio handles, Count and Ratio fields, cut drags that divide shapes into layers, paint bucket drags. Shapes & vector 97%/68% → 98%/72%, 1–3 h → 2–4 h. Totals recomputed from the area table, which earlier rows had left stale: feature breadth ~80% → ~82% (core ~91% → ~93%), Features (depth) 57% → 61% (core 68% → 69%), 60–115 h → 56–109 h, ready for real work ~49% → ~51%, mainstream ~46% → ~48%; full parity ~321–618 h → ~319–615 h (the selection suite's −3/−5 h hadn't reached the totals) | -| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation and its fixes: Normal groups and frames isolate their children and clipping frames don't (canvas, SVG, PDF); plus darker drawn on the canvas and in PNG exports; drop shadows hidden behind what the layer covers unless shown behind transparent areas, SVG included, with filter regions that reach as far as the effects; Fill images cover the box to its edges; "Normal" names Normal in commands and the blend menu. Paint 85%/66% → 87%/70% (breadth from the GIF/TIFF row), 5–9 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h | -| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h | -| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h | -| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h | -| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) | -| 2026-10-11 | minor | Embedded image colour profiles (ICC in PNG/JPG/WebP/TIFF, PNG sRGB/gAMA/cHRM) converted into the file's colour space when decoded. Paint 85%/66% → 90%/67% (the row had lagged the parity doc), 5–9 h → 3–6 h; totals unchanged at this rounding | -| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h | -| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | -| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | -| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged | -| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | -| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | -| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | -| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | -| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | -| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | -| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~358–687 h | -| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | -| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | -| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | -| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | -| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | -| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | -| 2026-10-10 | minor | Readiness by audience with hours to ~95% (full ~400–750, mainstream ~180–330, essentials ~85–160) | -| 2026-10-10 | minor | Added essentials user (~53%) and mainstream practitioner (~43%); ready for real work unchanged at ~46% | -| 2026-10-10 | minor | Alpha gate (core workflows) checked under the revised stage rules: passes, stays alpha | -| 2026-10-10 | major | Rewritten to the craftrules progress-docs shape after a full re-measure; rubric, area narrative and inventory moved to docs/target-app-parity.md, gaps to docs/gaps.md, milestones to docs/roadmap.md. Depth ~79% (self-graded, 16 areas) → ready for real work ~46% (all dimensions); full parity 115–195 h → 420–790 h | -| 2026-10-10 | minor | Day-2 refreshes after each batch of PRs (depth 58% → 79%) | -| 2026-10-09 | major | First roadmap | +# FigCraft Roadmap + +**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) + +FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, +native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through +the CLI, a JSON control channel and MCP. This page is the one-page summary; agents choosing work +start with [`docs/gaps.md`](docs/gaps.md). + +## Headline numbers + +| | Value | Kind | +|---|---|---| +| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | +| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | +| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) | +| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | +| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | +| Remaining to beta | **~155–280 h** one agent | estimated | +| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | + +### Readiness by audience + +| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | +|---|---|---|---| +| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) | +| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | + +Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour +per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see +[how the hours are calibrated](docs/target-app-parity.md#how-the-hours-are-calibrated)). Each audience's work is a subset of +the next. About 70–80% parallelizes across 4–6 agents on disjoint crates (roughly ÷4 in +wall-clock); the `.fig` reader, the multiplayer core and a plugin runtime each need one owner, and +native-speaker review, real-file licensing and screen-reader QA need people. + +Hours are Opus 5.5 agent wall-clock hours, calibrated from this repo's history: the app was built in +~25 wall-clock hours by 4–6 parallel agents (~110–150 agent-hours, ~1 agent-hour per 1,000 added +lines, a typical feature PR 1–2.5 h). Details and what needs a human: +[`docs/target-app-parity.md`](docs/target-app-parity.md#how-the-hours-are-calibrated). + +## Stage + +**Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components +with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but +FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked +against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs +~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured +fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h), +basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h); +native speakers' reviews of the six language drafts need people. + +## By dimension + +| Dimension | Ready | Hours | Doc | +|---|---|---|---| +| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) | +| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | +| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) | +| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) | +| Stability | 55% | 20–40 | [gaps](docs/gaps.md) | +| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | +| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | +| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | +| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | +| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | +| AI features | 0% | 30–60 | [gaps](docs/gaps.md) | + +## Features + +| Area | Breadth | Ready | Hours | Doc | +|---|---|---|---|---| +| Canvas & navigation | 98% | 74% | 1–4 | [ui-parity](docs/ui-parity.md) | +| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) | +| Layers panel | 99% | 85% | 1–2 | [ui-parity](docs/ui-parity.md) | +| Shapes & vector | 99% | 73% | 1–3 | [target-app-parity](docs/target-app-parity.md) | +| Paint | 90% | 71% | 2–5 | [target-app-parity](docs/target-app-parity.md) | +| Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) | +| Effects | 100% | 75% | 2–5 | [target-app-parity](docs/target-app-parity.md) | +| Frames, groups, sections, masks | 97% | 80% | 1–3 | [target-app-parity](docs/target-app-parity.md) | +| Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) | +| Text | 90% | 70% | 3–7 | [target-app-parity](docs/target-app-parity.md) | +| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) | +| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) | +| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) | +| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) | +| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) | +| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | +| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | +| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | +| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | +| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | +| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | + +## Languages + +| Language | Code | Strings | Status | +|---|---|---|---| +| English | en | 100% | full | +| Simplified Chinese | zh-Hans | 100% | draft (needs native review) | +| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) | +| Hindi | hi | 0% | none | +| Arabic | ar | 0% | none | +| French | fr | 100% | draft (needs native review) | +| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) | +| Indonesian | id | 0% | none | +| Japanese | ja | 100% | draft (needs native review) | +| German | de | 100% | draft (needs native review) | +| Korean | ko | 100% | draft (needs native review) | +| Vietnamese | vi | 0% | none | + +Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), +French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ +Language; Traditional Chinese has no catalog; CJK UI +text uses an installed CJK font, loaded only when needed; adding a language: +[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, +IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md). + +## Upcoming + +Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). + +1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. +2. Real-file corpus and measured fidelity: 15–30 h. +3. Tool-by-tool interaction pass against Figma: 40–60 h. +4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). +5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. + +## Progress log + +- **2026-10-15** · M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. + +- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. +- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. +- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. +- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. +- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged. +- **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged. +- **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged. +- **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged. +- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. +- **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. +- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. +- **2026-10-13** · Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. +- **2026-10-13** · Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. +- **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. +- **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. +- **2026-10-13** · Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. +- **2026-10-13** · `.fig` export: instance sizes, variables, styles (gap 1): instances write what they change from + their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as + derived data and a scale factor when scaled as a whole; variable collections, modes, variables and bindings, + the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the + import reads all of it back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; + number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged. + text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged. +- **2026-10-13** · Starter templates and first-run help: eight templates built in code (mobile app, landing page, + dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter), a + Templates tab on the home screen, `template.list` and `file.newFromTemplate` for scripts and agents, and a Getting + started card that can be closed and comes back from the Help menu +- **2026-10-12** · Your online files: a file shared or published online stays yours after its tab closes or the app + quits. The app remembers it (with its owner token, kept with the preferences and never shown) and Home ▸ **Online** + lists what you own: open a file again as its owner, start or stop sharing, copy its code, rename it, remove it from + the server or forget it here. Servers are asked once, when the list opens; tabs mark files in a live session; a + crash reopens such a file from its server and puts back the changes that hadn't reached it. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an + upgrade only to tell which files are still there. The six draft catalogs translate every string again +- **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge + preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and + object bounds, Hide when publishing in the Assets panel, a cursor for every tool and modifier, panel widths that are + kept, and dark pages (white text on them) for files made in the dark theme +- **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or + **Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that + carries the host's address); others type it under Join with a code (the popover, the home screen), where a link + works too. The server names links with codes, keeps them hashed and limits wrong ones; `collab.share`, + `collab.join {code}`; servers must be upgraded to hand codes out (older ones keep working with links) +- **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and + compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing + goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before + in the design-system example). One font family; real files still unchecked (gap 3). No number moves +- **2026-10-12** · Live sessions are per document: every open file has its own session or none, several run at + once (host one, join another, publish a third) and keep syncing behind the tab in front, the Share popover, + people, cursors, chat, spotlight and follow are the file in front's, `collab.peers` lists them all (`sessions`), + and closing a file ends its session only (before, one session followed every tab and starting another replaced it) +- **2026-10-12** · A public `figcraft-server`: `open_publish = true` lets anyone publish without the server key + (Share ▸ Publish to server asks the server first and shows who hosts it, its terms and how long unopened files are + kept), with an owner token shown once that manages the file later (`collab.manageFile`); limits per address, + idle expiry, reports (Share ▸ Report this file…), `block-ip` and `files list --sort`, `GET /healthz`, and + `packaging/server/install.sh` that sets up a Debian/Ubuntu machine with Caddy, ufw and backups + ([server.md](docs/server.md#running-a-public-server)). Numbers unchanged (hosting, not a Figma feature). +- **2026-10-12** · `.fig` export opens elsewhere (gap 1): written files were checked by opening them in other + software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. The + export now says that the document and its pages are visible and opaque, numbers its schema as the format does, + and writes outlines, text settings, standard font style names and line height units; effects such as glass and + noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers + unchanged. +- **2026-10-12** · Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig + (experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document: layers, + geometry, paints with their images, strokes, effects, styled text, vector networks, auto layout, components and + instances with their overrides, and prototype interactions; what's left out (style links, variables, comments, + Dev Mode data…) comes back as warnings. Checked only by reading the files back with our own importer. File + formats 60%. +- **2026-10-12** · Keyboard shortcut parity (gap 4): a table test with a row for each of the 129 + shortcuts in the public documentation, macOS and Windows/Linux keys each, and the 48 bindings it + is silent on pinned; 126 bound (110 before), Run last plugin moved off a documented conflict on + Windows, 2 missing. New: others' cursors toggle, list levels on Cmd/Ctrl+] and [ while editing, + keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, Control + suspending snapping on macOS, Space/N/C/F while presenting, the Frame tool on A, pixel grid on + Shift+', link on Shift+Cmd+U, ungroup on Cmd/Ctrl+Backspace, suggest auto layout and go to main + component keys; shifted symbols on other keyboard layouts. +- **2026-10-12** · Plugin directory on `figcraft-server` (gap 6): publish a plugin folder with the server key + (`figcraft-cli plugin publish`), browse a server's plugins in Plugins ▸ Manage plugins ▸ Browse, install after a + confirmation that shows the permissions it asks for, update when a newer version appears, remove; bundles are + checked in full on the server and again in the app, downloads must match their SHA-256, installed plugins live + per user in the same sandbox; `plugin.directory.*` for scripts and MCP; the operator moderates with + `figcraft-server plugins`. Plugins & widgets 60%. +- **2026-10-12** · Plugin widgets and the panel tail (gap 6, plugin API 3; older plugins run unchanged): a widget + plugin puts a layer of its own on the canvas (Plugins ▸ Widgets, `widget.insert`), described by the plugin, + validated and built with locked layers inside; clicking a part it named calls it back with the widget's state + (kept in the layer, so it saves, copies, undoes, exports and syncs) as one undo step, and it redraws; + `widget.click` and `widget.list` for agents; a Counter example. Panels of several plugins open side by side and + plugin colour fields open the colour picker. Plugins & widgets 55%. +- **2026-10-12** · Layers and Assets panels behaviour suite (gap 4): several layers dropped together land + together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers + at once, a Rename layers dialog for several layers (number codes as documented, regular expressions, a start + value), pages dragged to reorder, dividers, "Copy of" duplicates, Assets folders by page, frame and slash names, + and hidden-from-publishing names that decide both ways. +- **2026-10-12** · Vector multi-edit and the vector edit tail (gap 9): several vector layers in edit + mode at once (select, marquee, drag, nudge, delete, mirroring and radius across them), several + Bezier handles dragged together, rounded points beside curved segments, a stroke cap and join per + point, and the Pen connecting to its own points or continuing a selected open path. +- **2026-10-11** · Canvas navigation, guides and grids behaviour suite (gap 4): zoom, panning, the pixel + grid (now from 400%), rulers, guides (top-level frames only; select, Delete, nudge, Remove guide, + Alt-drag copies, Alt measures while dragging), layout grids (Auto counts; hidden on rotated frames), + outline mode (clicks reach layers behind) and measuring, each rule from the documentation. +- **2026-10-11** · Export and inspect behaviour suite (gap 4): exports cover what a layer paints (shadows + only where they fall, true stroke widths, no room for hidden layers, a mask group's mask), slices export what's under + them, layer names with slashes export into folders; export settings gained contents only, include bounding box and + the SVG options (outline text, ids, simplified strokes); Inspect shows colours in hex, RGB, CSS, HSL or HSB, CSS in + rem, blend modes and positions from the frame, and lists icons and images as assets. Dev Mode 70%. +- **2026-10-11** · `.fig` import's tail (gap 1): vectors open as editable vector networks with their + corner radii, handle mirroring and end caps (outlines when the network is missing or damaged); sticky notes, shapes + with text, connectors, tables and similar layers import as frames, text and vectors instead of being dropped; + set-variable and conditional prototype actions come across. File formats 58%, ready ~49%. +- **2026-10-11** · Paint and effects behaviour suite: every documented rule for fills, blend modes, + gradients, images, colour models, shadows, blurs and effect exports is a table-driven case. Fixes: Normal + groups isolate and clipping frames don't, plus darker is drawn, drop shadows hide behind translucent + layers in every export, Fill images reach the box's edges, "Normal" no longer means pass-through. + Paint 70%, effects 75%. +- **2026-10-11** · `.fig` import, best effort (gap 1): `file.open`, `file.import`, drag and drop, the CLI and + MCP open `.fig` files. A generic kiwi decoder reads each file's own schema, and the decoded nodes map by field name + onto the REST importer: layers, paints and styles, text runs, vectors as outlines, auto layout, components and + instances (expanded with their overrides and property values), variables, prototyping and images from the archive; + the rest is a warning. File formats 55%, ready ~49%. +- **2026-10-11** · Gamut warnings (gap 18): the colour picker of a Display P3 file flags colours + outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas. Showing P3 itself waits on + eframe/egui-wgpu exposing the surface colour space wgpu 30 already supports. +- **2026-10-11** · Several windows (gap 18): File ▸ New window, Open in new window on a tab or a + tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it + needs to and asks about unsaved changes when it closes (the main window too); the control channel + and MCP open, list, focus and capture windows. Hardware 60%. +- **2026-10-11** · Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls, + certificates always verified; `FIGCRAFT_CA_FILE` for a private authority), releases ship the server for Linux + x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` walks through deploying it on + a rented Linux server (systemd unit, Caddy or nginx, firewall, Docker, upgrades, backups). +- **2026-10-11** · Plugin panels and settings (gap 6, ABI v2; v1 plugins run unchanged): a plugin shows a panel of + fields and buttons drawn with FigCraft's widgets and is called back for each button or changed value (one undo step + per callback, same sandbox limits), `plugin.ui` / `plugin.event` / `plugin.close` drive panels from scripts and MCP, + and per-plugin settings are kept with the preferences; Swatch grid gained a palette panel. Plugins & widgets 45%. +- **2026-10-11** · Canvas fixes: clicks hit boolean groups where their result is (a click in a + subtracted hole reaches what's behind), a layer dragged inside a group stays in it, View ▸ Frame + outlines and View ▸ Slices draw, and a flaky idle test now waits for the render thread (gap 13). +- **2026-10-11** · Team libraries on `figcraft-server` (gap 10): Assets ▸ Libraries ▸ Publish to server keeps + the file's library on the server under the file (per-asset versions; the server's version is followed); other files + enable it by pasting a share link (a view link is enough), get "Library updates available" from a background check + and accept with overrides kept; the server counts which files use which components (Show usage, `library.usage`); + `library.swap` re-points instances, styles and variables to another library by names. Team libraries 70%. +- **2026-10-11** · Animated GIF fills (first frame on the canvas and in exports, played in the + presenter and the preview, waking the window only for the next frame) and TIFF images (gap 12). +- **2026-10-11** · The last three menu items got commands: File ▸ Open recent (kept in the + preferences, missing files greyed, Clear recent), outlines for the selection and mask outlines + (gap 21). Menu breadth 203/203. +- **2026-10-11** · Extended variable collections (a library collection's modes and variables in a + collection of the file, overridden per mode with reset, following library updates), variables + import/export (the REST local variables JSON, merged by name; CSS custom properties), gradient + stops and variant properties bound to variables (gap 14). +- **2026-10-11** · Live boolean editing: operands stay editable layers (double-click or Cmd/Ctrl-click + selects one, moving, resizing or editing its points redraws the result during the drag with every + operand outlined, per-operand operations from the boolean controls, prefix-cached recompute); + Design panel arc fields; the shape builder on strokes and open paths; one-sided variable widths + (gap 13). Feature breadth ~79%. +- **2026-10-10** · WASM plugins (#101: sandboxed wasmi runtime, manifest permissions, Plugins menu, + Run last, Manage, Rust SDK and two examples), docs refresh (#102), the multiplayer core without + networking (#103: client-scoped ids, change sets, server ordering and validation, client + reconciliation, convergence tests). Menu breadth 200/203; ready for real work ~47%. +- **2026-10-10** · Full re-measure against Figma desktop 126.10.7 under the craftrules progress-docs + standard: ready for real work ~46%, feature breadth ~76%, stage alpha; progress docs split out + (`docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, the per-area parity + docs); `docs/parity.md` renamed `docs/parity-checklist.md`. +- **2026-10-10** · Day 2 (PRs #1–#99): vector edit mode depth (transform box, point snapping, arc + handles, shape builder, variable width), text depth (variable fonts, bidi, RTL names, style + browser), auto layout nesting and keyboard reordering, slots and library variables, components + at scale (2,000 instances), effects fidelity (glass, noise, texture, progressive blur), + prototyping (conditionals, expressions, device frames, responsive presentation), REST import of + prototypes and variables, SVG import fidelity, PDF print options, Dev Mode (code, annotations, + ready for dev, compare changes), comments polish, version history, keyboard shortcut parity, + accessibility, retained canvas and 50k-layer performance, no redraws while idle; v0.2.0 + released for every platform; multiplayer protocol designed. +- **2026-10-09** · Day 1: foundation (13 crates, layering, command registry, renderer, engine, + UI3 frontend, control channel), CLI and MCP, web build, PDF export, packaging, first depth + passes on text, components, prototyping and comments. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | +| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | +| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. | +| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | +| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | +| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | +| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | +| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged | +| 2026-10-14 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen and leaves it again; FigCraft's own item. Numbers unchanged | +| 2026-10-14 | minor | Colour picker: wrapped rows of page and recent swatches stay inset on both sides instead of running past the picker's right edge. Numbers unchanged | +| 2026-10-14 | minor | Server snapshots (#188): figcraft-server never replaces a readable snapshot with one too large to be read back, and reads snapshots up to twice the file cap (recent batches kept for reconnecting clients may take a file over it). Numbers unchanged | +| 2026-10-14 | minor | Preferences owner-only on Unix (#200): `state.json`, which holds online owner tokens, is written with mode 0600 from the first byte, and an older world-readable file is narrowed. Numbers unchanged | +| 2026-10-14 | minor | ruzstd 0.9.1 (#182): `.fig` import requires the release that fixes RUSTSEC-2026-0344 (a zstd decompression bomb). Numbers unchanged | +| 2026-10-14 | minor | Layer cycles (#205): `validate` reports children listed twice, pages with a parent and layers not reachable from a page (cycles, detached subtrees); `repair` drops them in one linear pass. Numbers unchanged | +| 2026-10-14 | minor | AppImage CLI: `figcraft.AppImage cli ` runs the bundled `figcraft-cli` (MCP server included) on Linux without a second download. Numbers unchanged | +| 2026-10-14 | minor | Boolean outline depth (#193): nested boolean groups share one outline depth budget instead of each level getting its own. Numbers unchanged | +| 2026-10-14 | minor | Command batches roll back as one: a failed `command.batch` (MCP `batch`) restores every open document and its state, and puts the tab in front back. Numbers unchanged | +| 2026-10-14 | minor | Desktop builds no longer rebuild the text crate on every build when the craft-fonts manifest is missing. Numbers unchanged | +| 2026-10-14 | minor | Direct3D 12 on Windows: the desktop app asks wgpu for Direct3D 12 there instead of Vulkan, so it starts without Vulkan loader errors on machines with stray overlay layers. Numbers unchanged | +| 2026-10-14 | minor | Plugin metering (#192): every host call and every byte copied between a plugin and the host costs fuel, so a plugin can't stall the app through cheap-looking host calls. Numbers unchanged | +| 2026-10-14 | minor | SVG/PDF export depth (#208): explicit preflight refusal beyond 256 descendant levels, propagated through public APIs, commands and CLI, with boundary/content and destination-preservation regressions. Numbers unchanged. | +| 2026-10-13 | minor | MCP/control transport hardening (#201): frame limits during reads, bounded connections and both UI request queues, socket inactivity timeouts; percentages and estimates unchanged | +| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | +| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | +| 2026-10-13 | minor | Repeat last command (M1, #181): keep the underlying command and parameters across repeats, reject self-reference, and retain one undo step per repeated edit. Numbers unchanged. | +| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | +| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | +| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | +| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. | +| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. | +| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | +| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | +| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | +| 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | +| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials ~57% → ~59% (discoverability ×0.92 → ×0.94), its hours ~60–110 h → ~55–100 h; other numbers unchanged | +| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged | +| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains | +| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged | +| 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged | +| 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged | +| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged | +| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | +| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder mirroring the decoder and the schema of the fields written; what's left out is a warning; verified only by round trips through our own importer. File formats 59% → 60%; import/export 84%/57% → 86%/58%; file exchange stays ×0.85; mainstream hours ~130–244 h → ~120–229 h; feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta (~155–280 h) and full parity (~265–523 h) unchanged at this rounding | +| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding | +| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h | +| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h | +| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h | +| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h | +| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35%, 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h | +| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h | +| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h | +| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page until they move their own. Ephemeral presence the host validates and rate-limits, passed only to clients that read it (older clients keep working). Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; collaboration 13% unchanged, 49–98 h → 46–92 h; feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h | +| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes (several layers dropped together, Collapse layers, eye and padlock sweeps, Rename layers dialog with the documented number codes, page dragging, dividers, "Copy of" duplicates, Assets folders, hidden-from-publishing names both ways). Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | +| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | +| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | +| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | +| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h (the Features table here still showed 80%/58%/4–8 h from before branches landed) | +| 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) | +| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h | +| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h (this table still showed 80%/58%/4–8 h from before branches landed); percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | +| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h (this table still showed 95%/72%/3–6 h from before the outlines landed); percentages unchanged at this rounding; full parity ~320–615 h → ~319–614 h | +| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53% (the row still showed the figures from before `.fig` import); Dev Mode 90%/64% → 93%/70%; mainstream weekly areas 68.1% → 68.4%; percentages and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | +| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation and its fixes: rounded vector points are drawn and exported, a boolean group looks like its top layer (a subtraction like the bottom one), Alt draws lines from the centre, stars keep to 3–60 points, delete and heal follows the curve, Shift-click on a point at a corner of the points' box deselects it, outline stroke keeps the stroke's colour style, opacity and effects, the miter field shows degrees, individual corners only for rectangles and frames, Option/Alt+Shift+F flattens, MCP `key` reaches the edit tools. Added: corner radius and star ratio handles, Count and Ratio fields, cut drags that divide shapes into layers, paint bucket drags. Shapes & vector 97%/68% → 98%/72%, 1–3 h → 2–4 h. Totals recomputed from the area table, which earlier rows had left stale: feature breadth ~80% → ~82% (core ~91% → ~93%), Features (depth) 57% → 61% (core 68% → 69%), 60–115 h → 56–109 h, ready for real work ~49% → ~51%, mainstream ~46% → ~48%; full parity ~321–618 h → ~319–615 h (the selection suite's −3/−5 h hadn't reached the totals) | +| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation and its fixes: Normal groups and frames isolate their children and clipping frames don't (canvas, SVG, PDF); plus darker drawn on the canvas and in PNG exports; drop shadows hidden behind what the layer covers unless shown behind transparent areas, SVG included, with filter regions that reach as far as the effects; Fill images cover the box to its edges; "Normal" names Normal in commands and the blend menu. Paint 85%/66% → 87%/70% (breadth from the GIF/TIFF row), 5–9 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h | +| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h | +| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h | +| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h | +| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) | +| 2026-10-11 | minor | Embedded image colour profiles (ICC in PNG/JPG/WebP/TIFF, PNG sRGB/gAMA/cHRM) converted into the file's colour space when decoded. Paint 85%/66% → 90%/67% (the row had lagged the parity doc), 5–9 h → 3–6 h; totals unchanged at this rounding | +| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h | +| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | +| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | +| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged | +| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | +| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | +| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | +| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | +| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | +| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~358–687 h | +| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | +| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | +| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | +| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | +| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | +| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | +| 2026-10-10 | minor | Readiness by audience with hours to ~95% (full ~400–750, mainstream ~180–330, essentials ~85–160) | +| 2026-10-10 | minor | Added essentials user (~53%) and mainstream practitioner (~43%); ready for real work unchanged at ~46% | +| 2026-10-10 | minor | Alpha gate (core workflows) checked under the revised stage rules: passes, stays alpha | +| 2026-10-10 | major | Rewritten to the craftrules progress-docs shape after a full re-measure; rubric, area narrative and inventory moved to docs/target-app-parity.md, gaps to docs/gaps.md, milestones to docs/roadmap.md. Depth ~79% (self-graded, 16 areas) → ready for real work ~46% (all dimensions); full parity 115–195 h → 420–790 h | +| 2026-10-10 | minor | Day-2 refreshes after each batch of PRs (depth 58% → 79%) | +| 2026-10-09 | major | First roadmap | diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index bed70d3d..fd766735 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -1,650 +1,653 @@ -//! Structure: groups, frames, masks, boolean groups, flatten, outline stroke, auto layout, pages. - -use figcraft_color::{Color, Paint}; -use figcraft_doc::*; -use figcraft_geom::{Affine, BezPath, Rect, Shape}; -use serde_json::{Value, json}; - -use super::edit::{roots, z_sorted}; -use super::{CommandSpec, ok}; -use crate::params::*; -use crate::{EngineError, Result, Session, bad, spec}; - -pub fn specs() -> Vec { - vec![ - spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)), - spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup), - spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)), - spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup), - spec!( - "object.convertToGroup", - "Convert to group", - "{ids?} (frames become groups in place, their layers staying where they are; what only a frame has goes: auto layout, fills, strokes, corner radius, clipping, layout grids, guides and scrolling. Groups are left alone; instances, main components, layers inside instances and empty frames are refused)", - true, - |s, p| convert(s, p, "object.convertToGroup", false) - ), - spec!( - "object.convertToFrame", - "Convert to frame", - "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)", - true, - |s, p| convert(s, p, "object.convertToFrame", true) - ), - spec!( - "object.wrapInSection", - "Wrap in new section", - "{ids?, name?} (layers on the page or in a section; the section wraps them with some room)", - true, - |s, p| wrap(s, p, Wrap::Section) - ), - spec!( - "object.resizeToFit", - "Resize to fit", - "{ids?} (frames, sections and components shrink or grow to their contents; the contents stay put)", - true, - resize_to_fit - ), - spec!("object.mask", "Use as mask", "{ids?}", true, mask), - spec!("object.releaseMask", "Release mask", "{ids?}", true, |s, p| { - for i in req_targets("object.releaseMask", s, p)? { - s.doc_mut()?.get_mut(i)?.is_mask = false; - } - ok() - }), - spec!("bool.union", "Union selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.union", p, BoolOp::Union)), - spec!("bool.subtract", "Subtract selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.subtract", p, BoolOp::Subtract)), - spec!("bool.intersect", "Intersect selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.intersect", p, BoolOp::Intersect)), - spec!("bool.exclude", "Exclude selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.exclude", p, BoolOp::Exclude)), - spec!("bool.flatten", "Flatten boolean", "{ids?}", true, flatten), - spec!("vector.flatten", "Flatten", "{ids?}", true, flatten), - spec!("vector.outlineStroke", "Outline stroke", "{ids?}", true, outline_stroke), - spec!("layout.add", "Add auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add), - spec!("layout.remove", "Remove auto layout", "{ids?}", true, |s, p| { - for i in req_targets("layout.remove", s, p)? { - // Hugging needs auto layout and the children that filled have nothing to fill: both - // become fixed (the frame keeps filling its own auto layout parent). - let doc = s.doc_mut()?; - doc.get_mut(i)?.layout = None; - figcraft_layout::coherent_sizing(doc, i); - } - ok() - }), - spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add), - spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page), - spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| { - let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?; - let doc = s.doc_mut()?; - for n in doc.ids().collect::>() { - if let Some(node) = doc.node(n) - && node.plugin_data.contains_key("figcraft.thumbnail") - { - doc.get_mut(n)?.plugin_data.remove("figcraft.thumbnail"); - } - } - doc.get_mut(id)?.plugin_data.insert("figcraft.thumbnail".into(), "true".into()); - ok() - }), - spec!("object.rasterize", "Rasterize selection", "{ids?, scale? (default 2)}", true, rasterize), - spec!("vector.reverse", "Reverse path direction", "{ids?}", true, |s, p| { - for i in req_targets("vector.reverse", s, p)? { - if let NodeKind::Vector { network } = &mut s.doc_mut()?.get_mut(i)?.kind { - for seg in &mut network.segments { - std::mem::swap(&mut seg.a, &mut seg.b); - std::mem::swap(&mut seg.ta, &mut seg.tb); - } - } - } - ok() - }), - spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify), - spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path), - spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| { - crate::vectoredit::join_in_edit_mode(s, false).unwrap_or_else(|| join(s, p)) - }), - spec!("vector.smoothJoin", "Smooth join selection", "{ids?} (in vector edit mode: joins the selected points smoothly)", true, |s, p| { - crate::vectoredit::join_in_edit_mode(s, true).unwrap_or_else(|| join(s, p)) - }), - ] -} - -enum Wrap { - Group, - Frame, - Section, - Bool(BoolOp), -} - -/// Room a section made around layers leaves on each side. -const SECTION_PAD: f64 = 40.0; - -/// What the boolean commands do with what they're given. -const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group"; - -/// Wrap the selection in a new boolean group doing `op`. -pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result { - wrap(s, p, Wrap::Bool(op)) -} - -/// Wrap the selection in a new container at the topmost selected layer's place. -fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result { - let ids = req_targets("object.group", s, p)?; - let doc = s.doc_mut()?; - let ids = z_sorted(doc, &roots(doc, &ids)); - let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?; - let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?; - // Sections sit on the page or in sections, never in frames or groups. - let board = |doc: &Document, i: NodeId| { - doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) - }; - if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) { - return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section")); - } - if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) { - return Err(bad("object.group", "sections can't go in groups or frames")); - } - let index = doc.index_in_parent(top).map(|i| i + 1); - let mut bounds = doc.bounds_of(&ids).unwrap_or_default(); - if matches!(w, Wrap::Section) { - bounds = bounds.inflate(SECTION_PAD, SECTION_PAD); - } - let pw = doc.world_transform(parent); - let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds); - let (kind, base) = match &w { - Wrap::Group => (NodeKind::Group, "Group"), - Wrap::Frame => (NodeKind::Frame, "Frame"), - Wrap::Section => (NodeKind::Section { contents_hidden: false }, "Section"), - Wrap::Bool(op) => (NodeKind::BooleanOp { op: *op }, op.label()), - }; - let name = string(p, "name").unwrap_or_else(|| if matches!(w, Wrap::Bool(_)) { base.to_string() } else { doc.next_name(base) }); - let gid = doc.alloc_id(); - let mut g = match w { - Wrap::Section => Node::new_section(gid, name, local.width(), local.height()), - _ => Node::new(gid, kind, name, local.width(), local.height()), - }; - g.transform = Affine::translate((local.x0, local.y0)); - if let Wrap::Bool(op) = w { - // A boolean group looks like its top layer; a subtraction like the bottom one it cuts. - let source = if op == BoolOp::Subtract { ids.first() } else { ids.last() }; - if let Some(first) = source.and_then(|i| doc.node(*i)) { - g.fills = if first.fills.is_empty() { vec![Paint::solid(Color::DEFAULT_SHAPE)] } else { first.fills.clone() }; - g.strokes = first.strokes.clone(); - g.stroke = first.stroke.clone(); - g.effects = first.effects.clone(); - } - g.blend = figcraft_color::BlendMode::Normal; - } - if let Wrap::Frame = w { - g.clip = true; - } - // Insert the container where the topmost layer is. - doc.insert(g, parent, index)?; - for i in &ids { - doc.reparent(*i, gid, None)?; - } - if !matches!(w, Wrap::Frame | Wrap::Section) { - let _ = doc.fit_to_children(gid); - } - s.set_selection(vec![gid]); - Ok(json!({"id": gid})) -} - -/// Turn frames into groups or groups into frames in place (same id, name, layers, effects, -/// opacity, exports and interactions), the layers inside staying where they are on the page. -/// Nothing changes unless every layer can convert. -fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Result { - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let mut todo = Vec::new(); - for i in ids { - let n = doc.get(i)?; - let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); - match (&n.kind, to_frame) { - (NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"), - (NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"), - (NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => { - return refuse("is inside an instance: edit its main component, or detach the instance"); - } - (NodeKind::Frame, false) if n.children.is_empty() => return refuse("is empty: a group needs layers"), - (NodeKind::Frame, false) | (NodeKind::Group, true) => todo.push(i), - (NodeKind::Frame | NodeKind::Group, _) => {} - _ => return refuse("is neither a frame nor a group"), - } - } - for i in &todo { - if to_frame { - // The group's box as it is; nothing painted or clipped, so the look stays. - let n = doc.get_mut(*i)?; - n.kind = NodeKind::Frame; - n.clip = false; - n.fills.clear(); - n.strokes.clear(); - continue; - } - let n = doc.get_mut(*i)?; - n.kind = NodeKind::Group; - (n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None); - n.fills.clear(); - n.strokes.clear(); - n.grids.clear(); - n.guides.clear(); - n.corners = Corners::default(); - (n.styles.fill, n.styles.stroke, n.styles.grid) = (None, None, None); - // Variables bound to what went would bring it back. - n.bound.retain(|k, _| matches!(k.as_str(), "opacity" | "visible")); - let kids = n.children.clone(); - for k in kids { - // Out of the flow there is nothing to fill or to be absolute against. - doc.get_mut(k)?.child_layout.absolute = false; - } - figcraft_layout::coherent_sizing(doc, *i); - doc.fit_to_children(*i)?; - } - Ok(json!({"ids": todo})) -} - -fn ungroup(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("object.ungroup", s, p)?; - let doc = s.doc_mut()?; - let mut out = Vec::new(); - for g in ids { - let n = doc.get(g)?; - if !(matches!(n.kind, NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Frame | NodeKind::Section { .. })) { - continue; - } - let Some(parent) = n.parent else { continue }; - let idx = doc.index_in_parent(g).unwrap_or(0); - let kids = n.children.clone(); - // Ungrouping a group keeps its opacity on the children (multiplied). - let op = n.opacity; - for (k, c) in kids.iter().enumerate() { - doc.reparent(*c, parent, Some(idx + k))?; - // Released operands no longer combine with anything. - if doc.get(*c)?.bool_op.is_some() { - doc.get_mut(*c)?.bool_op = None; - } - if op < 1.0 { - let cn = doc.get_mut(*c)?; - cn.opacity *= op; - } - out.push(*c); - } - doc.remove(g)?; - } - s.set_selection(out.clone()); - Ok(json!({"ids": out})) -} - -fn mask(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("object.mask", s, p)?; - if ids.len() == 1 { - let doc = s.doc_mut()?; - let id = ids.first().copied().unwrap_or(NodeId(0)); - let n = doc.get_mut(id)?; - n.is_mask = !n.is_mask; - return ok(); - } - // Group, then the bottom-most child is the mask. - let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?; - let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?; - let doc = s.doc_mut()?; - if let Some(first) = doc.get(gid)?.children.first().copied() { - doc.get_mut(first)?.is_mask = true; - } - Ok(r) -} - -/// The outline of a node in its parent's space (shapes, text, booleans, groups). -pub fn outline_in_parent(doc: &Document, id: NodeId) -> Option { - let n = doc.node(id)?; - let (mut p, _) = figcraft_render::subtree_outline(doc, n, 0)?; - p.apply_affine(n.transform); - Some(p) -} - -fn flatten(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("vector.flatten", s, p)?; - let doc = s.doc_mut()?; - let ids = z_sorted(doc, &roots(doc, &ids)); - let top = *ids.last().ok_or_else(|| bad("vector.flatten", "nothing selected"))?; - let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?; - let index = doc.index_in_parent(top).map(|i| i + 1); - let pw = doc.world_transform(parent); - let mut path = BezPath::new(); - let mut even = false; - for i in &ids { - let Some(n) = doc.node(*i) else { continue }; - // Booleans flatten to their result; strokes don't get outlined. - let Some((mut op, rule)) = (match &n.kind { - NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0), - _ => figcraft_render::node_outline(doc, n), - }) else { - continue; - }; - even |= rule == figcraft_render::FillRule::EvenOdd; - // Into the common parent's space. - let w = doc.world_transform(*i); - op.apply_affine(pw.inverse() * w); - path.extend(op.elements().iter().copied()); - } - if path.elements().is_empty() { - return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into())); - } - let first = ids.first().and_then(|i| doc.node(*i)).cloned(); - let bb = path.bounding_box(); - path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut net = VectorNetwork::from_path(&path); - for r in &mut net.regions { - r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero }; - } - let vid = doc.alloc_id(); - let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into()); - let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height()); - v.transform = Affine::translate((bb.x0, bb.y0)); - if let Some(f) = first { - v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() { - vec![Paint::solid(Color::DEFAULT_SHAPE)] - } else { - f.fills.clone() - }; - v.strokes = f.strokes.clone(); - v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() }; - v.effects = f.effects.clone(); - v.opacity = f.opacity; - v.blend = if matches!(f.blend, figcraft_color::BlendMode::PassThrough) { figcraft_color::BlendMode::Normal } else { f.blend }; - } - doc.insert(v, parent, index)?; - for i in &ids { - doc.remove(*i)?; - } - s.set_selection(vec![vid]); - Ok(json!({"id": vid})) -} - -fn outline_stroke(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("vector.outlineStroke", s, p)?; - let mut out = Vec::new(); - for i in ids { - let doc = s.doc_mut()?; - let n = doc.get(i)?.clone(); - if !n.has_visible_stroke() { - continue; - } - let closed = figcraft_doc::geometry::is_closed(&n); - let Some(geom) = (match &n.kind { - NodeKind::Text(_) | NodeKind::BooleanOp { .. } => figcraft_render::node_outline(doc, &n).map(|(p, _)| p), - _ => figcraft_doc::geometry::stroke_geometry(&n), - }) else { - continue; - }; - let area = figcraft_render::draw::stroke_area(&n, &geom, closed); - let mut path = (*area).clone(); - // Union the pieces so the outline is one clean shape. - if let Ok(u) = figcraft_pathops::simplify(&path) { - path = u; - } - let bb = path.bounding_box(); - path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut net = VectorNetwork::from_path(&path); - for r in &mut net.regions { - r.winding = WindingRule::EvenOdd; - } - let parent = n.parent.ok_or_else(|| bad("vector.outlineStroke", "page"))?; - let idx = doc.index_in_parent(i); - let vid = doc.alloc_id(); - let mut v = Node::new(vid, NodeKind::Vector { network: net }, n.name.clone(), bb.width(), bb.height()); - v.transform = n.transform * Affine::translate((bb.x0, bb.y0)); - // The stroke's paints (and its colour style) fill the outline. - v.fills = n.strokes.clone(); - v.strokes.clear(); - v.styles.fill = n.styles.stroke; - if n.fills.iter().any(|f| f.visible) && closed { - // Keep the fill as its own layer under the outline, grouped. - doc.insert(v, parent, idx.map(|x| x + 1))?; - let fill_id = i; - { - let f = doc.get_mut(fill_id)?; - f.strokes.clear(); - } - let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?; - if let Some(g) = id_of(&r["id"]) { - out.push(g); - } - continue; - } - // Standing in for the layer, the outline keeps how it is drawn and placed. - (v.opacity, v.blend, v.effects, v.styles.effect) = (n.opacity, n.blend, n.effects.clone(), n.styles.effect); - (v.constraints, v.child_layout, v.visible, v.locked) = (n.constraints, n.child_layout.clone(), n.visible, n.locked); - doc.insert(v, parent, idx.map(|x| x + 1))?; - doc.remove(i)?; - out.push(vid); - } - s.set_selection(out.clone()); - Ok(json!({"ids": out})) -} - -/// Infer direction, gap and padding from where children are (Figma's "Add auto layout"). -fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout { - let Some(f) = doc.node(frame) else { return AutoLayout::default() }; - let boxes: Vec = - f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect(); - if boxes.is_empty() { - return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() }; - } - let span_x = boxes.iter().map(|b| b.x1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.x0).fold(f64::MAX, f64::min); - let span_y = boxes.iter().map(|b| b.y1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.y0).fold(f64::MAX, f64::min); - let vertical = span_y > span_x; - let mut sorted = boxes.clone(); - sorted.sort_by(|a, b| if vertical { a.y0.total_cmp(&b.y0) } else { a.x0.total_cmp(&b.x0) }); - let gaps: Vec = - sorted.windows(2).filter_map(|w| Some(if vertical { w.get(1)?.y0 - w.first()?.y1 } else { w.get(1)?.x0 - w.first()?.x1 })).collect(); - let gap = if gaps.is_empty() { 10.0 } else { (gaps.iter().sum::() / gaps.len() as f64).max(0.0).round() }; - let all = boxes.iter().copied().reduce(|a, b| a.union(b)).unwrap_or_default(); - let pad = [all.y0.max(0.0), (f.width - all.x1).max(0.0), (f.height - all.y1).max(0.0), all.x0.max(0.0)].map(f64::round); - AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() } -} - -fn layout_add(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("layout.add", s, p)?; - let dir = string(p, "direction"); - let doc = s.doc()?; - let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())); - let targets = if all_frames { - ids - } else { - // Wrap non-frames in a new frame without a fill, then add auto layout to it. - let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?; - let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?; - let f = s.doc_mut()?.get_mut(fid)?; - f.fills.clear(); - f.clip = false; - vec![fid] - }; - let doc = s.doc_mut()?; - for f in &targets { - let mut al = infer_layout(doc, *f); - if let Some(d) = &dir { - al.mode = match d.to_ascii_lowercase().as_str() { - "vertical" | "column" => LayoutMode::Vertical, - "grid" => LayoutMode::Grid, - _ => LayoutMode::Horizontal, - }; - } - // Children in reading order along the axis. - let mut kids = doc.get(*f)?.children.clone(); - let vertical = al.mode == LayoutMode::Vertical; - kids.sort_by(|a, b| { - let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); - let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); - if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) } - }); - let n = doc.get_mut(*f)?; - n.children = kids; - n.layout = Some(al); - n.child_layout.sizing_h = Sizing::Hug; - n.child_layout.sizing_v = Sizing::Hug; - } - s.set_selection(targets.clone()); - Ok(json!({"ids": targets})) -} - -fn move_to_page(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("object.moveToPage", s, p)?; - let page = req_id("object.moveToPage", p, "page")?; - let doc = s.doc_mut()?; - if !doc.node(page).is_some_and(Node::is_page) { - return Err(bad("object.moveToPage", format!("{page} is not a page"))); - } - if doc.is_divider(page) { - return Err(bad("object.moveToPage", "a divider holds no layers")); - } - for i in z_sorted(doc, &roots(doc, &ids)) { - doc.reparent(i, page, None)?; - } - s.set_selection(vec![]); - ok() -} - -fn rasterize(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("object.rasterize", s, p)?; - let scale = num_or(p, "scale", 2.0); - let mut out = Vec::new(); - for i in ids { - let doc = s.doc()?; - let Some(pix) = figcraft_render::render_node(doc, i, scale) else { continue }; - let png = figcraft_render::encode_png(&pix).map_err(EngineError::Other)?; - let b = figcraft_render::export_bounds(doc, i).unwrap_or_default(); - let parent = doc.get(i)?.parent.ok_or_else(|| bad("object.rasterize", "page"))?; - let idx = doc.index_in_parent(i); - let pw = doc.world_transform(parent); - let name = doc.get(i)?.name.clone(); - let docm = s.doc_mut()?; - let (hash, _, _) = figcraft_format::add_image(docm, png).map_err(|e| EngineError::Other(e.to_string()))?; - let rid = docm.alloc_id(); - let mut r = Node::new(rid, NodeKind::Rectangle, name, b.width(), b.height()); - r.transform = pw.inverse() * Affine::translate((b.x0, b.y0)); - r.fills = vec![Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; - docm.insert(r, parent, idx.map(|x| x + 1))?; - docm.remove(i)?; - out.push(rid); - } - s.set_selection(out.clone()); - Ok(json!({"ids": out})) -} - -fn simplify(s: &mut Session, p: &Value) -> Result { - let tol = num_or(p, "tolerance", 0.5).max(0.01); - for i in req_targets("vector.simplify", s, p)? { - let doc = s.doc_mut()?; - let n = doc.get_mut(i)?; - if let NodeKind::Vector { network } = &mut n.kind { - let fill = network.fill_path(); - let src = if fill.elements().is_empty() { network.stroke_path() } else { fill }; - let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default()); - let regions = !network.regions.is_empty(); - let mut net = VectorNetwork::from_path(&simplified); - if !regions { - net.regions.clear(); - } - *network = net; - } - } - ok() -} - -fn offset_path(s: &mut Session, p: &Value) -> Result { - let d = req_num("vector.offset", p, "offset")?; - for i in req_targets("vector.offset", s, p)? { - let doc = s.doc_mut()?; - let n = doc.get(i)?.clone(); - let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue }; - let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() }; - let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props); - let res = if d >= 0.0 { - figcraft_pathops::boolean(&[path, ring], BoolOp::Union) - } else { - figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract) - }; - let Ok(mut res) = res else { continue }; - let bb = res.bounding_box(); - res.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let nm = doc.get_mut(i)?; - let mut net = VectorNetwork::from_path(&res); - for r in &mut net.regions { - r.winding = WindingRule::EvenOdd; - } - nm.kind = NodeKind::Vector { network: net }; - nm.transform *= Affine::translate((bb.x0, bb.y0)); - nm.width = bb.width(); - nm.height = bb.height(); - } - ok() -} - -/// Join open vectors whose end points touch into one vector. -fn join(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("vector.join", s, p)?; - if ids.len() < 2 { - // Close a single open path. - if let Some(i) = ids.first() { - let n = s.doc_mut()?.get_mut(*i)?; - if let NodeKind::Vector { network } = &mut n.kind - && network.regions.is_empty() - && network.vertices.len() > 2 - { - let last = network.vertices.len() - 1; - network.segments.push(Segment::line(last, 0)); - let all: Vec = (0..network.segments.len()).collect(); - network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero }); - } - } - return ok(); - } - flatten(s, &json!({"ids": ids})) -} - -/// Fit containers to their visible children's bounds, keeping the children where they are on the -/// page (auto layout frames hug instead); rotated containers fit in their own axes. -fn resize_to_fit(s: &mut Session, p: &Value) -> Result { - let cmd = "object.resizeToFit"; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let mut fitted = Vec::new(); - for id in ids { - let Some(n) = doc.node(id).cloned() else { continue }; - let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }); - if !container || n.children.is_empty() || matches!(n.kind, NodeKind::Instance { .. }) { - continue; - } - // Auto layout frames fit by hugging their contents. - if n.layout.is_some() { - let node = doc.get_mut(id)?; - node.child_layout.sizing_h = Sizing::Hug; - node.child_layout.sizing_v = Sizing::Hug; - fitted.push(id); - continue; - } - // Children's bounds in the container's own coordinates. - let mut b: Option = None; - for c in n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible) { - let r = figcraft_geom::transform_rect_bbox(c.transform, c.local_rect()); - if r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite() { - b = Some(b.map_or(r, |u| u.union(r))); - } - } - let Some(b) = b else { continue }; - let (w, h) = (b.width().max(0.01), b.height().max(0.01)); - // Move the container by the offset, and its children back by it. - let shift = Affine::translate((b.x0, b.y0)); - let node = doc.get_mut(id)?; - node.transform *= shift; - node.width = w; - node.height = h; - for c in &n.children { - if let Ok(cn) = doc.get_mut(*c) { - cn.transform = shift.inverse() * cn.transform; - } - } - fitted.push(id); - } - Ok(json!({"ids": fitted})) -} +//! Structure: groups, frames, masks, boolean groups, flatten, outline stroke, auto layout, pages. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::*; +use figcraft_geom::{Affine, BezPath, Rect, Shape}; +use serde_json::{Value, json}; + +use super::edit::{roots, z_sorted}; +use super::{CommandSpec, ok}; +use crate::params::*; +use crate::{EngineError, Result, Session, bad, spec}; + +pub fn specs() -> Vec { + vec![ + spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)), + spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup), + spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)), + spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup), + spec!( + "object.convertToGroup", + "Convert to group", + "{ids?} (frames become groups in place, their layers staying where they are; what only a frame has goes: auto layout, fills, strokes, corner radius, clipping, layout grids, guides and scrolling. Groups are left alone; instances, main components, layers inside instances and empty frames are refused)", + true, + |s, p| convert(s, p, "object.convertToGroup", false) + ), + spec!( + "object.convertToFrame", + "Convert to frame", + "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)", + true, + |s, p| convert(s, p, "object.convertToFrame", true) + ), + spec!( + "object.wrapInSection", + "Wrap in new section", + "{ids?, name?} (layers on the page or in a section; the section wraps them with some room)", + true, + |s, p| wrap(s, p, Wrap::Section) + ), + spec!( + "object.resizeToFit", + "Resize to fit", + "{ids?} (frames, sections and components shrink or grow to their contents; the contents stay put)", + true, + resize_to_fit + ), + spec!("object.mask", "Use as mask", "{ids?}", true, mask), + spec!("object.releaseMask", "Release mask", "{ids?}", true, |s, p| { + for i in req_targets("object.releaseMask", s, p)? { + s.doc_mut()?.get_mut(i)?.is_mask = false; + } + ok() + }), + spec!("bool.union", "Union selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.union", p, BoolOp::Union)), + spec!("bool.subtract", "Subtract selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.subtract", p, BoolOp::Subtract)), + spec!("bool.intersect", "Intersect selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.intersect", p, BoolOp::Intersect)), + spec!("bool.exclude", "Exclude selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.exclude", p, BoolOp::Exclude)), + spec!("bool.flatten", "Flatten boolean", "{ids?}", true, flatten), + spec!("vector.flatten", "Flatten", "{ids?}", true, flatten), + spec!("vector.outlineStroke", "Outline stroke", "{ids?}", true, outline_stroke), + spec!("layout.add", "Add auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add), + spec!("layout.remove", "Remove auto layout", "{ids?}", true, |s, p| { + for i in req_targets("layout.remove", s, p)? { + // Hugging needs auto layout and the children that filled have nothing to fill: both + // become fixed (the frame keeps filling its own auto layout parent). + let doc = s.doc_mut()?; + doc.get_mut(i)?.layout = None; + figcraft_layout::coherent_sizing(doc, i); + } + ok() + }), + spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add), + spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page), + spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| { + let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?; + let doc = s.doc_mut()?; + for n in doc.ids().collect::>() { + if let Some(node) = doc.node(n) + && node.plugin_data.contains_key("figcraft.thumbnail") + { + doc.get_mut(n)?.plugin_data.remove("figcraft.thumbnail"); + } + } + doc.get_mut(id)?.plugin_data.insert("figcraft.thumbnail".into(), "true".into()); + ok() + }), + spec!("object.rasterize", "Rasterize selection", "{ids?, scale? (default 2)}", true, rasterize), + spec!("vector.reverse", "Reverse path direction", "{ids?}", true, |s, p| { + for i in req_targets("vector.reverse", s, p)? { + if let NodeKind::Vector { network } = &mut s.doc_mut()?.get_mut(i)?.kind { + for seg in &mut network.segments { + std::mem::swap(&mut seg.a, &mut seg.b); + std::mem::swap(&mut seg.ta, &mut seg.tb); + } + } + } + ok() + }), + spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify), + spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path), + spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| { + crate::vectoredit::join_in_edit_mode(s, false).unwrap_or_else(|| join(s, p)) + }), + spec!("vector.smoothJoin", "Smooth join selection", "{ids?} (in vector edit mode: joins the selected points smoothly)", true, |s, p| { + crate::vectoredit::join_in_edit_mode(s, true).unwrap_or_else(|| join(s, p)) + }), + ] +} + +enum Wrap { + Group, + Frame, + Section, + Bool(BoolOp), +} + +/// Room a section made around layers leaves on each side. +const SECTION_PAD: f64 = 40.0; + +/// What the boolean commands do with what they're given. +const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group"; + +/// Wrap the selection in a new boolean group doing `op`. +pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result { + wrap(s, p, Wrap::Bool(op)) +} + +/// Wrap the selection in a new container at the topmost selected layer's place. +fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result { + let ids = req_targets("object.group", s, p)?; + let doc = s.doc_mut()?; + let ids = z_sorted(doc, &roots(doc, &ids)); + let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?; + let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?; + // Sections sit on the page or in sections, never in frames or groups. + let board = |doc: &Document, i: NodeId| { + doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) + }; + if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) { + return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section")); + } + if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) { + return Err(bad("object.group", "sections can't go in groups or frames")); + } + let index = doc.index_in_parent(top).map(|i| i + 1); + let mut bounds = doc.bounds_of(&ids).unwrap_or_default(); + if matches!(w, Wrap::Section) { + bounds = bounds.inflate(SECTION_PAD, SECTION_PAD); + } + let pw = doc.world_transform(parent); + let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds); + let (kind, base) = match &w { + Wrap::Group => (NodeKind::Group, "Group"), + Wrap::Frame => (NodeKind::Frame, "Frame"), + Wrap::Section => (NodeKind::Section { contents_hidden: false }, "Section"), + Wrap::Bool(op) => (NodeKind::BooleanOp { op: *op }, op.label()), + }; + let name = string(p, "name").unwrap_or_else(|| if matches!(w, Wrap::Bool(_)) { base.to_string() } else { doc.next_name(base) }); + let gid = doc.alloc_id(); + let mut g = match w { + Wrap::Section => Node::new_section(gid, name, local.width(), local.height()), + _ => Node::new(gid, kind, name, local.width(), local.height()), + }; + g.transform = Affine::translate((local.x0, local.y0)); + if let Wrap::Bool(op) = w { + // A boolean group looks like its top layer; a subtraction like the bottom one it cuts. + let source = if op == BoolOp::Subtract { ids.first() } else { ids.last() }; + if let Some(first) = source.and_then(|i| doc.node(*i)) { + g.fills = if first.fills.is_empty() { vec![Paint::solid(Color::DEFAULT_SHAPE)] } else { first.fills.clone() }; + g.strokes = first.strokes.clone(); + g.stroke = first.stroke.clone(); + g.effects = first.effects.clone(); + } + g.blend = figcraft_color::BlendMode::Normal; + } + if let Wrap::Frame = w { + g.clip = true; + } + // Insert the container where the topmost layer is. + doc.insert(g, parent, index)?; + for i in &ids { + doc.reparent(*i, gid, None)?; + } + if !matches!(w, Wrap::Frame | Wrap::Section) { + let _ = doc.fit_to_children(gid); + } + s.set_selection(vec![gid]); + Ok(json!({"id": gid})) +} + +/// Turn frames into groups or groups into frames in place (same id, name, layers, effects, +/// opacity, exports and interactions), the layers inside staying where they are on the page. +/// Nothing changes unless every layer can convert. +fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Result { + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut todo = Vec::new(); + for i in ids { + let n = doc.get(i)?; + let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); + match (&n.kind, to_frame) { + (NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"), + (NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"), + (NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => { + return refuse("is inside an instance: edit its main component, or detach the instance"); + } + (NodeKind::Frame, false) if n.children.is_empty() => return refuse("is empty: a group needs layers"), + (NodeKind::Frame, false) | (NodeKind::Group, true) => todo.push(i), + (NodeKind::Frame | NodeKind::Group, _) => {} + _ => return refuse("is neither a frame nor a group"), + } + } + for i in &todo { + if to_frame { + // The group's box as it is; nothing painted or clipped, so the look stays. + let n = doc.get_mut(*i)?; + n.kind = NodeKind::Frame; + n.clip = false; + n.fills.clear(); + n.strokes.clear(); + continue; + } + let n = doc.get_mut(*i)?; + n.kind = NodeKind::Group; + (n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None); + n.fills.clear(); + n.strokes.clear(); + n.grids.clear(); + n.guides.clear(); + n.corners = Corners::default(); + (n.styles.fill, n.styles.stroke, n.styles.grid) = (None, None, None); + // Variables bound to what went would bring it back. + n.bound.retain(|k, _| matches!(k.as_str(), "opacity" | "visible")); + let kids = n.children.clone(); + for k in kids { + // Out of the flow there is nothing to fill or to be absolute against. + doc.get_mut(k)?.child_layout.absolute = false; + } + figcraft_layout::coherent_sizing(doc, *i); + doc.fit_to_children(*i)?; + } + Ok(json!({"ids": todo})) +} + +fn ungroup(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("object.ungroup", s, p)?; + let doc = s.doc_mut()?; + let mut out = Vec::new(); + for g in ids { + let n = doc.get(g)?; + if !(matches!(n.kind, NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Frame | NodeKind::Section { .. })) { + continue; + } + let Some(parent) = n.parent else { continue }; + let idx = doc.index_in_parent(g).unwrap_or(0); + let kids = n.children.clone(); + // Ungrouping a group keeps its opacity on the children (multiplied). + let op = n.opacity; + for (k, c) in kids.iter().enumerate() { + doc.reparent(*c, parent, Some(idx + k))?; + // Released operands no longer combine with anything. + if doc.get(*c)?.bool_op.is_some() { + doc.get_mut(*c)?.bool_op = None; + } + if op < 1.0 { + let cn = doc.get_mut(*c)?; + cn.opacity *= op; + } + out.push(*c); + } + doc.remove(g)?; + } + s.set_selection(out.clone()); + Ok(json!({"ids": out})) +} + +fn mask(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("object.mask", s, p)?; + if ids.len() == 1 { + let doc = s.doc_mut()?; + let id = ids.first().copied().unwrap_or(NodeId(0)); + let n = doc.get_mut(id)?; + n.is_mask = !n.is_mask; + return ok(); + } + // Group, then the bottom-most child is the mask. + let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?; + let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?; + let doc = s.doc_mut()?; + if let Some(first) = doc.get(gid)?.children.first().copied() { + doc.get_mut(first)?.is_mask = true; + } + Ok(r) +} + +/// The outline of a node in its parent's space (shapes, text, booleans, groups). +pub fn outline_in_parent(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + let (mut p, _) = figcraft_render::subtree_outline(doc, n, 0)?; + p.apply_affine(n.transform); + Some(p) +} + +fn flatten(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("vector.flatten", s, p)?; + let doc = s.doc_mut()?; + let ids = z_sorted(doc, &roots(doc, &ids)); + let top = *ids.last().ok_or_else(|| bad("vector.flatten", "nothing selected"))?; + let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?; + let index = doc.index_in_parent(top).map(|i| i + 1); + let pw = doc.world_transform(parent); + let mut path = BezPath::new(); + let mut even = false; + for i in &ids { + let Some(n) = doc.node(*i) else { continue }; + // Booleans flatten to their result; strokes don't get outlined. + let Some((mut op, rule)) = (match &n.kind { + NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0), + _ => figcraft_render::node_outline(doc, n), + }) else { + continue; + }; + even |= rule == figcraft_render::FillRule::EvenOdd; + // Into the common parent's space. + let w = doc.world_transform(*i); + op.apply_affine(pw.inverse() * w); + path.extend(op.elements().iter().copied()); + } + if path.elements().is_empty() { + return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into())); + } + let first = ids.first().and_then(|i| doc.node(*i)).cloned(); + let bb = path.bounding_box(); + path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); + let mut net = VectorNetwork::from_path(&path); + for r in &mut net.regions { + r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero }; + } + let vid = doc.alloc_id(); + let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into()); + let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height()); + v.transform = Affine::translate((bb.x0, bb.y0)); + if let Some(f) = first { + v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() { + vec![Paint::solid(Color::DEFAULT_SHAPE)] + } else { + f.fills.clone() + }; + v.strokes = f.strokes.clone(); + v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() }; + v.effects = f.effects.clone(); + v.opacity = f.opacity; + v.blend = if matches!(f.blend, figcraft_color::BlendMode::PassThrough) { figcraft_color::BlendMode::Normal } else { f.blend }; + } + doc.insert(v, parent, index)?; + for i in &ids { + doc.remove(*i)?; + } + s.set_selection(vec![vid]); + Ok(json!({"id": vid})) +} + +fn outline_stroke(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("vector.outlineStroke", s, p)?; + let mut out = Vec::new(); + for i in ids { + let doc = s.doc_mut()?; + let n = doc.get(i)?.clone(); + if !n.has_visible_stroke() { + continue; + } + let closed = figcraft_doc::geometry::is_closed(&n); + let Some((area, rule)) = (match &n.kind { + NodeKind::Text(_) => figcraft_render::node_outline(doc, &n) + .map(|(p, _)| (figcraft_render::draw::stroke_area(&n, &p, closed), figcraft_render::FillRule::Winding)), + _ => figcraft_render::stroke_fill(doc, &n), + }) else { + continue; + }; + let mut path = (*area).clone(); + // Simplify non-zero strokes into even-odd contours. Inside/outside rings already use + // even-odd: interpreting their same-direction contours as non-zero would fill the hole. + if rule == figcraft_render::FillRule::Winding + && let Ok(u) = figcraft_pathops::simplify(&path) + { + path = u; + } + let bb = path.bounding_box(); + path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); + let mut net = VectorNetwork::from_path(&path); + for r in &mut net.regions { + r.winding = WindingRule::EvenOdd; + } + let parent = n.parent.ok_or_else(|| bad("vector.outlineStroke", "page"))?; + let idx = doc.index_in_parent(i); + let vid = doc.alloc_id(); + let mut v = Node::new(vid, NodeKind::Vector { network: net }, n.name.clone(), bb.width(), bb.height()); + v.transform = n.transform * Affine::translate((bb.x0, bb.y0)); + // The stroke's paints (and its colour style) fill the outline. + v.fills = n.strokes.clone(); + v.strokes.clear(); + v.styles.fill = n.styles.stroke; + if n.fills.iter().any(|f| f.visible) && closed { + // Keep the fill as its own layer under the outline, grouped. + doc.insert(v, parent, idx.map(|x| x + 1))?; + let fill_id = i; + { + let f = doc.get_mut(fill_id)?; + f.strokes.clear(); + } + let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?; + if let Some(g) = id_of(&r["id"]) { + out.push(g); + } + continue; + } + // Standing in for the layer, the outline keeps how it is drawn and placed. + (v.opacity, v.blend, v.effects, v.styles.effect) = (n.opacity, n.blend, n.effects.clone(), n.styles.effect); + (v.constraints, v.child_layout, v.visible, v.locked) = (n.constraints, n.child_layout.clone(), n.visible, n.locked); + doc.insert(v, parent, idx.map(|x| x + 1))?; + doc.remove(i)?; + out.push(vid); + } + s.set_selection(out.clone()); + Ok(json!({"ids": out})) +} + +/// Infer direction, gap and padding from where children are (Figma's "Add auto layout"). +fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout { + let Some(f) = doc.node(frame) else { return AutoLayout::default() }; + let boxes: Vec = + f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect(); + if boxes.is_empty() { + return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() }; + } + let span_x = boxes.iter().map(|b| b.x1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.x0).fold(f64::MAX, f64::min); + let span_y = boxes.iter().map(|b| b.y1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.y0).fold(f64::MAX, f64::min); + let vertical = span_y > span_x; + let mut sorted = boxes.clone(); + sorted.sort_by(|a, b| if vertical { a.y0.total_cmp(&b.y0) } else { a.x0.total_cmp(&b.x0) }); + let gaps: Vec = + sorted.windows(2).filter_map(|w| Some(if vertical { w.get(1)?.y0 - w.first()?.y1 } else { w.get(1)?.x0 - w.first()?.x1 })).collect(); + let gap = if gaps.is_empty() { 10.0 } else { (gaps.iter().sum::() / gaps.len() as f64).max(0.0).round() }; + let all = boxes.iter().copied().reduce(|a, b| a.union(b)).unwrap_or_default(); + let pad = [all.y0.max(0.0), (f.width - all.x1).max(0.0), (f.height - all.y1).max(0.0), all.x0.max(0.0)].map(f64::round); + AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() } +} + +fn layout_add(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("layout.add", s, p)?; + let dir = string(p, "direction"); + let doc = s.doc()?; + let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())); + let targets = if all_frames { + ids + } else { + // Wrap non-frames in a new frame without a fill, then add auto layout to it. + let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?; + let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?; + let f = s.doc_mut()?.get_mut(fid)?; + f.fills.clear(); + f.clip = false; + vec![fid] + }; + let doc = s.doc_mut()?; + for f in &targets { + let mut al = infer_layout(doc, *f); + if let Some(d) = &dir { + al.mode = match d.to_ascii_lowercase().as_str() { + "vertical" | "column" => LayoutMode::Vertical, + "grid" => LayoutMode::Grid, + _ => LayoutMode::Horizontal, + }; + } + // Children in reading order along the axis. + let mut kids = doc.get(*f)?.children.clone(); + let vertical = al.mode == LayoutMode::Vertical; + kids.sort_by(|a, b| { + let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); + let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); + if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) } + }); + let n = doc.get_mut(*f)?; + n.children = kids; + n.layout = Some(al); + n.child_layout.sizing_h = Sizing::Hug; + n.child_layout.sizing_v = Sizing::Hug; + } + s.set_selection(targets.clone()); + Ok(json!({"ids": targets})) +} + +fn move_to_page(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("object.moveToPage", s, p)?; + let page = req_id("object.moveToPage", p, "page")?; + let doc = s.doc_mut()?; + if !doc.node(page).is_some_and(Node::is_page) { + return Err(bad("object.moveToPage", format!("{page} is not a page"))); + } + if doc.is_divider(page) { + return Err(bad("object.moveToPage", "a divider holds no layers")); + } + for i in z_sorted(doc, &roots(doc, &ids)) { + doc.reparent(i, page, None)?; + } + s.set_selection(vec![]); + ok() +} + +fn rasterize(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("object.rasterize", s, p)?; + let scale = num_or(p, "scale", 2.0); + let mut out = Vec::new(); + for i in ids { + let doc = s.doc()?; + let Some(pix) = figcraft_render::render_node(doc, i, scale) else { continue }; + let png = figcraft_render::encode_png(&pix).map_err(EngineError::Other)?; + let b = figcraft_render::export_bounds(doc, i).unwrap_or_default(); + let parent = doc.get(i)?.parent.ok_or_else(|| bad("object.rasterize", "page"))?; + let idx = doc.index_in_parent(i); + let pw = doc.world_transform(parent); + let name = doc.get(i)?.name.clone(); + let docm = s.doc_mut()?; + let (hash, _, _) = figcraft_format::add_image(docm, png).map_err(|e| EngineError::Other(e.to_string()))?; + let rid = docm.alloc_id(); + let mut r = Node::new(rid, NodeKind::Rectangle, name, b.width(), b.height()); + r.transform = pw.inverse() * Affine::translate((b.x0, b.y0)); + r.fills = vec![Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; + docm.insert(r, parent, idx.map(|x| x + 1))?; + docm.remove(i)?; + out.push(rid); + } + s.set_selection(out.clone()); + Ok(json!({"ids": out})) +} + +fn simplify(s: &mut Session, p: &Value) -> Result { + let tol = num_or(p, "tolerance", 0.5).max(0.01); + for i in req_targets("vector.simplify", s, p)? { + let doc = s.doc_mut()?; + let n = doc.get_mut(i)?; + if let NodeKind::Vector { network } = &mut n.kind { + let fill = network.fill_path(); + let src = if fill.elements().is_empty() { network.stroke_path() } else { fill }; + let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default()); + let regions = !network.regions.is_empty(); + let mut net = VectorNetwork::from_path(&simplified); + if !regions { + net.regions.clear(); + } + *network = net; + } + } + ok() +} + +fn offset_path(s: &mut Session, p: &Value) -> Result { + let d = req_num("vector.offset", p, "offset")?; + for i in req_targets("vector.offset", s, p)? { + let doc = s.doc_mut()?; + let n = doc.get(i)?.clone(); + let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue }; + let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() }; + let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props); + let res = if d >= 0.0 { + figcraft_pathops::boolean(&[path, ring], BoolOp::Union) + } else { + figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract) + }; + let Ok(mut res) = res else { continue }; + let bb = res.bounding_box(); + res.apply_affine(Affine::translate((-bb.x0, -bb.y0))); + let nm = doc.get_mut(i)?; + let mut net = VectorNetwork::from_path(&res); + for r in &mut net.regions { + r.winding = WindingRule::EvenOdd; + } + nm.kind = NodeKind::Vector { network: net }; + nm.transform *= Affine::translate((bb.x0, bb.y0)); + nm.width = bb.width(); + nm.height = bb.height(); + } + ok() +} + +/// Join open vectors whose end points touch into one vector. +fn join(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("vector.join", s, p)?; + if ids.len() < 2 { + // Close a single open path. + if let Some(i) = ids.first() { + let n = s.doc_mut()?.get_mut(*i)?; + if let NodeKind::Vector { network } = &mut n.kind + && network.regions.is_empty() + && network.vertices.len() > 2 + { + let last = network.vertices.len() - 1; + network.segments.push(Segment::line(last, 0)); + let all: Vec = (0..network.segments.len()).collect(); + network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero }); + } + } + return ok(); + } + flatten(s, &json!({"ids": ids})) +} + +/// Fit containers to their visible children's bounds, keeping the children where they are on the +/// page (auto layout frames hug instead); rotated containers fit in their own axes. +fn resize_to_fit(s: &mut Session, p: &Value) -> Result { + let cmd = "object.resizeToFit"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut fitted = Vec::new(); + for id in ids { + let Some(n) = doc.node(id).cloned() else { continue }; + let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }); + if !container || n.children.is_empty() || matches!(n.kind, NodeKind::Instance { .. }) { + continue; + } + // Auto layout frames fit by hugging their contents. + if n.layout.is_some() { + let node = doc.get_mut(id)?; + node.child_layout.sizing_h = Sizing::Hug; + node.child_layout.sizing_v = Sizing::Hug; + fitted.push(id); + continue; + } + // Children's bounds in the container's own coordinates. + let mut b: Option = None; + for c in n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible) { + let r = figcraft_geom::transform_rect_bbox(c.transform, c.local_rect()); + if r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite() { + b = Some(b.map_or(r, |u| u.union(r))); + } + } + let Some(b) = b else { continue }; + let (w, h) = (b.width().max(0.01), b.height().max(0.01)); + // Move the container by the offset, and its children back by it. + let shift = Affine::translate((b.x0, b.y0)); + let node = doc.get_mut(id)?; + node.transform *= shift; + node.width = w; + node.height = h; + for c in &n.children { + if let Ok(cn) = doc.get_mut(*c) { + cn.transform = shift.inverse() * cn.transform; + } + } + fitted.push(id); + } + Ok(json!({"ids": fitted})) +} diff --git a/crates/engine/tests/outline_stroke.rs b/crates/engine/tests/outline_stroke.rs new file mode 100644 index 00000000..49e8a8b2 --- /dev/null +++ b/crates/engine/tests/outline_stroke.rs @@ -0,0 +1,174 @@ +//! Outline stroke keeps the renderer's holes and existing property ownership. +#[cfg(test)] +mod tests { + use figcraft_doc::{Document, NodeId, NodeKind}; + use figcraft_engine::Session; + use figcraft_render::{Options, View, render_view}; + use serde_json::{Value, json}; + + fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap() + } + + fn rectangle(s: &mut Session, opacity: f64) -> NodeId { + NodeId( + run( + s, + "shape.rectangle", + json!({ + "x": 24, "y": 24, "width": 64, "height": 64, + "props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8, "opacity": opacity} + }), + )["id"] + .as_u64() + .unwrap(), + ) + } + + fn pixels(doc: &Document) -> Vec { + render_view(doc, doc.first_page().unwrap(), &View { origin: (0.0, 0.0), scale: 1.0, width: 112, height: 112 }, &Options::default()) + .unwrap() + .data() + .to_vec() + } + + fn outlined(s: &mut Session, id: NodeId) -> NodeId { + NodeId(run(s, "vector.outlineStroke", json!({"ids": [id]}))["ids"][0].as_u64().unwrap()) + } + + fn same_pixels(actual: &[u8], expected: &[u8]) { + assert_eq!(actual.len(), expected.len()); + let differences: Vec<_> = actual.iter().zip(expected).enumerate().filter(|(_, (a, b))| a != b).take(8).collect(); + assert!(differences.is_empty(), "rendered channels changed (byte, actual/expected): {differences:?}"); + } + + #[test] + fn outline_keeps_rings_through_undo_and_native_reopen() { + for vector in [false, true] { + let mut s = Session::new(); + let mut id = rectangle(&mut s, 1.0); + if vector { + id = NodeId(run(&mut s, "vector.flatten", json!({"ids": [id]}))["id"].as_u64().unwrap()); + } + let before = s.doc().unwrap().clone(); + let expected = pixels(&before); + let depth = s.undo_depth(); + let group = outlined(&mut s, id); + let after = s.doc().unwrap().clone(); + same_pixels(&pixels(&after), &expected); + assert!(after.node(id).unwrap().strokes.is_empty()); + assert_eq!(s.selection(), vec![group]); + assert_eq!(s.undo_depth(), (depth.0 + 1, 0)); + let (loaded, notes) = figcraft_format::load_bytes(&figcraft_format::save_bytes(&after, None).unwrap()).unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + same_pixels(&pixels(&loaded), &expected); + assert!(s.undo().unwrap()); + assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&before).unwrap()); + assert!(s.redo().unwrap()); + assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&after).unwrap()); + } + } + + #[test] + fn stroke_only_keeps_holes_at_each_opacity() { + for opacity in [0.0, 0.25, 0.5, 1.0] { + let mut s = Session::new(); + let id = rectangle(&mut s, opacity); + run(&mut s, "object.removeFill", json!({"ids": [id]})); + let before = pixels(s.doc().unwrap()); + let vector = outlined(&mut s, id); + let doc = s.doc().unwrap(); + assert!(matches!(doc.node(vector).unwrap().kind, NodeKind::Vector { .. })); + assert_eq!(doc.node(vector).unwrap().opacity, opacity as f32); + same_pixels(&pixels(doc), &before); + } + } + + #[test] + fn outline_keeps_stroke_holes_and_alignment() { + for props in [ + json!({"strokeAlign": "inside"}), + json!({"strokeAlign": "outside"}), + json!({"strokeAlign": "center"}), + json!({"strokeSides": [2, 4, 8, 16]}), + json!({"strokeAlign": "center", "dashes": [8, 4]}), + ] { + for filled in [false, true] { + let mut s = Session::new(); + let id = rectangle(&mut s, 1.0); + run(&mut s, "node.set", json!({"ids": [id], "props": props})); + if !filled { + run(&mut s, "object.removeFill", json!({"ids": [id]})); + } + let before = pixels(s.doc().unwrap()); + outlined(&mut s, id); + same_pixels(&pixels(s.doc().unwrap()), &before); + } + } + } + + #[test] + fn outlining_an_instance_keeps_its_inherited_opacity_link() { + let mut s = Session::new(); + let component = NodeId( + run( + &mut s, + "frame.create", + json!({ + "width": 64, "height": 64, + "props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8, "opacity": 0.5} + }), + )["id"] + .as_u64() + .unwrap(), + ); + run(&mut s, "component.create", json!({"ids": [component]})); + let instance = NodeId(run(&mut s, "instance.create", json!({"component": component, "x": 200}))["id"].as_u64().unwrap()); + outlined(&mut s, instance); + assert_eq!(s.doc().unwrap().node(instance).unwrap().opacity, 0.5); + assert!(!s.doc().unwrap().node(instance).unwrap().overrides.contains("opacity")); + run(&mut s, "node.set", json!({"id": component, "props": {"opacity": 0.25}})); + let n = s.doc().unwrap().node(instance).unwrap(); + assert_eq!(n.opacity, 0.25, "the instance still follows its main"); + assert!(!n.overrides.contains("opacity")); + } + + #[test] + fn outlining_a_main_layer_keeps_its_instance_override() { + let mut s = Session::new(); + let frame = NodeId(run(&mut s, "frame.create", json!({"width": 100, "height": 100}))["id"].as_u64().unwrap()); + let layer = NodeId( + run( + &mut s, + "shape.rectangle", + json!({ + "parent": frame, "x": 24, "y": 24, "width": 64, "height": 64, + "props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8} + }), + )["id"] + .as_u64() + .unwrap(), + ); + let component = run(&mut s, "component.create", json!({"ids": [frame]}))["id"].clone(); + run(&mut s, "instance.create", json!({"component": component, "x": 200})); + let copy = s.doc().unwrap().nodes().find(|n| n.source == Some(layer)).unwrap().id; + run(&mut s, "node.set", json!({"id": copy, "props": {"opacity": 0.25}})); + let before = s.doc().unwrap().clone(); + let view = View { origin: (0.0, 0.0), scale: 1.0, width: 312, height: 112 }; + let expected = render_view(&before, s.page().unwrap(), &view, &Options::default()).unwrap(); + let group = outlined(&mut s, layer); + let doc = s.doc().unwrap(); + let actual = render_view(doc, s.page().unwrap(), &view, &Options::default()).unwrap(); + same_pixels(actual.data(), expected.data()); + let instance_group = doc.node(copy).unwrap(); + assert!(matches!(instance_group.kind, NodeKind::Group)); + assert_eq!(instance_group.source, Some(group)); + assert_eq!(instance_group.opacity, 0.25); + assert!(instance_group.overrides.contains("opacity")); + assert!(s.undo().unwrap()); + assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&before).unwrap()); + assert!(s.redo().unwrap()); + let actual = render_view(s.doc().unwrap(), s.page().unwrap(), &view, &Options::default()).unwrap(); + same_pixels(actual.data(), expected.data()); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 07192dfd..96620b05 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,445 +1,448 @@ -# Where FigCraft falls short of Figma - -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) - -Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from -doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The -numbers behind the ranking are in [`target-app-parity.md`](target-app-parity.md). Hours are one -Opus 5.5 agent working sequentially, tests included, calibrated as described there. "Estimated" -everywhere unless marked measured. - -## Ranked - -No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passes. Beta blockers are marked. - -| # | Gap | Kind | Hours | Doc | -|---|---|---|---|---| -| 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed, and an experimental export (round trips through our own importer; written files opened in other software, where layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, instances keep their size and variables with modes and colour styles come across); real-file checks and the small tail left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) | -| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | -| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | -| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | -| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts | Localization | 29–51 | [localization-parity](localization-parity.md) | -| 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | -| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | -| 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | -| 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | -| 10 | Team libraries: no team-wide lists or accounts; analytics are counts | Feature | 4–8 | [components-parity](components-parity.md) | -| 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) | -| 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) | -| 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) | -| 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | -| 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | -| 16 | Dev Mode: import lines inside generated layer trees; the editor extension is left to the MCP server (code connect, Dev Mode plugins and dev resources landed) | Feature | 0–1 | [dev-mode-parity](dev-mode-parity.md) | -| 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | -| 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | -| 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | -| 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) | -| 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) | -| 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) | -| 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) | - -## Entries - -### 1. `.fig` files: the import's tail -- **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`, - `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved - over. -- **Landed (2026-10-12):** an experimental export: `file.export {format: "fig"}`, File ▸ Export… ▸ - .fig (experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document - with a kiwi encoder mirroring the decoder and a schema of the fields written: the layer tree, - geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto - layout, constraints, components, sets, instances with their overrides and property values, and - prototype interactions; shared style links, variables, comments, Dev Mode data and the rest are - left out with a warning ([file-format-parity](file-format-parity.md#export-to-fig-experimental)). - The import gained ellipse arcs, boolean operation types and component set descriptions. Then - (2026-10-12): written files were checked by opening them in other software: layers, fills, - gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after the export - learned to say that the document and its pages are visible and opaque, to number its schema as - the format does, to write outlines, text settings, standard font style names and line height - units. Then (2026-10-13): instances keep their own size (an override addressed by the main's id, - derived data for their layers, a scale factor), variables with modes and bindings, shared styles - and variable-width strokes are written and read back; checked there: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there), - like effects such as glass and noise, images and prototype interactions. -- **Missing:** connector labels, elbowed or curved connectors without a saved path (drawn straight), - table cells beyond their own layers, filled areas of a vector with their own fills, per-point stroke - joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks and video - fills aren't read; text lays out again with - local fonts. Nothing has been checked against real-world files yet (gap 3). The export is checked - only against our own importer: whether other apps open its files is unknown. -- **Evidence:** fixtures generated in code (`crates/format/src/fig/tests.rs` and `tail_tests.rs`: kiwi - round trips, every mapped feature, vector network blobs, truncation, bit flips and junk never panic); `crates/engine/src/fig_import_tests.rs`. - Export: 60 generated documents round trip through the importer layer by layer, written files - decode with the generic decoder and list parents first, the encoder agrees with the decoder, - junk documents never panic (`crates/format/src/fig/export_tests.rs`, - `crates/engine/src/fig_export_tests.rs`). -- **Impact:** existing work can be brought across, but only real files will show how much of it. -- **Estimate:** 5–10 h for the read tail (real-file fixes, connector labels, table cells); checking the export against real files a further 5–15 h (with gap 3). - -### 2. Multiplayer and sharing are a preview, without accounts -- **Missing:** accounts, teams and projects, per-person permissions, audio, - shared comments and version history. Comments and versions are local to the file. -- **Evidence:** the core landed in #103 (`crates/collab`: client-scoped ids, change sets, server - ordering and validation, client reconciliation, binary messages, randomized convergence tests over - real commands), and live sessions run over the network (`crates/collab-net`: hosting from the - app, invite links that open the app, document sync between apps, live cursors, selections, - text carets, avatars and follow, cursor chat and spotlight, and the browser build joins) with per-person undo, refused - edits rolled back with a reason, reconnects that resume and send what was made offline, and - styles, variables and comments that several people create at once all kept; sessions are per - document (each open file has its own or none, several at once, closing a file ends only its - own); files are shared with a short join code (online through the server in Preferences, or on - the local network with a code that carries the host's address) and joined by typing it; the app - remembers the files it put online and lists them on the home screen (open again as owner, stop - and start sharing, rename, remove, forget), per computer; a standalone - `figcraft-server` keeps many files (snapshot plus log, compacted; restarts lose nothing - confirmed), takes files published from the app and hands out share links that let people edit - or only view; the desktop app and the browser reach it over `wss://` through a TLS reverse - proxy, and releases ship it with a deployment guide (`docs/server.md`). But there are no - accounts (a link is the access) (`docs/collab-protocol.md`). -- **Impact:** Figma's defining feature; most teams use it every day. Solo designers can work - without it. -- **Estimate:** 42–84 h: accounts, teams and per-person permissions, shared comments and versions - (numbers from before the server landed; re-measured next). - -### 3. Nothing checked against real Figma files -- **Missing:** a corpus of real documents (design systems, app screens, prototypes) imported and - compared with Figma's own PNG/SVG/PDF exports. -- **Evidence:** every corpus in the repo is synthetic (`crates/svg/tests/corpus`, the synthetic - design system in `crates/engine/src/synthetic_ds.rs`). Scores were self-graded by the agents that - built the features. -- **Impact:** unknown fidelity in auto layout sizing, text metrics, effects and component - resolution on real files; the numbers in this repo can't be trusted beyond "estimated" until it - exists. -- **Measured so far:** text box sizes and auto layout results of our own example documents, - against the same documents re-saved by other software (which lays text out again). Three text - rules explained every difference and are applied (whole-pixel line heights, advances on a - 1024-per-em grid, letter spacing between glyphs only): all 230 self-sizing text boxes in 7 - documents now come out to the pixel (24 of 131 before, in the design-system example), and 208 of - its 231 other layers match in size and position (115 before). The other 23 hang on one grid - auto layout frame whose row positions came back as written while its cells got shorter. One font - family (Inter), left-aligned text; real files, other fonts, effects and component resolution are - still unmeasured (`crates/text/src/metrics_tests.rs`, and a local check over such documents in - `crates/engine/src/text_metrics_local_tests.rs`). -- **Estimate:** 15–30 h once `.fig` or REST import of real files is available; needs files whose - licence allows use (owner to supply or approve; never committed if not redistributable, see - craftrules `standards/test-corpora.md`). - -### 4. Interaction fidelity not compared tool by tool -- **Missing:** a side-by-side pass of every tool and panel: cursors, modifier keys while dragging, - hover states, popovers, menus, snapping thresholds. -- **Landed (2026-10-11):** behaviour suites written from the public documentation for auto layout, - selection and transform, text, and canvas navigation with rulers, guides, layout grids, outline - mode and measuring, then (2026-10-12) comments and version history, and the layers and Assets - panels (`crates/engine/src/*_behaviour_tests.rs`, `crates/ui-egui/src/layers_behaviour_tests.rs`), - each with its fixes; then keyboard shortcuts per platform (`shortcut_parity_tests.rs`: 129 - documented rows, 126 bound, 1 moved off a documented conflict, 2 missing, 48 undocumented - bindings pinned), with 16 rows newly bound, Control suspending snapping on macOS and shifted - symbols on other keyboard layouts; then the everyday partials of - [`ui-parity.md`](ui-parity.md): number fields, outline mode's options, Hide when publishing, a - cursor for every tool and modifier, panel widths, dark pages (`field_tests`, `cursor_tests`, - `cursors_tests`, `outline_options_tests`, `hide_publishing_tests`, `panel_width_tests`, - `theme_pages_tests`). -- **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). -- **Impact:** Figma users notice the feel within minutes; it decides whether they stay. -- **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). - -### 5. Localization: English, reviewed Russian, plus unreviewed drafts -- **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, - Korean and Simplified Chinese drafts and the Latin American Spanish overlay, the 25 newest strings - in five drafts, Hindi, Arabic, Indonesian and Vietnamese, localized engine messages and number - formatting; CJK UI text on the web without the craft-fonts CJK build. -- **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in - `crates/ui-egui/locales/` (1,371 UI keys plus 524 command labels and 29 menu wordings), - Preferences ▸ Language, `pref.language`; `en.toml` is complete, and `es.toml`, `fr.toml`, - `de.toml`, `pt-BR.toml`, `ja.toml`, `ko.toml` and `zh-Hans.toml` (2026-10-12) are machine drafts - (all but `zh-Hans.toml` lack the 25 newest strings), with `es-419.toml` a Latin American overlay - listing only what differs from Spanish (Spanish and Russian are held to completeness by the check; the - others show English for strings added later). Russian adds all 2,165 current entries, - reviewed and approved by VanTan, with one/few/many integer plurals. - CJK UI text uses an installed CJK face the desktop app loads when something on screen needs it - (`crates/ui-egui/src/cjk.rs`). Engine error messages and Dev Mode values stay English. -- **Impact:** non-English designers get machine drafts nobody has reviewed yet, and English - errors. -- **Estimate:** 3–7 h per language plus native review, 4–6 h for engine messages and numbers; - 29–51 h for the key languages left and the drafts' follow-ups. Detail in - [`localization-parity.md`](localization-parity.md); how to add one in - [`translating.md`](translating.md). - -### 6. Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run -- **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in - the browser) acting through the command registry, one undo step per run, manifest permissions, - Plugins menu, Run last plugin, Manage plugins, `plugin.*` commands, a Rust SDK and two examples - ([`plugins.md`](plugins.md)). Plugin panels and settings (ABI v2, 2026-10-11): a plugin shows a - panel of fields and buttons drawn with FigCraft's widgets and is called back for each button or - changed value (one undo step per callback, same sandbox limits), driven headless with - `plugin.ui` / `plugin.event` / `plugin.close`; per-plugin settings kept with the preferences. - Widgets (plugin API 3, 2026-10-12): a plugin draws a layer of its own on the canvas (Plugins ▸ - Widgets, `widget.insert`), validated and built with locked layers inside, called back for clicks - on the parts it names (one undo step each, a fresh metered sandbox, the state kept in the layer - so it saves, copies, undoes, exports and syncs), `widget.click` / `widget.list` for agents, a - Counter example; panels of several plugins side by side; the colour picker on plugin colour - fields. A plugin directory on `figcraft-server` (2026-10-12): bundles published with the server - key and checked in full (`figcraft-cli plugin publish`), browsed, installed after a permissions - confirmation and updated from Plugins ▸ Manage plugins ▸ Browse, downloads checked against their - SHA-256, `plugin.directory.*` for scripts and MCP, moderation with `figcraft-server plugins`. -- **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings - and install counts, background update checks, the directory in the browser build; widgets take only clicks (no - text input, property menus, hover or timers) and draw only frames, text, shapes and images; - no images or sliders in panels. Figma's own plugins are JavaScript against Figma's plugin API and - don't run. -- **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks) - can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better - than Figma does. -- **Estimate:** 8–26 h for the missing pieces of FigCraft's own API. Running Figma's plugins - needs a JS engine and an API shim: **owner decision needed**. - -### 7. CPU canvas where Figma uses the GPU -- **Missing:** GPU rendering of the canvas. FigCraft renders with tiny-skia on the CPU (banded, - parallel, retained) and shows the image through wgpu. A new zoom can't reuse the retained - image; when renders from scratch take a frame or more, the whole view is previewed with a - sixteenth of the pixels first, then refined. -- **Evidence (measured, `cargo xtask bench`):** a 50k-layer page ~8 ms zoomed out with levels of - detail, single-layer edits at 10k layers ~0.2 ms; full 1440×900 renders 7–15 ms at 10k layers. - Heavy effects (glass, background blur, noise) and 4K/5K canvases at 2x are where CPU time grows. -- **Impact:** large files and effect-heavy designs; battery on laptops. -- **Estimate:** 30–60 h for a GPU path (vello or wgpu shaders) held to the CPU renderer as the - oracle. - -### 8. Platform QA: Linux and the browser left -- **Missing:** regular runs on Linux and the browser; real screen readers; a repeat of the Windows - pass on other machines (scale factors above 100%, pen tablets, file drops from Explorer). -- **Evidence:** CI tests on Linux only (`.github/workflows/ci.yml`); release builds exist for every - platform (v0.2.0) but no issue has been filed yet. Windows: QA pass done 2026-10-11, findings in - PR (Windows 11, the desktop app driven over the control channel, the same workflow through MCP): - eight bugs fixed, among them unsaved changes lost when the app was killed or crashed, - Ctrl+arrows and Ctrl+Backspace acting on the whole line in text, scrubbed fields snapping back - and skewed linear gradients. -- **Impact:** unknown bugs on the platforms where FigCraft is most attractive (Linux has no Figma - desktop app). -- **Estimate:** 10–20 h, partly human (hardware). - -### 9. Figma Draw: tablet pressure on macOS and Linux -- **Missing:** pen pressure on macOS and Linux tablets (winit reports none there). -- **Landed (2026-10-12):** multi-edit: several vector layers in edit mode at once (Enter or - double-click with them selected, `vector.edit.begin {ids}`); points selected, marqueed, dragged, - nudged, deleted, mirrored and rounded across layers in one undo step; the pen, bend, paint bucket, - cut and eraser work in the layer last pressed (`vector_multi_behaviour_tests`). -- **Landed:** text on a path (`text.onPath`, `text.pathOptions`, `text.removeFromPath`; the - Design panel's Typography section; canvas, PNG, SVG and PDF as outlines; SVG `` imports - as text on a path), pattern fills (`PaintKind::Pattern`, `fill.pattern`, the colour picker's - Pattern tab; SVG ``, PDF tiling patterns), stretch and scatter brushes (`stroke.brush`, - eight presets drawn in code; canvas, PNG, SVG, PDF and outline stroke) and pen pressure for the - Pencil (Windows pens and browser styluses; recorded as the stroke's width profile, which brushes - follow). -- **Evidence:** winit reports no pressure on macOS and Linux tablets. -- **Impact:** illustration-flavoured work moved into Figma Draw in 2025. -- **Estimate:** 1–3 h once winit (or a platform tablet API behind it) reports pressure there. - -### 10. Team libraries: no team-wide lists or accounts; analytics are counts -- **Missing:** libraries listed for a whole team or organization (each file enables a library by - its link), accounts and per-person library permissions, analytics beyond instance counts per - component and file (insertions and detaches over time), update notices pushed by the server - (apps ask every 30 seconds while the Assets panel shows). -- **Evidence:** a file's library is published to `figcraft-server` under the file and enabled in - other files by a share link, with per-asset versions, updates with overrides kept, usage counts - and swapping a whole library by names ([`server.md`](server.md#team-libraries), - [`components-parity.md`](components-parity.md)). -- **Impact:** design-system teams. -- **Estimate:** 4–8 h, the accounts part after gap 2. - -### 11. Spell check -- **Missing:** spelling underline and suggestions while editing text. -- **Evidence:** no spell-check code in the repo. -- **Estimate:** 4–8 h with a permissively licensed dictionary and checker (provenance per - `ATTRIBUTION.md`). - -### 12. Video fills; HEIC images -- **Landed (2026-10-11):** animated GIF fills (the file kept as is, the first frame on the canvas - and in exports, played in the presenter and the preview) and TIFF images. -- **Missing:** Figma takes JPG, PNG, HEIC, WebP, GIF and TIFF images and MP4, MOV and WebM video - (help center, checked 2026-10-10). FigCraft decodes PNG, JPEG, WebP, GIF, TIFF and BMP; HEIC and - video need decoders that are C libraries (libheif, FFmpeg) or each platform's media framework. -- **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock). - -### 13. Vector tail: none left -- **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together, - sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG, - PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a - selected open path. Left: corner smoothing beyond rectangles and frames, the transform box around - points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. -- **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a - subtracted hole reaches what's behind; with the group or an operand selected its operands are - hit, so each can still be picked; "Select layer" lists the group only where its result is), live - boolean editing (operands selected by double-click or Cmd/Ctrl-click; - moving, resizing or editing an operand's points redraws the result during the drag, with every - operand outlined; per-operand operations from the boolean controls and `node.set`; a drag frame - recombines only the operands from the edited one up), Design panel Arc fields (Start, Sweep, - Ratio; Mixed across ellipses; bindable), the shape builder on strokes and open paths (strokes take - part as their outlined areas), one-sided variable widths (Alt-drag a width handle; canvas, PNG, - SVG, PDF and outline stroke). -- **Estimate:** none left (vector QA with real files is part of gap 3). - -### 14. Variables: extended collections on real libraries -- **Landed:** extended collections (`library.extendCollection`: a library collection's modes and - variables in a collection of this file, values overridden per mode with reset, following library - updates, variables of its own), variables import/export (`variable.export` / `variable.import`: - the REST local variables JSON, merged by name; CSS custom properties), gradient stops and variant - properties bound to variables. -- **Landed (2026-10-12):** variables a library adds to a collection this file extends are a library - update of their own (`library.updates` lists them by name, without an `id` until they have a - copy) and arrive inheriting when accepted; a library collection that is itself an extended - collection (a brand theme extending a core one) can be extended again, its values flowing down - the chain as each file accepts updates, every level's overrides kept. -- **Missing:** checked with synthetic files only; extending a collection of the same file isn't - offered (extending takes library collections). -- **Estimate:** 0–1 h. - -### 15. Prototyping: media triggers and actions, controllers off Windows -- **Landed (2026-10-12):** key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad - Home`, the standard layout's 17 buttons; a Controller menu beside the key field; REST files' - controller triggers import); the player reads controllers on Windows and in the browser (Gamepad - API) and takes `ui.input {gamepad}` on the control channel; widgets react to clicks in a - presented prototype, on the prototype's own copy; `proto.evaluate` computes expressions (mode - lookups included) where a layer's interaction would run (`crates/engine/src/proto_tail_tests.rs`, - `crates/ui-egui/src/present_tail_tests.rs`, `crates/format/src/rest/gamepad_tests.rs`). -- **Missing:** video triggers (on media hit/end) and play/pause/mute/seek actions (the REST - importer warns and skips them, `crates/format/src/rest/proto.rs`); controllers on macOS, Linux - and FreeBSD (the usual Rust controller library links libudev on Linux; a pure-Rust reader per - platform, or an owner decision on that dependency); PlayStation and Switch button labels. -- **Estimate:** 2–5 h, the media part after video fills (gap 12). - -### 16. Dev Mode: the tail after dev resources -- **Landed (2026-10-11):** code connect: a component or set links to its code component per - language (React, SwiftUI, Compose): an import path, the name and a template whose `{property}` - and `{property ? "a" : "b"}` placeholders take the instance's values, escaped per language and - never executed. `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet - instead of generated code (inside generated layer trees too); the panel's Connect to code edits - it with a live preview. Links are stored on the component (saved, synced, undoable) and travel - as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`, validated and capped) - so code owners can version them. Plugins whose manifest asks only to read run from the Inspect - tab. -- **Landed (2026-10-12):** dev resources: links for developers (a URL and a name) on any layer - (`devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced). An instance also - lists its main component's links (and its set's) read-only with where they come from; layers - inside an instance show their main layer's. URLs are checked as web links (http or https, a - host, no spaces, capped) and never fetched: a link added without a name is named after its URL, - and a link opens only when clicked. The Inspect tab lists, adds, edits and removes them; - `get_node` / `inspect_document` return them as `devResources`. -- **Missing:** import lines for connected instances inside a - generated layer tree (only the selected layer's own import is shown); publishing mappings from - the code repository with a CLI. An editor extension is out of scope: the MCP server already - serves design data, code and the connected snippets to agents and editors. -- **Estimate:** 0–1 h. - -### 17. Branching and merging -- **Landed (2026-10-11):** branches of a file (`branch.*`): File ▸ Create branch… (or the Branches - list in version history) copies the file into a branch tab that remembers the file as it was (its - base) and makes its ids in its own id space; edits on the branch are saved with the main file. - Review and merge (the banner on a branch tab) shows the main file and the branch side by side or - as an overlay, what changed on each side since the base (layers with their property changes as - Inspect shows them; styles, variables, comments) and the conflicts: the same property changed on - both sides, or a layer deleted on one side while the other edited it or what's inside it. Each - conflict is resolved for the branch or main; nothing conflicting merges until it is. Merging is - one undo step on the main file and archives the branch; Update from main brings main's changes - into the branch the same way; branches can be archived without merging. Three-way merge over the - collaboration change sets (`figcraft_collab::merge`), fuzz-tested with random real commands. -- **Landed (2026-10-12):** a branch's own version history: versions saved on a branch tab (by hand - or autosaved) go into the branch, not the main file, and are viewed, restored (one undo step on - the branch), renamed, deleted and duplicated there; they are saved in the file with the branch. - Merging adds a version to the main file's history (Merged "name"); the merged or archived - branch keeps its own history and opens with it. Fills, strokes and effects merge item by item: - different items changed on each side, or items added at the end on one side, merge without a - conflict; only the same item changed both ways, an item removed on one side, or different items - added on both sides conflict (as the whole list). -- **Missing:** branches of a file in a live session are refused; other lists (text runs, export - settings, guides…) still conflict as a whole; the branch history has no entries of its own for - its creation or updates from main (only saved versions, autosaves and the merge on main). -- **Estimate:** 1–2 h. - -### 18. Display P3 on screen (several windows landed) -- **Landed (2026-10-11):** several windows: File ▸ New window, a tab's Open in new window or a tab - dragged off the strip; each window has its own tabs, panels and canvas and draws only when it - needs to; closing one asks about unsaved changes; `window.new`, `window.list`, `window.focus` and - `window` on any control request. -- **Missing:** wide-gamut colour on the canvas (egui draws sRGB, so P3 colours outside sRGB clip on - screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.36 - gives only the main window one); window positions restored at the next start. -- **Estimate:** 4–8 h. - -### 19. AI features -- **Missing:** Figma AI (first draft, rename layers, remove background, image generation and - editing, visual search, Make). -- **Estimate:** 30–60 h with openly licensed local models. **Owner decision needed.** - -### 20. Mobile apps -- **Missing:** Figma's iOS and Android apps view and comment on files and mirror prototypes. - FigCraft's web build runs in mobile browsers but is not designed for touch. -- **Estimate:** 20–40 h for a touch-first viewer and mirror. - -### 21. Menu items without a command (measured): none left -- **Landed (2026-10-11):** `file.openRecent` (File ▸ Open recent: recent files kept in the - preferences, at most 20, greyed when the file is gone and dropped when picked, Clear recent), - `view.outlinesSelection` (outline mode for the selected layers only) and `view.maskOutlines`; - the plugin pair landed in #101. Every item in the menu catalog has a command - ([`parity-checklist.md`](parity-checklist.md)). -- **Estimate:** none left. - -### 22. Screen readers never run; browser has no accessibility tree -- **Evidence:** AccessKit names and keyboard navigation landed (#96), checked by tests only; - eframe's web backend doesn't send the tree yet. -- **Estimate:** 4–8 h plus human QA with VoiceOver, NVDA, Narrator and Orca. - -### 23. CMYK output intent for print PDFs -- **Evidence:** CMYK conversion is a documented naive formula; no CMYK ICC profile can ship in the - repository (licensing). -- **Estimate:** 2–4 h once an openly licensed profile is found. - -## Revision history - -| Date | Change | Summary | -|---|---|---| -| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | -| 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | -| 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged | -| 2026-10-12 | minor | Gap 2: sharing with a join code (Share ▸ Online or Local network, one Start sharing button, a short code to give others, one join field for a code or a link; the server names links with codes and limits wrong ones) added to the landed list; usability, so the estimate and ranking are unchanged | -| 2026-10-12 | minor | Gap 3: first measured fidelity data. Text box sizes and auto layout results of our example documents were compared with the same documents re-saved by other software; three text rules (whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only) were found and applied, and all 230 self-sizing text boxes in 7 documents now match to the pixel (one font family). Real files are still unchecked: hours and the ranking unchanged | -| 2026-10-12 | minor | Gap 2: live sessions are per document (each open file has its own session or none, several run at once, the window and the `collab.*` commands follow the file in front, closing a file ends its session only) added to the landed list; a fix, so the estimate and ranking are unchanged | -| 2026-10-12 | minor | Gap 1: the experimental `.fig` export now opens elsewhere: written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Hours and the ranking unchanged | -| 2026-10-12 | minor | Gap 1: an experimental `.fig` export landed (the whole document, from the same public format notes as the import; checked only by round trips through our own importer); export hours 15–30 h → 5–15 h (checks against real files); gap 1's read tail (5–10 h) and the ranking unchanged | -| 2026-10-12 | minor | Gap 4: keyboard shortcut parity suite per platform (129 documented rows: 126 bound, 1 moved, 2 missing; 48 undocumented bindings pinned) added to the landed list; estimate and ranking unchanged | -| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Gap 5 31–54 h → 29–51 h, rank unchanged | -| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Gap 5 32–55 h → 31–54 h, rank unchanged | -| 2026-10-12 | minor | Gap 15: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes and `proto.evaluate` landed; gap 15 now media plus controllers off Windows, 3–6 h → 2–5 h; ranking unchanged | -| 2026-10-12 | minor | Plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version, operator moderation) added to gap 6's landed list; gap 6 10–30 h → 8–26 h; ranking unchanged | -| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Gap 5 40–65 h → 32–55 h, rank unchanged | -| 2026-10-12 | minor | Plugin widgets (API 3) and the panel tail (panels side by side, colour picker on plugin fields) added to gap 6's landed list; gap 6 now 10–30 h; ranking unchanged | -| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Gap 5 45–75 h → 40–65 h, rank unchanged | -| 2026-10-12 | minor | Cursor chat and spotlight in live sessions (gap 2): `/` opens a chat bubble at your pointer, a spotlight makes everyone follow your view; ephemeral presence the host validates and rate-limits. Gap 2 45–90 h → 42–84 h | -| 2026-10-12 | minor | Layers and Assets panels behaviour suite and fixes added to gap 4's landed list; ranking and hours unchanged | -| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Gap 9 2–5 h → 1–3 h (pen pressure on macOS and Linux tablets left, blocked on winit). Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | -| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | -| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | -| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | -| 2026-10-12 | minor | Comments and version history behaviour suite and fixes added to gap 4's landed list (gap 17 unchanged: a branch's own version history is still left); ranking and hours unchanged | -| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | -| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Gap 1 30–60 h → 8–15 h (vector networks, other layer kinds, real-file checks; export 15–30 h on top, as before) | -| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | -| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | -| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h | -| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | -| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | -| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | -| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | -| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | -| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | -| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | -| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | -| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | -| 2026-10-10 | minor | Marked beta blockers; no alpha blockers (the alpha gate passes) | -| 2026-10-10 | major | Created from the 2026-10-10 re-measure against Figma desktop 126.10.7 (replaces the gap list that lived in ROADMAP.md) | +# Where FigCraft falls short of Figma + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) + +Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from +doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The +numbers behind the ranking are in [`target-app-parity.md`](target-app-parity.md). Hours are one +Opus 5.5 agent working sequentially, tests included, calibrated as described there. "Estimated" +everywhere unless marked measured. + +## Ranked + +No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passes. Beta blockers are marked. + +| # | Gap | Kind | Hours | Doc | +|---|---|---|---|---| +| 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed, and an experimental export (round trips through our own importer; written files opened in other software, where layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, instances keep their size and variables with modes and colour styles come across); real-file checks and the small tail left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) | +| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | +| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | +| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | +| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts | Localization | 29–51 | [localization-parity](localization-parity.md) | +| 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | +| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | +| 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | +| 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | +| 10 | Team libraries: no team-wide lists or accounts; analytics are counts | Feature | 4–8 | [components-parity](components-parity.md) | +| 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) | +| 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) | +| 13 | Vector tail: outline-stroke compositing (stroke rings preserved) | Feature | — | [target-app-parity](target-app-parity.md) | +| 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | +| 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | +| 16 | Dev Mode: import lines inside generated layer trees; the editor extension is left to the MCP server (code connect, Dev Mode plugins and dev resources landed) | Feature | 0–1 | [dev-mode-parity](dev-mode-parity.md) | +| 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | +| 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | +| 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | +| 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) | +| 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) | +| 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) | +| 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) | + +## Entries + +### 1. `.fig` files: the import's tail +- **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`, + `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved + over. +- **Landed (2026-10-12):** an experimental export: `file.export {format: "fig"}`, File ▸ Export… ▸ + .fig (experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document + with a kiwi encoder mirroring the decoder and a schema of the fields written: the layer tree, + geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto + layout, constraints, components, sets, instances with their overrides and property values, and + prototype interactions; shared style links, variables, comments, Dev Mode data and the rest are + left out with a warning ([file-format-parity](file-format-parity.md#export-to-fig-experimental)). + The import gained ellipse arcs, boolean operation types and component set descriptions. Then + (2026-10-12): written files were checked by opening them in other software: layers, fills, + gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after the export + learned to say that the document and its pages are visible and opaque, to number its schema as + the format does, to write outlines, text settings, standard font style names and line height + units. Then (2026-10-13): instances keep their own size (an override addressed by the main's id, + derived data for their layers, a scale factor), variables with modes and bindings, shared styles + and variable-width strokes are written and read back; checked there: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there), + like effects such as glass and noise, images and prototype interactions. +- **Missing:** connector labels, elbowed or curved connectors without a saved path (drawn straight), + table cells beyond their own layers, filled areas of a vector with their own fills, per-point stroke + joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks and video + fills aren't read; text lays out again with + local fonts. Nothing has been checked against real-world files yet (gap 3). The export is checked + only against our own importer: whether other apps open its files is unknown. +- **Evidence:** fixtures generated in code (`crates/format/src/fig/tests.rs` and `tail_tests.rs`: kiwi + round trips, every mapped feature, vector network blobs, truncation, bit flips and junk never panic); `crates/engine/src/fig_import_tests.rs`. + Export: 60 generated documents round trip through the importer layer by layer, written files + decode with the generic decoder and list parents first, the encoder agrees with the decoder, + junk documents never panic (`crates/format/src/fig/export_tests.rs`, + `crates/engine/src/fig_export_tests.rs`). +- **Impact:** existing work can be brought across, but only real files will show how much of it. +- **Estimate:** 5–10 h for the read tail (real-file fixes, connector labels, table cells); checking the export against real files a further 5–15 h (with gap 3). + +### 2. Multiplayer and sharing are a preview, without accounts +- **Missing:** accounts, teams and projects, per-person permissions, audio, + shared comments and version history. Comments and versions are local to the file. +- **Evidence:** the core landed in #103 (`crates/collab`: client-scoped ids, change sets, server + ordering and validation, client reconciliation, binary messages, randomized convergence tests over + real commands), and live sessions run over the network (`crates/collab-net`: hosting from the + app, invite links that open the app, document sync between apps, live cursors, selections, + text carets, avatars and follow, cursor chat and spotlight, and the browser build joins) with per-person undo, refused + edits rolled back with a reason, reconnects that resume and send what was made offline, and + styles, variables and comments that several people create at once all kept; sessions are per + document (each open file has its own or none, several at once, closing a file ends only its + own); files are shared with a short join code (online through the server in Preferences, or on + the local network with a code that carries the host's address) and joined by typing it; the app + remembers the files it put online and lists them on the home screen (open again as owner, stop + and start sharing, rename, remove, forget), per computer; a standalone + `figcraft-server` keeps many files (snapshot plus log, compacted; restarts lose nothing + confirmed), takes files published from the app and hands out share links that let people edit + or only view; the desktop app and the browser reach it over `wss://` through a TLS reverse + proxy, and releases ship it with a deployment guide (`docs/server.md`). But there are no + accounts (a link is the access) (`docs/collab-protocol.md`). +- **Impact:** Figma's defining feature; most teams use it every day. Solo designers can work + without it. +- **Estimate:** 42–84 h: accounts, teams and per-person permissions, shared comments and versions + (numbers from before the server landed; re-measured next). + +### 3. Nothing checked against real Figma files +- **Missing:** a corpus of real documents (design systems, app screens, prototypes) imported and + compared with Figma's own PNG/SVG/PDF exports. +- **Evidence:** every corpus in the repo is synthetic (`crates/svg/tests/corpus`, the synthetic + design system in `crates/engine/src/synthetic_ds.rs`). Scores were self-graded by the agents that + built the features. +- **Impact:** unknown fidelity in auto layout sizing, text metrics, effects and component + resolution on real files; the numbers in this repo can't be trusted beyond "estimated" until it + exists. +- **Measured so far:** text box sizes and auto layout results of our own example documents, + against the same documents re-saved by other software (which lays text out again). Three text + rules explained every difference and are applied (whole-pixel line heights, advances on a + 1024-per-em grid, letter spacing between glyphs only): all 230 self-sizing text boxes in 7 + documents now come out to the pixel (24 of 131 before, in the design-system example), and 208 of + its 231 other layers match in size and position (115 before). The other 23 hang on one grid + auto layout frame whose row positions came back as written while its cells got shorter. One font + family (Inter), left-aligned text; real files, other fonts, effects and component resolution are + still unmeasured (`crates/text/src/metrics_tests.rs`, and a local check over such documents in + `crates/engine/src/text_metrics_local_tests.rs`). +- **Estimate:** 15–30 h once `.fig` or REST import of real files is available; needs files whose + licence allows use (owner to supply or approve; never committed if not redistributable, see + craftrules `standards/test-corpora.md`). + +### 4. Interaction fidelity not compared tool by tool +- **Missing:** a side-by-side pass of every tool and panel: cursors, modifier keys while dragging, + hover states, popovers, menus, snapping thresholds. +- **Landed (2026-10-11):** behaviour suites written from the public documentation for auto layout, + selection and transform, text, and canvas navigation with rulers, guides, layout grids, outline + mode and measuring, then (2026-10-12) comments and version history, and the layers and Assets + panels (`crates/engine/src/*_behaviour_tests.rs`, `crates/ui-egui/src/layers_behaviour_tests.rs`), + each with its fixes; then keyboard shortcuts per platform (`shortcut_parity_tests.rs`: 129 + documented rows, 126 bound, 1 moved off a documented conflict, 2 missing, 48 undocumented + bindings pinned), with 16 rows newly bound, Control suspending snapping on macOS and shifted + symbols on other keyboard layouts; then the everyday partials of + [`ui-parity.md`](ui-parity.md): number fields, outline mode's options, Hide when publishing, a + cursor for every tool and modifier, panel widths, dark pages (`field_tests`, `cursor_tests`, + `cursors_tests`, `outline_options_tests`, `hide_publishing_tests`, `panel_width_tests`, + `theme_pages_tests`). +- **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). +- **Impact:** Figma users notice the feel within minutes; it decides whether they stay. +- **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). + +### 5. Localization: English, reviewed Russian, plus unreviewed drafts +- **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, + Korean and Simplified Chinese drafts and the Latin American Spanish overlay, the 25 newest strings + in five drafts, Hindi, Arabic, Indonesian and Vietnamese, localized engine messages and number + formatting; CJK UI text on the web without the craft-fonts CJK build. +- **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in + `crates/ui-egui/locales/` (1,371 UI keys plus 524 command labels and 29 menu wordings), + Preferences ▸ Language, `pref.language`; `en.toml` is complete, and `es.toml`, `fr.toml`, + `de.toml`, `pt-BR.toml`, `ja.toml`, `ko.toml` and `zh-Hans.toml` (2026-10-12) are machine drafts + (all but `zh-Hans.toml` lack the 25 newest strings), with `es-419.toml` a Latin American overlay + listing only what differs from Spanish (Spanish and Russian are held to completeness by the check; the + others show English for strings added later). Russian adds all 2,165 current entries, + reviewed and approved by VanTan, with one/few/many integer plurals. + CJK UI text uses an installed CJK face the desktop app loads when something on screen needs it + (`crates/ui-egui/src/cjk.rs`). Engine error messages and Dev Mode values stay English. +- **Impact:** non-English designers get machine drafts nobody has reviewed yet, and English + errors. +- **Estimate:** 3–7 h per language plus native review, 4–6 h for engine messages and numbers; + 29–51 h for the key languages left and the drafts' follow-ups. Detail in + [`localization-parity.md`](localization-parity.md); how to add one in + [`translating.md`](translating.md). + +### 6. Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run +- **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in + the browser) acting through the command registry, one undo step per run, manifest permissions, + Plugins menu, Run last plugin, Manage plugins, `plugin.*` commands, a Rust SDK and two examples + ([`plugins.md`](plugins.md)). Plugin panels and settings (ABI v2, 2026-10-11): a plugin shows a + panel of fields and buttons drawn with FigCraft's widgets and is called back for each button or + changed value (one undo step per callback, same sandbox limits), driven headless with + `plugin.ui` / `plugin.event` / `plugin.close`; per-plugin settings kept with the preferences. + Widgets (plugin API 3, 2026-10-12): a plugin draws a layer of its own on the canvas (Plugins ▸ + Widgets, `widget.insert`), validated and built with locked layers inside, called back for clicks + on the parts it names (one undo step each, a fresh metered sandbox, the state kept in the layer + so it saves, copies, undoes, exports and syncs), `widget.click` / `widget.list` for agents, a + Counter example; panels of several plugins side by side; the colour picker on plugin colour + fields. A plugin directory on `figcraft-server` (2026-10-12): bundles published with the server + key and checked in full (`figcraft-cli plugin publish`), browsed, installed after a permissions + confirmation and updated from Plugins ▸ Manage plugins ▸ Browse, downloads checked against their + SHA-256, `plugin.directory.*` for scripts and MCP, moderation with `figcraft-server plugins`. +- **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings + and install counts, background update checks, the directory in the browser build; widgets take only clicks (no + text input, property menus, hover or timers) and draw only frames, text, shapes and images; + no images or sliders in panels. Figma's own plugins are JavaScript against Figma's plugin API and + don't run. +- **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks) + can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better + than Figma does. +- **Estimate:** 8–26 h for the missing pieces of FigCraft's own API. Running Figma's plugins + needs a JS engine and an API shim: **owner decision needed**. + +### 7. CPU canvas where Figma uses the GPU +- **Missing:** GPU rendering of the canvas. FigCraft renders with tiny-skia on the CPU (banded, + parallel, retained) and shows the image through wgpu. A new zoom can't reuse the retained + image; when renders from scratch take a frame or more, the whole view is previewed with a + sixteenth of the pixels first, then refined. +- **Evidence (measured, `cargo xtask bench`):** a 50k-layer page ~8 ms zoomed out with levels of + detail, single-layer edits at 10k layers ~0.2 ms; full 1440×900 renders 7–15 ms at 10k layers. + Heavy effects (glass, background blur, noise) and 4K/5K canvases at 2x are where CPU time grows. +- **Impact:** large files and effect-heavy designs; battery on laptops. +- **Estimate:** 30–60 h for a GPU path (vello or wgpu shaders) held to the CPU renderer as the + oracle. + +### 8. Platform QA: Linux and the browser left +- **Missing:** regular runs on Linux and the browser; real screen readers; a repeat of the Windows + pass on other machines (scale factors above 100%, pen tablets, file drops from Explorer). +- **Evidence:** CI tests on Linux only (`.github/workflows/ci.yml`); release builds exist for every + platform (v0.2.0) but no issue has been filed yet. Windows: QA pass done 2026-10-11, findings in + PR (Windows 11, the desktop app driven over the control channel, the same workflow through MCP): + eight bugs fixed, among them unsaved changes lost when the app was killed or crashed, + Ctrl+arrows and Ctrl+Backspace acting on the whole line in text, scrubbed fields snapping back + and skewed linear gradients. +- **Impact:** unknown bugs on the platforms where FigCraft is most attractive (Linux has no Figma + desktop app). +- **Estimate:** 10–20 h, partly human (hardware). + +### 9. Figma Draw: tablet pressure on macOS and Linux +- **Missing:** pen pressure on macOS and Linux tablets (winit reports none there). +- **Landed (2026-10-12):** multi-edit: several vector layers in edit mode at once (Enter or + double-click with them selected, `vector.edit.begin {ids}`); points selected, marqueed, dragged, + nudged, deleted, mirrored and rounded across layers in one undo step; the pen, bend, paint bucket, + cut and eraser work in the layer last pressed (`vector_multi_behaviour_tests`). +- **Landed:** text on a path (`text.onPath`, `text.pathOptions`, `text.removeFromPath`; the + Design panel's Typography section; canvas, PNG, SVG and PDF as outlines; SVG `` imports + as text on a path), pattern fills (`PaintKind::Pattern`, `fill.pattern`, the colour picker's + Pattern tab; SVG ``, PDF tiling patterns), stretch and scatter brushes (`stroke.brush`, + eight presets drawn in code; canvas, PNG, SVG, PDF and outline stroke) and pen pressure for the + Pencil (Windows pens and browser styluses; recorded as the stroke's width profile, which brushes + follow). +- **Evidence:** winit reports no pressure on macOS and Linux tablets. +- **Impact:** illustration-flavoured work moved into Figma Draw in 2025. +- **Estimate:** 1–3 h once winit (or a platform tablet API behind it) reports pressure there. + +### 10. Team libraries: no team-wide lists or accounts; analytics are counts +- **Missing:** libraries listed for a whole team or organization (each file enables a library by + its link), accounts and per-person library permissions, analytics beyond instance counts per + component and file (insertions and detaches over time), update notices pushed by the server + (apps ask every 30 seconds while the Assets panel shows). +- **Evidence:** a file's library is published to `figcraft-server` under the file and enabled in + other files by a share link, with per-asset versions, updates with overrides kept, usage counts + and swapping a whole library by names ([`server.md`](server.md#team-libraries), + [`components-parity.md`](components-parity.md)). +- **Impact:** design-system teams. +- **Estimate:** 4–8 h, the accounts part after gap 2. + +### 11. Spell check +- **Missing:** spelling underline and suggestions while editing text. +- **Evidence:** no spell-check code in the repo. +- **Estimate:** 4–8 h with a permissively licensed dictionary and checker (provenance per + `ATTRIBUTION.md`). + +### 12. Video fills; HEIC images +- **Landed (2026-10-11):** animated GIF fills (the file kept as is, the first frame on the canvas + and in exports, played in the presenter and the preview) and TIFF images. +- **Missing:** Figma takes JPG, PNG, HEIC, WebP, GIF and TIFF images and MP4, MOV and WebM video + (help center, checked 2026-10-10). FigCraft decodes PNG, JPEG, WebP, GIF, TIFF and BMP; HEIC and + video need decoders that are C libraries (libheif, FFmpeg) or each platform's media framework. +- **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock). + +### 13. Vector tail: outline-stroke compositing +- **Landed (2026-10-15):** Outline stroke keeps the renderer's even-odd stroke rings; regression pixels, Undo/Redo, native save/reopen and instance opacity inheritance/overrides in `outline_stroke`. +- **Missing:** preserving filled-shape opacity, blend and effects on the composite. A follow-up must preserve component inheritance and references when deciding which node owns these properties. +- **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together, + sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG, + PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a + selected open path. Left: corner smoothing beyond rectangles and frames, the transform box around + points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. +- **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a + subtracted hole reaches what's behind; with the group or an operand selected its operands are + hit, so each can still be picked; "Select layer" lists the group only where its result is), live + boolean editing (operands selected by double-click or Cmd/Ctrl-click; + moving, resizing or editing an operand's points redraws the result during the drag, with every + operand outlined; per-operand operations from the boolean controls and `node.set`; a drag frame + recombines only the operands from the edited one up), Design panel Arc fields (Start, Sweep, + Ratio; Mixed across ellipses; bindable), the shape builder on strokes and open paths (strokes take + part as their outlined areas), one-sided variable widths (Alt-drag a width handle; canvas, PNG, + SVG, PDF and outline stroke). +- **Estimate:** compositing not yet re-estimated; vector QA with real files remains part of gap 3. + +### 14. Variables: extended collections on real libraries +- **Landed:** extended collections (`library.extendCollection`: a library collection's modes and + variables in a collection of this file, values overridden per mode with reset, following library + updates, variables of its own), variables import/export (`variable.export` / `variable.import`: + the REST local variables JSON, merged by name; CSS custom properties), gradient stops and variant + properties bound to variables. +- **Landed (2026-10-12):** variables a library adds to a collection this file extends are a library + update of their own (`library.updates` lists them by name, without an `id` until they have a + copy) and arrive inheriting when accepted; a library collection that is itself an extended + collection (a brand theme extending a core one) can be extended again, its values flowing down + the chain as each file accepts updates, every level's overrides kept. +- **Missing:** checked with synthetic files only; extending a collection of the same file isn't + offered (extending takes library collections). +- **Estimate:** 0–1 h. + +### 15. Prototyping: media triggers and actions, controllers off Windows +- **Landed (2026-10-12):** key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad + Home`, the standard layout's 17 buttons; a Controller menu beside the key field; REST files' + controller triggers import); the player reads controllers on Windows and in the browser (Gamepad + API) and takes `ui.input {gamepad}` on the control channel; widgets react to clicks in a + presented prototype, on the prototype's own copy; `proto.evaluate` computes expressions (mode + lookups included) where a layer's interaction would run (`crates/engine/src/proto_tail_tests.rs`, + `crates/ui-egui/src/present_tail_tests.rs`, `crates/format/src/rest/gamepad_tests.rs`). +- **Missing:** video triggers (on media hit/end) and play/pause/mute/seek actions (the REST + importer warns and skips them, `crates/format/src/rest/proto.rs`); controllers on macOS, Linux + and FreeBSD (the usual Rust controller library links libudev on Linux; a pure-Rust reader per + platform, or an owner decision on that dependency); PlayStation and Switch button labels. +- **Estimate:** 2–5 h, the media part after video fills (gap 12). + +### 16. Dev Mode: the tail after dev resources +- **Landed (2026-10-11):** code connect: a component or set links to its code component per + language (React, SwiftUI, Compose): an import path, the name and a template whose `{property}` + and `{property ? "a" : "b"}` placeholders take the instance's values, escaped per language and + never executed. `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet + instead of generated code (inside generated layer trees too); the panel's Connect to code edits + it with a live preview. Links are stored on the component (saved, synced, undoable) and travel + as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`, validated and capped) + so code owners can version them. Plugins whose manifest asks only to read run from the Inspect + tab. +- **Landed (2026-10-12):** dev resources: links for developers (a URL and a name) on any layer + (`devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced). An instance also + lists its main component's links (and its set's) read-only with where they come from; layers + inside an instance show their main layer's. URLs are checked as web links (http or https, a + host, no spaces, capped) and never fetched: a link added without a name is named after its URL, + and a link opens only when clicked. The Inspect tab lists, adds, edits and removes them; + `get_node` / `inspect_document` return them as `devResources`. +- **Missing:** import lines for connected instances inside a + generated layer tree (only the selected layer's own import is shown); publishing mappings from + the code repository with a CLI. An editor extension is out of scope: the MCP server already + serves design data, code and the connected snippets to agents and editors. +- **Estimate:** 0–1 h. + +### 17. Branching and merging +- **Landed (2026-10-11):** branches of a file (`branch.*`): File ▸ Create branch… (or the Branches + list in version history) copies the file into a branch tab that remembers the file as it was (its + base) and makes its ids in its own id space; edits on the branch are saved with the main file. + Review and merge (the banner on a branch tab) shows the main file and the branch side by side or + as an overlay, what changed on each side since the base (layers with their property changes as + Inspect shows them; styles, variables, comments) and the conflicts: the same property changed on + both sides, or a layer deleted on one side while the other edited it or what's inside it. Each + conflict is resolved for the branch or main; nothing conflicting merges until it is. Merging is + one undo step on the main file and archives the branch; Update from main brings main's changes + into the branch the same way; branches can be archived without merging. Three-way merge over the + collaboration change sets (`figcraft_collab::merge`), fuzz-tested with random real commands. +- **Landed (2026-10-12):** a branch's own version history: versions saved on a branch tab (by hand + or autosaved) go into the branch, not the main file, and are viewed, restored (one undo step on + the branch), renamed, deleted and duplicated there; they are saved in the file with the branch. + Merging adds a version to the main file's history (Merged "name"); the merged or archived + branch keeps its own history and opens with it. Fills, strokes and effects merge item by item: + different items changed on each side, or items added at the end on one side, merge without a + conflict; only the same item changed both ways, an item removed on one side, or different items + added on both sides conflict (as the whole list). +- **Missing:** branches of a file in a live session are refused; other lists (text runs, export + settings, guides…) still conflict as a whole; the branch history has no entries of its own for + its creation or updates from main (only saved versions, autosaves and the merge on main). +- **Estimate:** 1–2 h. + +### 18. Display P3 on screen (several windows landed) +- **Landed (2026-10-11):** several windows: File ▸ New window, a tab's Open in new window or a tab + dragged off the strip; each window has its own tabs, panels and canvas and draws only when it + needs to; closing one asks about unsaved changes; `window.new`, `window.list`, `window.focus` and + `window` on any control request. +- **Missing:** wide-gamut colour on the canvas (egui draws sRGB, so P3 colours outside sRGB clip on + screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.36 + gives only the main window one); window positions restored at the next start. +- **Estimate:** 4–8 h. + +### 19. AI features +- **Missing:** Figma AI (first draft, rename layers, remove background, image generation and + editing, visual search, Make). +- **Estimate:** 30–60 h with openly licensed local models. **Owner decision needed.** + +### 20. Mobile apps +- **Missing:** Figma's iOS and Android apps view and comment on files and mirror prototypes. + FigCraft's web build runs in mobile browsers but is not designed for touch. +- **Estimate:** 20–40 h for a touch-first viewer and mirror. + +### 21. Menu items without a command (measured): none left +- **Landed (2026-10-11):** `file.openRecent` (File ▸ Open recent: recent files kept in the + preferences, at most 20, greyed when the file is gone and dropped when picked, Clear recent), + `view.outlinesSelection` (outline mode for the selected layers only) and `view.maskOutlines`; + the plugin pair landed in #101. Every item in the menu catalog has a command + ([`parity-checklist.md`](parity-checklist.md)). +- **Estimate:** none left. + +### 22. Screen readers never run; browser has no accessibility tree +- **Evidence:** AccessKit names and keyboard navigation landed (#96), checked by tests only; + eframe's web backend doesn't send the tree yet. +- **Estimate:** 4–8 h plus human QA with VoiceOver, NVDA, Narrator and Orca. + +### 23. CMYK output intent for print PDFs +- **Evidence:** CMYK conversion is a documented naive formula; no CMYK ICC profile can ship in the + repository (licensing). +- **Estimate:** 2–4 h once an openly licensed profile is found. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | +| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | +| 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | +| 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged | +| 2026-10-12 | minor | Gap 2: sharing with a join code (Share ▸ Online or Local network, one Start sharing button, a short code to give others, one join field for a code or a link; the server names links with codes and limits wrong ones) added to the landed list; usability, so the estimate and ranking are unchanged | +| 2026-10-12 | minor | Gap 3: first measured fidelity data. Text box sizes and auto layout results of our example documents were compared with the same documents re-saved by other software; three text rules (whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only) were found and applied, and all 230 self-sizing text boxes in 7 documents now match to the pixel (one font family). Real files are still unchecked: hours and the ranking unchanged | +| 2026-10-12 | minor | Gap 2: live sessions are per document (each open file has its own session or none, several run at once, the window and the `collab.*` commands follow the file in front, closing a file ends its session only) added to the landed list; a fix, so the estimate and ranking are unchanged | +| 2026-10-12 | minor | Gap 1: the experimental `.fig` export now opens elsewhere: written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Hours and the ranking unchanged | +| 2026-10-12 | minor | Gap 1: an experimental `.fig` export landed (the whole document, from the same public format notes as the import; checked only by round trips through our own importer); export hours 15–30 h → 5–15 h (checks against real files); gap 1's read tail (5–10 h) and the ranking unchanged | +| 2026-10-12 | minor | Gap 4: keyboard shortcut parity suite per platform (129 documented rows: 126 bound, 1 moved, 2 missing; 48 undocumented bindings pinned) added to the landed list; estimate and ranking unchanged | +| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Gap 5 31–54 h → 29–51 h, rank unchanged | +| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Gap 5 32–55 h → 31–54 h, rank unchanged | +| 2026-10-12 | minor | Gap 15: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes and `proto.evaluate` landed; gap 15 now media plus controllers off Windows, 3–6 h → 2–5 h; ranking unchanged | +| 2026-10-12 | minor | Plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version, operator moderation) added to gap 6's landed list; gap 6 10–30 h → 8–26 h; ranking unchanged | +| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Gap 5 40–65 h → 32–55 h, rank unchanged | +| 2026-10-12 | minor | Plugin widgets (API 3) and the panel tail (panels side by side, colour picker on plugin fields) added to gap 6's landed list; gap 6 now 10–30 h; ranking unchanged | +| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Gap 5 45–75 h → 40–65 h, rank unchanged | +| 2026-10-12 | minor | Cursor chat and spotlight in live sessions (gap 2): `/` opens a chat bubble at your pointer, a spotlight makes everyone follow your view; ephemeral presence the host validates and rate-limits. Gap 2 45–90 h → 42–84 h | +| 2026-10-12 | minor | Layers and Assets panels behaviour suite and fixes added to gap 4's landed list; ranking and hours unchanged | +| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Gap 9 2–5 h → 1–3 h (pen pressure on macOS and Linux tablets left, blocked on winit). Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | +| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | +| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | +| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | +| 2026-10-12 | minor | Comments and version history behaviour suite and fixes added to gap 4's landed list (gap 17 unchanged: a branch's own version history is still left); ranking and hours unchanged | +| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | +| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Gap 1 30–60 h → 8–15 h (vector networks, other layer kinds, real-file checks; export 15–30 h on top, as before) | +| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | +| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | +| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h | +| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | +| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | +| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | +| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | +| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | +| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | +| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | +| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | +| 2026-10-10 | minor | Marked beta blockers; no alpha blockers (the alpha gate passes) | +| 2026-10-10 | major | Created from the 2026-10-10 re-measure against Figma desktop 126.10.7 (replaces the gap list that lived in ROADMAP.md) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 12f2a5fb..336eb1ad 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,124 +1,125 @@ -# FigCraft roadmap: milestones and what's next - -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) - -Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and -[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours -are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint crates. There is no -`cargo xtask scorecard` in this repo; the measured number is `cargo xtask parity`. - -## Current focus - -1. **`.fig` import's tail**: editable vector networks, the skipped layer kinds that matter and - fixes from real files (the best-effort import landed 2026-10-11): 8–15 h. -2. **Real-file corpus and fidelity measurement**: import real files, compare against Figma's own - exports, turn estimated percentages into measured ones: 15–30 h. -3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)): - 40–60 h, parallel by tool. -4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja, - ko and zh-Hans drafts and the es-419 overlay (people), then the other key languages: 3–7 h per language - plus native review. -5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h. - -## Alpha gate - -The core workflows a Figma Design user runs every day, checked end to end on macOS (the main -platform) with work saved to `.figcraft` and reopened. Evidence is the repo's tests and the -builders' side-by-side checks with synthetic files; nothing has been run on real Figma files yet. - -| Workflow | Works end to end? | Evidence | Hours to pass | -|---|---|---|---| -| Design screens: frames, shapes, vectors, text, auto layout, styles; save, reopen, export assets | yes | tools and panels in `crates/engine` and `crates/ui-egui`; `.figcraft` round trips (`crates/format/src/large_tests.rs`); PNG/JPG/SVG/PDF export tests | — | -| Build and use a design system: components, variants, properties, variables and modes, libraries | yes | `components_scale_tests.rs` (300 components, 2,000 instances), `library_tests.rs`, `library_variables_tests.rs`; libraries are local files, not shared | — | -| Prototype and present: interactions, flows, overlays, smart animate, variables | yes | `proto_*_tests.rs`, `present_*_tests.rs` | — | -| Hand off to developers: Dev Mode inspect, code, measurements, asset export, ready for dev | yes | `devinspect_tests.rs`, `component_codegen_tests.rs`, `devcompare_tests.rs`, `annotations_tests.rs` | — | -| Review with others: comments, versions, sharing the work | partial (not blocking) | comments with threads, mentions and resolve plus version history work inside a file (`comments_tests.rs`, `version_tests.rs`); the file is shared by passing it along; live sessions (preview: hosted from the desktop app or a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, undo per person); no accounts or teams ([collaboration-parity](collaboration-parity.md)) | beta gap: 45–90 | -| Continue existing Figma work (open `.fig`) | partial (beta gap) | best-effort `.fig` import (`crates/format/src/fig`, tested on generated fixtures, not yet on real files); REST JSON import works for files fetched through Figma's API ([file-format-parity](file-format-parity.md)) | beta gap: 8–15 | - -**Passes, so FigCraft stays alpha:** every daily design workflow works end to end on FigCraft's -own files, which save and reopen, and ready for real work (~47%) is above the ~40% bar. The two -gaps are real but don't break a workflow done in FigCraft: review works asynchronously through -comments in a shared file, and opening `.fig` is the step that moves work across from Figma, which -the standard requires for beta, not alpha. Both stay at the top of [`gaps.md`](gaps.md) as beta -blockers. - -## Milestones - -| Milestone | Status | Left | Hours | -|---|---|---|---| -| M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — | -| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — | -| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass | -| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | -| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | -| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11; vector edit mode regions 2026-10-13) | spell check, Figma Draw multi-edit | 7–18 | -| M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 | -| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | -| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | -| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | -| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | -| M10 Localization (new) | not started | layer + 11 languages | 60–100 | -| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 | -| M12 AI features (new, owner decision) | not started | local models | 30–60 | - -## Next, after the current focus - -- Team libraries for a whole team (lists, accounts and permissions) and deeper library analytics (4–8 h, after - accounts), branching (8–15 h). -- GPU canvas held to the CPU renderer (30–60 h). -- Figma Draw multi-edit and pen pressure on macOS/Linux tablets (2–5 h). -- Mobile viewer and prototype mirror (20–40 h). - -## Revision history - -| Date | Change | Summary | -|---|---|---| -| 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | -| 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | -| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | -| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | -| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged | -| 2026-10-14 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen and leaves it again; FigCraft's own item. Numbers unchanged | -| 2026-10-14 | minor | Colour picker: wrapped rows of page and recent swatches stay inset on both sides instead of running past the picker's right edge. Numbers unchanged | -| 2026-10-14 | minor | Server snapshots (#188): figcraft-server never replaces a readable snapshot with one too large to be read back, and reads snapshots up to twice the file cap (recent batches kept for reconnecting clients may take a file over it). Numbers unchanged | -| 2026-10-14 | minor | Preferences owner-only on Unix (#200): `state.json`, which holds online owner tokens, is written with mode 0600 from the first byte, and an older world-readable file is narrowed. Numbers unchanged | -| 2026-10-14 | minor | ruzstd 0.9.1 (#182): `.fig` import requires the release that fixes RUSTSEC-2026-0344 (a zstd decompression bomb). Numbers unchanged | -| 2026-10-14 | minor | Layer cycles (#205): `validate` reports children listed twice, pages with a parent and layers not reachable from a page (cycles, detached subtrees); `repair` drops them in one linear pass. Numbers unchanged | -| 2026-10-14 | minor | AppImage CLI: `figcraft.AppImage cli ` runs the bundled `figcraft-cli` (MCP server included) on Linux without a second download. Numbers unchanged | -| 2026-10-14 | minor | Boolean outline depth (#193): nested boolean groups share one outline depth budget instead of each level getting its own. Numbers unchanged | -| 2026-10-14 | minor | Command batches roll back as one: a failed `command.batch` (MCP `batch`) restores every open document and its state, and puts the tab in front back. Numbers unchanged | -| 2026-10-14 | minor | Desktop builds no longer rebuild the text crate on every build when the craft-fonts manifest is missing. Numbers unchanged | -| 2026-10-14 | minor | Direct3D 12 on Windows: the desktop app asks wgpu for Direct3D 12 there instead of Vulkan, so it starts without Vulkan loader errors on machines with stray overlay layers. Numbers unchanged | -| 2026-10-14 | minor | Plugin metering (#192): every host call and every byte copied between a plugin and the host costs fuel, so a plugin can't stall the app through cheap-looking host calls. Numbers unchanged | -| 2026-10-13 | minor | M4: Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, octave-folded spacing fixed to the shape), and a click, Shift-click or drag selects and moves areas with their points (`vector_regions_tests`, `regions_tests`). Estimates unchanged. | -| 2026-10-13 | minor | M1, #220: The Layout panel's constrain-proportions link keeps the ratio on a W or H edit; the property edit dropped it, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep (transform behaviour suite). Estimates unchanged. | -| 2026-10-13 | minor | M1, #181: Repeat last command keeps the underlying command and parameters instead of recording itself; a stored self-reference is refused. Three cumulative repeats and their individual undo steps are covered by regression tests. Estimates unchanged. | -| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Focus item 4 adds the zh-Hans draft's review | -| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Focus item 4 no longer lists Latin American Spanish | -| 2026-10-12 | minor | M5: prototyping tail: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, `proto.evaluate`; M5's tail 3–6 h → 2–5 h | -| 2026-10-12 | minor | M11: a plugin directory on `figcraft-server` (bundles published with the server key and checked in full, browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, hash-checked downloads, updates by version, `plugin.directory.*`, `figcraft-cli plugin publish`); 10–30 h → 8–26 h | -| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Current focus 4 now lists the drafts' native reviews | -| 2026-10-12 | minor | M11: plugin widgets (API 3: plugin-drawn layers on the canvas, redrawn on clicks from state kept in the document), panels of several plugins side by side and the colour picker on plugin colour fields; 15–40 h → 10–30 h | -| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Current focus 4 now lists Japanese and Korean | -| 2026-10-11 | minor | `.fig` import, best effort: current focus 1 and the alpha gate's `.fig` row move to the import's tail (30–60 h → 8–15 h); M6 ~55% → ~65%, 37–75 h → 15–30 h | -| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | -| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | -| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | -| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | -| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | -| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | -| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | -| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | -| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | -| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | -| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | -| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | -| 2026-10-10 | minor | Added the alpha gate (core-workflow table): passes, stage stays alpha | -| 2026-10-10 | major | Created: milestone table moved from ROADMAP.md with statuses re-checked; added M10–M12; current focus re-planned | +# FigCraft roadmap: milestones and what's next + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) + +Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and +[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours +are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint crates. There is no +`cargo xtask scorecard` in this repo; the measured number is `cargo xtask parity`. + +## Current focus + +1. **`.fig` import's tail**: editable vector networks, the skipped layer kinds that matter and + fixes from real files (the best-effort import landed 2026-10-11): 8–15 h. +2. **Real-file corpus and fidelity measurement**: import real files, compare against Figma's own + exports, turn estimated percentages into measured ones: 15–30 h. +3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)): + 40–60 h, parallel by tool. +4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja, + ko and zh-Hans drafts and the es-419 overlay (people), then the other key languages: 3–7 h per language + plus native review. +5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h. + +## Alpha gate + +The core workflows a Figma Design user runs every day, checked end to end on macOS (the main +platform) with work saved to `.figcraft` and reopened. Evidence is the repo's tests and the +builders' side-by-side checks with synthetic files; nothing has been run on real Figma files yet. + +| Workflow | Works end to end? | Evidence | Hours to pass | +|---|---|---|---| +| Design screens: frames, shapes, vectors, text, auto layout, styles; save, reopen, export assets | yes | tools and panels in `crates/engine` and `crates/ui-egui`; `.figcraft` round trips (`crates/format/src/large_tests.rs`); PNG/JPG/SVG/PDF export tests | — | +| Build and use a design system: components, variants, properties, variables and modes, libraries | yes | `components_scale_tests.rs` (300 components, 2,000 instances), `library_tests.rs`, `library_variables_tests.rs`; libraries are local files, not shared | — | +| Prototype and present: interactions, flows, overlays, smart animate, variables | yes | `proto_*_tests.rs`, `present_*_tests.rs` | — | +| Hand off to developers: Dev Mode inspect, code, measurements, asset export, ready for dev | yes | `devinspect_tests.rs`, `component_codegen_tests.rs`, `devcompare_tests.rs`, `annotations_tests.rs` | — | +| Review with others: comments, versions, sharing the work | partial (not blocking) | comments with threads, mentions and resolve plus version history work inside a file (`comments_tests.rs`, `version_tests.rs`); the file is shared by passing it along; live sessions (preview: hosted from the desktop app or a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, undo per person); no accounts or teams ([collaboration-parity](collaboration-parity.md)) | beta gap: 45–90 | +| Continue existing Figma work (open `.fig`) | partial (beta gap) | best-effort `.fig` import (`crates/format/src/fig`, tested on generated fixtures, not yet on real files); REST JSON import works for files fetched through Figma's API ([file-format-parity](file-format-parity.md)) | beta gap: 8–15 | + +**Passes, so FigCraft stays alpha:** every daily design workflow works end to end on FigCraft's +own files, which save and reopen, and ready for real work (~47%) is above the ~40% bar. The two +gaps are real but don't break a workflow done in FigCraft: review works asynchronously through +comments in a shared file, and opening `.fig` is the step that moves work across from Figma, which +the standard requires for beta, not alpha. Both stay at the top of [`gaps.md`](gaps.md) as beta +blockers. + +## Milestones + +| Milestone | Status | Left | Hours | +|---|---|---|---| +| M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — | +| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — | +| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass | +| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | +| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | +| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11; vector edit mode regions 2026-10-13; stroke holes preserved 2026-10-15) | outline-stroke compositing, spell check, Figma Draw multi-edit | 7–18 | +| M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 | +| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | +| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | +| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | +| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | +| M10 Localization (new) | not started | layer + 11 languages | 60–100 | +| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 | +| M12 AI features (new, owner decision) | not started | local models | 30–60 | + +## Next, after the current focus + +- Team libraries for a whole team (lists, accounts and permissions) and deeper library analytics (4–8 h, after + accounts), branching (8–15 h). +- GPU canvas held to the CPU renderer (30–60 h). +- Figma Draw multi-edit and pen pressure on macOS/Linux tablets (2–5 h). +- Mobile viewer and prototype mirror (20–40 h). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | +| 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | +| 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | +| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | +| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | +| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged | +| 2026-10-14 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen and leaves it again; FigCraft's own item. Numbers unchanged | +| 2026-10-14 | minor | Colour picker: wrapped rows of page and recent swatches stay inset on both sides instead of running past the picker's right edge. Numbers unchanged | +| 2026-10-14 | minor | Server snapshots (#188): figcraft-server never replaces a readable snapshot with one too large to be read back, and reads snapshots up to twice the file cap (recent batches kept for reconnecting clients may take a file over it). Numbers unchanged | +| 2026-10-14 | minor | Preferences owner-only on Unix (#200): `state.json`, which holds online owner tokens, is written with mode 0600 from the first byte, and an older world-readable file is narrowed. Numbers unchanged | +| 2026-10-14 | minor | ruzstd 0.9.1 (#182): `.fig` import requires the release that fixes RUSTSEC-2026-0344 (a zstd decompression bomb). Numbers unchanged | +| 2026-10-14 | minor | Layer cycles (#205): `validate` reports children listed twice, pages with a parent and layers not reachable from a page (cycles, detached subtrees); `repair` drops them in one linear pass. Numbers unchanged | +| 2026-10-14 | minor | AppImage CLI: `figcraft.AppImage cli ` runs the bundled `figcraft-cli` (MCP server included) on Linux without a second download. Numbers unchanged | +| 2026-10-14 | minor | Boolean outline depth (#193): nested boolean groups share one outline depth budget instead of each level getting its own. Numbers unchanged | +| 2026-10-14 | minor | Command batches roll back as one: a failed `command.batch` (MCP `batch`) restores every open document and its state, and puts the tab in front back. Numbers unchanged | +| 2026-10-14 | minor | Desktop builds no longer rebuild the text crate on every build when the craft-fonts manifest is missing. Numbers unchanged | +| 2026-10-14 | minor | Direct3D 12 on Windows: the desktop app asks wgpu for Direct3D 12 there instead of Vulkan, so it starts without Vulkan loader errors on machines with stray overlay layers. Numbers unchanged | +| 2026-10-14 | minor | Plugin metering (#192): every host call and every byte copied between a plugin and the host costs fuel, so a plugin can't stall the app through cheap-looking host calls. Numbers unchanged | +| 2026-10-13 | minor | M4: Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, octave-folded spacing fixed to the shape), and a click, Shift-click or drag selects and moves areas with their points (`vector_regions_tests`, `regions_tests`). Estimates unchanged. | +| 2026-10-13 | minor | M1, #220: The Layout panel's constrain-proportions link keeps the ratio on a W or H edit; the property edit dropped it, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep (transform behaviour suite). Estimates unchanged. | +| 2026-10-13 | minor | M1, #181: Repeat last command keeps the underlying command and parameters instead of recording itself; a stored self-reference is refused. Three cumulative repeats and their individual undo steps are covered by regression tests. Estimates unchanged. | +| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Focus item 4 adds the zh-Hans draft's review | +| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Focus item 4 no longer lists Latin American Spanish | +| 2026-10-12 | minor | M5: prototyping tail: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, `proto.evaluate`; M5's tail 3–6 h → 2–5 h | +| 2026-10-12 | minor | M11: a plugin directory on `figcraft-server` (bundles published with the server key and checked in full, browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, hash-checked downloads, updates by version, `plugin.directory.*`, `figcraft-cli plugin publish`); 10–30 h → 8–26 h | +| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Current focus 4 now lists the drafts' native reviews | +| 2026-10-12 | minor | M11: plugin widgets (API 3: plugin-drawn layers on the canvas, redrawn on clicks from state kept in the document), panels of several plugins side by side and the colour picker on plugin colour fields; 15–40 h → 10–30 h | +| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Current focus 4 now lists Japanese and Korean | +| 2026-10-11 | minor | `.fig` import, best effort: current focus 1 and the alpha gate's `.fig` row move to the import's tail (30–60 h → 8–15 h); M6 ~55% → ~65%, 37–75 h → 15–30 h | +| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | +| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | +| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | +| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | +| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | +| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | +| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | +| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | +| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | +| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | +| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | +| 2026-10-10 | minor | Added the alpha gate (core-workflow table): passes, stage stays alpha | +| 2026-10-10 | major | Created: milestone table moved from ROADMAP.md with statuses re-checked; added M10–M12; current focus re-planned | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e15ddaed..1f17a8c0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,702 +1,703 @@ -# FigCraft parity with Figma - -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) - -The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) -summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is -[`parity-checklist.md`](parity-checklist.md). Deeper checklists: -[`ui-parity.md`](ui-parity.md), [`file-format-parity.md`](file-format-parity.md), -[`hardware-parity.md`](hardware-parity.md), [`localization-parity.md`](localization-parity.md), -[`auto-layout-parity.md`](auto-layout-parity.md), [`components-parity.md`](components-parity.md), -[`prototyping-parity.md`](prototyping-parity.md), [`dev-mode-parity.md`](dev-mode-parity.md), -[`collaboration-parity.md`](collaboration-parity.md). - -## Headline - -| Number | Value | Kind | -|---|---|---| -| Menu/command breadth | **100%** (211 / 211 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-12) | -| Feature breadth (does each Figma Design feature exist, weighted) | **~85%** (core editor areas 1–16: ~94%) | estimated from the area table below | -| Ready for real work | **~53%** | estimated (dimension table below: written weights × written per-dimension scores) | -| Essentials user | **~59%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.94 × 0.88) | -| Mainstream practitioner | **~50%** | estimated ([below](#mainstream-practitioner): weekly areas 72.7% × discounts 0.9 × 0.9 × 0.85) | -| Remaining to beta | **~155–280 h** one Opus 5.5 agent (~32–64 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated | -| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | - -The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were -self-graded against a 16-area rubric that left out, by design, multiplayer, plugins, cloud -libraries and AI features, and that counted no file-format blocker. Figma users rely on all of -those every day, so this re-measure weights them in. The core-editor depth figure drops from ~79% -to ~67% because nothing has yet been checked against real Figma files or side by side tool by -tool; presence was being graded as behaviour in places. - -## Readiness by audience - -| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | -|---|---|---|---| -| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | -| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the experimental `.fig` export opens elsewhere (5–15) | -| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | - -Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour -per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see -[how the hours are calibrated](#how-the-hours-are-calibrated)). Each audience's work is a subset of -the next. About 70–80% parallelizes across 4–6 agents on disjoint crates (roughly ÷4 in -wall-clock); the `.fig` reader, the multiplayer core and a plugin runtime each need one owner, and -native-speaker review, real-file licensing and screen-reader QA need people. - -The full number is an additive weighted sum over the dimension table; mainstream and essentials use -multiplicative discounts, as the standard says. - -## What was measured, and how - -- **Target and version.** Figma desktop for macOS, `CFBundleShortVersionString` 126.10.7, - installed at `/Applications/Figma.app`. FigCraft is measured as of `origin/main` d452da3 - (2026-10-10, PR #99). -- **The installed app, names and public listings only.** FigCraft is clean-room - ([`AGENTS.md`](../AGENTS.md)): nothing inside the bundle's code or web assets was read. From - `Info.plist`: one document type, `com.figma.document`, extensions `fig`, `jam`, `deck` - (MIME `application/x-figma`), and the `figma://` URL scheme. The `.lproj` folders are - Electron/Chromium locale packs (60+), not Figma's UI languages; the UI languages come from - Figma's help center (see [`localization-parity.md`](localization-parity.md)). The bundle ships - GPU, Plugin and Renderer helper processes (GPU rendering; a sandboxed plugin runtime). -- **The menu catalog.** `crates/engine/src/catalog.rs` holds Figma's main menu tree (202 items in - File, Edit, View, Object, Text, Arrange, Vector, Plugins, Preferences, Libraries, Help), written - from public documentation and black-box observation (local notes in the gitignored - `plan/figma/03-menu-tree.md`). It leaves out the Widgets menu and dynamic plugin lists, so the - 100% overstates breadth against the full menu a little. -- **From source** (no build; 2026-10-10): 449 command ids registered with `spec!`, 963 `#[test]` - functions in 177 files, ~108,800 lines of Rust in 13 crates + 3 apps + xtask (before #101–#103, which added the `plugin` and `collab` crates and ~7k lines), 140 key bindings - and 13 gesture rows in `figcraft_engine::keymap`, 21 files in the SVG import corpus, 0 - translated UI strings (no localization layer exists). -- **Figma's feature set**: help center, release notes and Config 2025 announcements (Figma Draw, - grid auto layout, slots, glass/noise/texture effects, progressive blur), known to the assessor up - to mid-2026, plus web checks on 2026-10-10 (supported image/video types, UI languages, pen - pressure in Figma Draw). -- **Side by side.** Look & feel was compared with Figma UI3 on the owner's Mac (screenshots kept - local, never committed). No real Figma file has been opened in FigCraft (no `.fig` reader), and - no rendered frame has been compared pixel for pixel against Figma's own export. - -## Weights - -Weights are the share of a typical product designer's working time in Figma Design, written down -so they can be argued with. Areas 1–16 keep the original rubric's weights (they sum to 94, not the 100 the rubric claimed); -areas 17–21 are added (sum 19) because Figma users depend on them. Percentages below divide by the -total, 113. -Out of scope as separate products: FigJam, Figma Slides, Sites, Make, Buzz, Weave (the `.jam` and -`.deck` document kinds belong to them). - -## By feature area - -Breadth: does it exist. Ready: depth, correctness and fidelity for real projects. Hours: one Opus -5.5 agent, sequential, tests included. - -| # | Area | Weight | Breadth | Ready | Remaining | Hours | -|---|---|---|---|---|---|---| -| 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | -| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | -| 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | -| 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | -| 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | -| 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | -| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | -| 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | -| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | -| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | -| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | -| 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | -| 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | -| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | -| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | -| 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | -| 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | -| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | -| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | -| 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | -| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~85%** | **~63%** | | **117–255** | -| | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 32–74 | - -## By dimension (ready for real work) - -| Dimension | Weight | Ready | Hours | Evidence | Doc | -|---|---|---|---|---|---| -| Features (depth) | 35 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file | -| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | -| File formats | 12 | 60% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | -| Collaboration | 10 | 13% | 52–104 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | -| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | -| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | -| Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | -| Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese (none reviewed by a native speaker; every draft covers all current strings), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | -| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | -| Ecosystem/plugins | 3 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | -| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~53%** | **~262–519** | | | - -Dimension hours add up to the full-parity total without double counting: the feature-area rows -for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are -counted under their dimensions. - -## Essentials user - -Someone who uses only the core of Figma: a casual or beginner user making a few screens, a social -graphic or a simple mockup. Same method, with the core features a casual user touches. - -**1. Core features, weights and depth** (depth from the area table and checklists above): - -| Feature | Weight | Depth | Notes | -|---|---|---|---| -| Create, open, save and reopen a file; pages | 10 | 85% | home screen, `.figcraft` round trips, Open recent | -| Pan and zoom | 10 | 80% | wheel, pinch, Space-drag, zoom shortcuts | -| Select, move, resize, rotate, align, group, duplicate | 15 | 75% | not yet compared tool by tool | -| Frames with device presets | 8 | 80% | | -| Rectangle, ellipse, line, arrow, corner radius | 8 | 80% | | -| Text: type, font, size, weight, colour, alignment | 12 | 70% | no spell check | -| Colour picker, fills and strokes; place and crop images | 10 | 72% | no HEIC images | -| Layers panel: rename, hide, lock, reorder | 7 | 80% | | -| Basic auto layout (add, gap, padding) | 6 | 70% | | -| Undo / redo | 5 | 90% | unlimited, one step per gesture | -| Export PNG, JPG, SVG, PDF | 6 | 85% | | -| Copy and paste, incl. images from other apps | 3 | 75% | | -| **Weighted** | 100 | **77.8%** | | - -Left out: components and variables beyond using one, prototyping, Dev Mode, advanced vector and -type, and everything the mainstream number leaves out. - -**2. Discounts a casual user actually hits:** - -| Discount | Factor | Evidence | -|---|---|---| -| Launch and stability | ×0.92 | signed and notarized macOS build, installers for every platform (v0.2.0), never-crash rules and recovery copies; no field record yet | -| Discoverability and UI clarity | ×0.94 | the UI3 layout Figma users know, Actions palette, keyboard shortcuts panel; a first run opens on the home screen with a Getting started card (six tips with their shortcuts, closable, back from the Help menu) and eight starter templates to open (also File ▸ New from template…); no Community files, no guided tour, and nobody new to the app has been watched using it | -| Opening files people send them | ×0.88 | what people send a Figma user is a Figma link or a `.fig`; a `.fig` opens (best effort, not yet checked on real files), a link doesn't. Images and SVG do | - -77.8% × 0.92 × 0.94 × 0.88 = **~59%** (estimated). User evidence: none yet (see above). - -## Mainstream practitioner - -The same question for the typical professional Figma Design user: a product designer who designs -screens, maintains or uses a design system, prototypes and hands off to developers every week. -Computed per craftrules `standards/progress-docs.md`. - -**1. Areas used weekly, with weights.** Feature areas 1–16 of the table above, with their weights -(sum 94). Left out as the standard says: plugins and widgets (18), AI (21), team and organization -libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a product designer), -localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration -and is left out as an area, but its effect on working with Figma users is counted in discount 3. - -**2. Average depth** ("Ready" column, weighted): **72.7%**. - -**3. Discounts for what still stops real work:** - -| Discount | Factor | Evidence | -|---|---|---| -| Interaction fidelity | ×0.90 | UI3 look compared side by side (~65% by the builders); no tool-by-tool comparison of cursors, modifier keys while dragging, snapping thresholds or popovers yet; Ctrl to suspend snapping on macOS missing ([ui-parity](ui-parity.md)) | -| Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way | -| File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) and writes an experimental one (written files open and draw in other software; effects, images and prototypes unchecked there); incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) | - -72.7% × 0.90 × 0.90 × 0.85 = **~50%** (estimated; 50.03%, rounded down). - -**4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments -from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is -private, 1 star, PRs only from maintainers). No outside evidence either way; nothing raises the -number. - -**Why it is lower than ready for real work (~53%), unusually.** The full number averages -dimensions additively, so the missing `.fig` exchange costs only the file-format dimension's 12% -weight, and FigCraft earns points the mainstream number doesn't count (platforms 75%: Linux, -FreeBSD and the web, where Figma has no desktop app). The mainstream number applies file exchange -as a multiplier on everything, because a Figma practitioner's whole working week is shared through -Figma files. Both are computed from written weights; neither was moved to match the other. The -mainstream number rises fastest with `.fig` import and the experimental `.fig` export checked on real files (the ×0.85 toward ×0.95: ~53%) and the -interaction pass. - -## How the hours are calibrated - -From this repository's own history (`git log`, `gh pr list`): - -- The whole app was built in ~25 wall-clock hours: first commit 2026-10-09 15:38 UTC, PR #99 - merged 2026-10-10 16:53 UTC; ~49k lines on day 1 before PRs, then 99 PRs adding ~81k lines. The - ROADMAP of that period records 4–6 agents in parallel, so ~110–150 agent-hours in all: about - **1 agent-hour per ~1,000 added lines**, and a typical feature PR (1,000–2,500 lines, e.g. - #84 arc handles, #92 PDF print options, #96 accessibility) took **1–2.5 agent-hours**. -- Between the ROADMAP revisions of 2026-10-09 22:19 UTC and 2026-10-10 16:53 UTC (~18.5 h wall, - 78 PRs, ~75–110 agent-hours) the self-graded depth went 58% → 79%: **~4–5 agent-hours per - self-graded point**. This re-measure grades about 0.6 points for each of those, which puts an - honest point nearer 7 agent-hours in the polish tail; new subsystems (a `.fig` reader, - multiplayer, a plugin runtime) are estimated bottom-up instead, since nothing comparable has been - built here yet. -- Parallelism: most of the work splits across 4–6 agents on disjoint crates (the history shows it - working); multiplayer, `.fig` import and the plugin runtime each need one owner for their core. - Needs a human: native-speaker review for every language, the plugin-runtime and AI-model - decisions, real-world Figma files to test with (licensing), Windows/Linux/screen-reader QA. - -## Area detail: what has landed and what is left - -Carried over from the ROADMAP's "Where we're lacking" list (2026-10-10, before this re-measure). -Hours in this narrative are the builders' estimates; the tables above supersede them. - -1. **Vector edit mode**: *landed:* edit-mode toolbar (move, pen, bend, paint bucket, lasso, cut), - pen that extends, branches and closes paths (vector networks), paint bucket fill/unfill of any - closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and - length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and - merge, delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder - (⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept), - variable width strokes (width profile presets with previews in the stroke settings, the Variable - width tool ⇧W: drag the path to add a width point, slide points, drag their handles, Delete; - smooth interpolation per subpath, dashes, caps and joins, canvas/PNG/SVG/PDF and outline stroke), - a transform box around several selected points (corners and edges scale them, Shift keeps - proportions, Alt from the centre; just outside a corner rotates, Shift in 15° steps; drag inside - to move; the Design panel's X/Y/W/H and flip/rotate act on the points), point snapping while - dragging points or handles and with both pens (to the points and segment midpoints of this and - other vectors, the usual layer targets and the pixel grid, with guides and a marker on the point - landed on; Ctrl/Cmd turns it off), arc handles on the canvas (sweep, start and ratio with a - live readout, Shift snapping, closing at the start, the other part from the gap; rotated and - flipped ellipses, any zoom), Design panel Arc fields (Start, Sweep, Ratio; bindable), live - boolean editing (operands selected by double-click or Cmd/Ctrl-click; moving, resizing or - editing an operand's points redraws the result during the drag with every operand outlined; - per-operand operations from the boolean controls; only the operands from the edited one up are - recombined; clicks hit a boolean group where its result is), the shape builder on strokes and open paths (strokes as their outlined areas), and - one-sided variable widths (Alt-drag a width handle; canvas, exports and outline stroke), and - filled regions with the Move tool (the area under the pointer hatched, clear of points and - segments, its spacing by octave folding fitted to measurements at several zoom levels and fixed - to the shape; click, Shift-click and drag to select and move areas with their points). - *Left:* vector QA with real files. 1–2 h. -2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case, - truncation/max lines, font picker with previews and recents, word/line selection, IME commit, - inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the - document only gets the committed text), variable fonts (axes and named instances read from the - font, named instances as styles, Type settings ▸ Variable sliders, per-range axis values applied - to shaping, outlines and PDF), range-aware Typography panel with "Mixed". Range fills in the Fill - section. Right-to-left and mixed-direction text (Unicode bidi: Arabic shaping, mirrored brackets, - caret, clicks and selection follow the runs; arrow keys move visually). Right-to-left and - mixed-direction names in the panels (layers, pages, Assets, tabs, the file header, canvas frame - titles: visual order, joined Arabic, the ellipsis at a right-to-left name's left end; rename fields - whose caret, clicks, drags and arrow keys follow the shown text; system fonts for scripts the UI - fonts lack). The text style browser in the Typography section (list and grid views in each - style's 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, "Mixed" when the - chars use several styles); text styles link chars, not just layers (editing what a style sets - unlinks the chars edited). Live previews of a hovered named instance or font style and of a - dragged axis slider (one undo step on release, Escape cancels). Visual QA: list markers keep the - paragraph's font, renames start with the name selected and centred, Max lines shows while - truncating. Right-to-left paragraphs mirror lists and indents: bullets and numbers (".1") on - the right, wrapped lines hanging from the right, the paragraph indent on the right, trailing - spaces hanging outside the line (canvas, PNG, PDF and SVG). A behaviour suite from the public - documentation (`crates/text/src/behaviour_tests.rs`, `crates/engine/src/text_behaviour_tests.rs`): - Auto / % / px line heights with the leading split above and below every line, letter spacing in - % and px, paragraph spacing and indent (start-aligned text only), resizing and truncation, - decorations from the font's metrics; CJK line breaking (between ideographs, kana and Hangul, no - break inside Latin words, kinsoku for closing and opening punctuation, small kana and the - prolonged sound mark); a style's metrics from its own font, not a fallback; carets, Delete and - Backspace by grapheme cluster; double-click and word moves by script run; IME composition with - CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`, - `crates/engine/src/text_tail_tests.rs`): lists per paragraph, nested up to 8 levels with Tab and - Shift+Tab (1. a. i. and • ◦ ▪ per level, counts restarting after plain paragraphs), Enter on an - empty item and Backspace at an item's start stepping out, `- `/`* `/`1. ` starting a list; list - spacing between items; hanging lists and hanging punctuation; decoration style (solid, dotted, - wavy), thickness, underline offset, skip ink and colour, with inspect, CSS and the REST import. - *Left:* visual QA; hanging punctuation in right-to-left paragraphs and centred text, list - numbering that continues across a plain paragraph and a list's own start number (documentation - silent); decoration colour in SVG exports (drawn with the text's fill, like run fills); Korean - keeps breaking by syllable and Han runs aren't split by a dictionary (documentation silent). 0.5 h. -3. **Auto layout and sections depth**: *landed:* on-canvas padding/gap handles (also wrapped-row - and grid column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, - hugging text wraps at its max), ignore auto layout (absolute position) with constraints, text - baseline alignment, grid tracks (fixed/hug/`fr`), grid cells and spans, wrap row gap UI, - wrap-aware drag-reorder (by row) and dragging into a grid cell, nested hug/fill settling in one - pass (wrapping fill text, three levels deep, grid rows), min/max redistribution, coherent - Fill/Hug rules, drag across nested auto layout frames (deepest one wins, insertion line, out to - the canvas or a plain frame). Arrow keys move auto layout children one place (along the flow - past hidden and absolute siblings, Up/Down between wrapped rows, to the neighbouring grid cell - (automatically placed children swap, anchored ones take or swap cells), and at the end of a - flow or across a row's axis into the neighbouring nested auto layout frame when the frame - around flows that way; one undo step, none when nothing can move). Visual QA of nested - hug/fill/fixed/min/max/wrap/grid samples against the documented rules (Fill turns a hugging - parent Fixed, min/max redistribution, wrap inside fill, absolute children following - constraints as hug frames grow). Sections: hide contents (name, outline and "Contents hidden" - on the canvas; contents skipped by rendering, hit testing and labels; dimmed in the layers - panel; Design panel checkbox and context menu), Wrap in new section, frames in sections get - canvas labels and are top-level frames (flows start there, navigation, Export frames to PDF), - "Navigate to" a section shows its frame shown last, else its first; ready for dev turns - "Changed" when the contents are edited, resolved with a reason; Compare changes shows what a - changed frame or section looked like when it was marked next to how it is now, with the - changed layers and their property changes. *Left:* whether `fr` rows of a fixed-height grid - should make room for fixed-size children (now `minmax(0, 1fr)`-like). 1–2 h. -4. **Components depth**: *landed:* component properties (edit/delete/bind) with the properties - panel, add variant, instance swap UI, per-layer overrides with reset, restore main, instance - structure protection; Assets panel with live thumbnails, search and drag-to-canvas (drops into - the frame or auto layout slot under the pointer); exposed nested instances (their properties - show on the main's instances); component descriptions (edited on the main, shown on - instances); local libraries (publish a file's components, sets and styles with per-asset - versions, enable library files, place library components and apply library styles as - read-only linked copies, "Library updates available" with overrides kept on update); slots - (a frame of a main made a slot property with preferred components; instances fill it with any - content of their own, kept across main updates, variant switches, copy/paste and detach, with - the main's default content until edited; reset slot; slot badges in the layers panel and on the - canvas; fill menu in the properties panel; drops from the Assets panel and the canvas land in - slots); nested overrides kept when the main moves or swaps a nested instance, and on nested - instances copied out of an instance (re-linked to their own main); canvas drags can no longer - pull layers out of an instance; library variables (publishing includes the file's variable - collections with their modes, aliases and scopes, versioned per variable and collection, without - variables hidden from publishing; files using the library see its collections read-only under - the library's name in the Variables editor and the variable pickers; binding, aliasing or setting - a library collection's mode on a frame copies the variables in as read-only linked copies, so the - file renders without the library; library components keep their variable bindings and modes; - "Library updates available" covers changed variables and bound layers follow). Explicit variable - modes in the Design panel: a frame's mode per collection, Auto (inherited) or its own, with - enabled libraries' collections listed under the library's name (picking a mode copies the - collection in). Instance boolean and text properties bound to boolean and string variables - (following modes, renames, reset and detach); layout grid settings in the Design panel whose - count, size, margin/offset and gutter bind to number variables; fills, strokes, effects and - layout grids reordered by dragging their rows, their variable bindings following. Components at - scale, QA'd with a synthetic design system (about 300 components: buttons, inputs, cards, navs - and badges as sets of size × state × type × theme variants, boolean/text/instance swap/slot - properties, instances nested four deep, text and paint styles, light/dark variables bound across - it) used 2,000 times with overrides at every level: main edits keep every override and match a - full rebuild exactly; values from variables (a theme switch, a bound corner radius) are no longer - recorded as overrides; detaching a variable on an instance layer is an override that survives main - edits; exposed nested instances stay exposed and swapped nested instances keep their layer names - through main edits; layers a swapped nested instance's main adds keep their ids and overrides. - Instance swap properties have preferred values (offered first). Alt-drop from the Assets panel - onto an instance swaps it (same-set instance preferred, highlighted while dragging); instances - pasted into other files follow their mains (library copies shared, published components used - from their library, other mains left behind as a restorable missing main). The Assets panel lays - out only the tiles in view and renders thumbnails as they show (cached, re-rendered only for - changed components). A behaviour suite from the public documentation checks overrides (what an - instance takes, what it refuses: geometry, constraints and resizing of its layers, its auto - layout direction), variant switches (overrides stay where both variants agreed; text layers keep - theirs), swaps (by layer name; from the Assets panel only text overrides), the top-left default - variant, detach, reset and push. *Left:* QA with real-world (not synthetic) libraries. 1 h. -5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor, - flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter. - Custom bezier and custom spring easings with editor fields; device frames (phone, tablet, - laptop, desktop bodies drawn in code) around the presented frame; overlays animate out with - their opening transition reversed; several selected layers edit their interactions together - (or replace mixed ones); scrolling inside device frames (the frame sized to the device's screen, - clipped or scrolling by wheel and drag, fixed layers pinned to the screen by their constraints, - nested "Scroll to", preserve scroll position); a resizable preview pane over the canvas that - plays the selected frame and follows edits live; device rotation (phones and tablets turn to - landscape: saved in the file, imported from REST files, drawn turned in the presenter and the - pane); presentation options (fit, fit width, fill, 100%, with Z to cycle them and R to - restart, the device frame shown or hidden, kept between presentations; what overflows the - window pans); scroll momentum (drags fling and slow down the same at any frame rate, the wheel - coasts, a drag past the ends stretches and springs back); responsive presentation (the presented - frame resized to the device's screen, or the window without a device, its constraints and auto - layout responding live; in the Options menu and the Prototype panel, saved in the file); device - colours (each model's colour variants, bodies drawn in code) and custom device sizes (a bare - screen with width and height fields, imported from REST files); flow descriptions in the - presenter's flow picker and under each flow; conditional actions (if / else if / else over - variable expressions) and set-variable-mode actions run in the presenter (from REST files or - commands). Conditionals are built in the interaction editor: an interaction's actions are a list - (add, reorder by dragging a row's grip, also inside conditional blocks, or from its menu, remove, - open one to edit it; one undo step per move), "Add action ▸ Conditional" adds an if whose - condition comes from a builder (a variable from the variable picker, an operator, a value or - another variable) or is typed as an expression (`count >= 3 and not done`, checked when entered, - what's wrong shown under the field), each block with its own actions (the same editors, - nested), plus "Else if" and "Else"; set variable values can be expressions (`count + 1`, - `"Hi " + name`); expressions read a variable's value in a given mode of its collection - (`accent[Dark]`, also from REST files' variable mode lookups) and the mode a collection is in - where the interaction runs (`mode(Theme) == "Dark"`, following set variable mode actions); every - edit is an undo step (`proto.action.*`, `proto.conditional.*`). Hotspot hints: a click where - nothing reacts flashes the screen's hotspots (an Options menu toggle, on by default); the - presenter's Inspect panel shows the hovered layer's properties. Overlays opened through commands - close on a click outside unless told not to, as in the editor. A behaviour suite written from - the public documentation covers triggers, actions, overlays, animations, easing, scrolling and - interactive components ([prototyping-parity](prototyping-parity.md)); its fixes: drag-driven - animations go their way and follow the pointer, key combinations, overlay timers from when they - open, instances play their variant's interactions, physical springs, smart animate overshoot, - fixed layers on top; scroll to takes an offset. *Left:* QA against larger real-world - prototypes. 1–2 h. -6. **Interop**: *landed:* image crop handles; REST API file JSON import (`file.open` / `file.import` - / CLI: pages, layer tree, geometry, paints, strokes, effects, text runs, vectors from geometry - paths, auto layout, constraints, grids, components, sets, instances with overrides, component - properties, styles; image fills from an `images` folder or placeholders; warnings for the rest), - now with prototyping (interactions with their triggers, actions, transitions and easing, flows, - scrolling, the prototype device; conditional actions (if / else if / else over expressions), - set variable with variable references and expressions, set variable mode, all run by the - presenter) and variables (the local variables JSON passed along, next to the file or embedded: - collections, modes, aliases, scopes, layer bindings and explicit modes). - Export panel: a row per export setting (typed or preset sizes like `2x` / `512w` / `512h`, PNG, - JPG, WebP, SVG and PDF, suffix, advanced options: contents only, include bounding box, and for - SVG outline text, id attributes and simplified strokes), a preview, and "Export N layers" (`file.exportLayers`) in the - panel and the Export dialog. Paste from other apps: images, SVG and text (the newest clipboard - content wins over our own copy; pasted SVG lands centred in the view). SVG import is editable and - checked against a corpus of synthetic files (expected pixels worked out by hand, layer structure, - export and re-import): groups named from their ids with transforms baked into the layers; - rectangles (corner radius), ellipses and lines kept as those shapes; gradients with exact - handles, `reflect`/`repeat` and focal points; patterns as image fills; clip paths and masks as - vector, luminance and alpha masks; shadow and blur filter chains as drop shadows, inner shadows - and layer blurs; blend modes; text as text layers with runs, anchors and stacked lines; images - and nested SVG images; import warnings for what's left out; linked files never read; hostile - input fuzzed. SVG export keeps alpha and luminance masks. PDF export: one page per frame in points (Export frames to - PDF, the Export panel, `figcraft-cli export -o x.pdf`), vectors, gradients and strokes as - vectors, real selectable text in subset fonts with clickable hyperlinks, images embedded once, - opacity and blend modes, clip content, alpha/luminance/vector masks, shadows and layer and - background blurs as images; checked by rendering the PDFs back against PNG exports. PDF print - options in the Export panel (per PDF export setting), the Export dialog, `file.export`, - `file.exportLayers`, `file.exportFramesPdf` and `figcraft-cli export`: a bleed showing what lies - past the frame (its fills reach out, its children are clipped at the bleed), crop and bleed - marks outside it, MediaBox / BleedBox / TrimBox, and CMYK colour (a documented, deterministic - naive conversion; images and rasterised effects too). - *Left:* .fig import (best effort), a CMYK output intent (no CMYK ICC profile can ship in the - repository), SVG filters other than shadows and blurs (left out with a warning). 8–16 h. -7. **Look & feel and interaction-fidelity pass**: *landed:* keyboard shortcuts audited against the - public shortcut reference: one keymap (`figcraft_engine::keymap`) drives the menus, the canvas, - the control channel and MCP, with macOS and Windows/Linux keys where they differ (mask, bring to - front/back, distribute, tidy up, layout grids, pixel preview), Text-only and canvas-only bindings - (Cmd/Ctrl+K: link while typing, Actions on the canvas), Cmd/Ctrl+/ for Actions, `+`/`-` zoom, - Shift+0/1/2 from any layout, Cmd/Ctrl+0, hold Z for the zoom tool (Alt zooms out, drag an area), - two quick digits for an exact opacity, boolean keys (Alt+Shift+U/S/I/E), remove fill/stroke - (Alt+/, Shift+/), outlines on Shift+Cmd+O, pixel grid on Shift+' (and Cmd+'), Shift+E / Shift+D and the other panel keys, Ctrl+Tab between files, Shift+B (and B) for the paint bucket in vector edit mode; the Keyboard - shortcuts panel docks at the bottom with a tab per group and search, keycaps per platform. - A shortcut parity suite (`shortcut_parity_tests.rs`) checks every documented shortcut on both - platforms (129 rows: 128 bound, including moving the rotation origin with Alt+R and placing an - object with Enter from the toolbar; 1 moved off a documented conflict) and pins the 48 undocumented - bindings; others' cursors (`Alt+Cmd+\`), list levels while editing (Cmd/Ctrl+] and [), the - keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, - Control to suspend snapping on macOS and Space/N/C/F while presenting landed with it - ([ui-parity](ui-parity.md#keyboard-shortcuts)). - *Left:* tool by tool, panel by panel against the incumbent (cursors, modifier keys while - dragging, hover states, menus, popovers); per-keyboard-layout shortcut tables. 40–60 h. -8. **Collaboration (local):** comments landed: pins pinned to frames and layers that move with them - (to other pages too) and stay where they were when the layer is deleted, your pins dragged onto - another frame, crowded pins clustered when zoomed out (a click zooms them apart), Shift+C to show - or hide them; threads where you edit and delete your own messages, emoji reactions, @mentions - with a picker of the names used in the file (your name from Preferences), read and unread state, - resolve, and "Copy link" (`figcraft://comment/`: pasting or `comment.open` goes to the page, - zooms to the frame and opens the thread); the Comments panel with search (text, names, frame), - filters (open, resolved, yours, unread), sorting by date or page, each thread's frame and page, - and Up/Down to step through threads; comments on the presented frame (Comments button or - Shift+C: read and reply). All of it through `comment.*` commands. Version history - landed (named versions saved in the `.figcraft` file; restore, rename, duplicate, delete; panel; - view a version on the canvas read-only before restoring it; autosave checkpoints every 30 - minutes, folded in the panel). A behaviour suite written from the public documentation covers - both: restoring keeps the comments and first keeps the replaced design as an autosave, a titled - autosave becomes a named version, merging a branch records the main file before and after, and - threads made in a live session can be replied to and resolved. Real-time collaboration is designed (`docs/collab-protocol.md`: - WebSocket sessions the app can host itself, server-ordered per-property changes, live cursors) - and its core landed (`crates/collab`, no networking yet): client-scoped node ids, every saved - layer field and the document-level data (styles, variables, comments, images…) as synced - properties, diffs after a command that cost the size of the edit, fractional sibling positions, - a session server that orders and validates batches (cycles, deleted layers, id spaces, sizes) - and a client that shows its own edits at once and replays them over others'; derived data - (auto layout, instances, bindings) is repaired by the author after concurrent edits; binary - messages (a moved layer is 43 bytes, a cursor 23) with a JSON form; a randomized multi-client - test over real commands converges. Live sessions run over the network (hosting from the app, - invite links, document sync, live cursors, selections, avatars, follow). Undo is per person in - a session (`edit.undo` takes back only your own steps, leaving what others changed since; - creations and deletions invert; a gesture or command group stays one step); refused edits roll - back with a toast; a dropped connection keeps editing and resumes as the same client with what - it missed (or a snapshot), then sends what was made offline. Randomized sessions with undo, - redo and dropped connections converge, and a checker confirms no undo reverts someone else's - change. - Plugins landed: sandboxed WebAssembly plugins (a metered wasmi - interpreter with a memory cap and no WASI, also in the browser) whose API is the command - registry; each run is one undo step and is rolled back when it fails (error, trap, endless loop, - memory); a `figcraft-plugin.json` manifest declares menu commands with parameters and read or - write access with an optional command allowlist, and files, other documents, the undo history, - dialogs and other plugins are always off limits. Plugins ▸ Development ▸ Import plugin from - manifest… (kept in the preferences, the module re-read on every run), the installed plugins in - the Plugins menu with a parameters form drawn from the manifest, Run last plugin (⌥⌘P), Manage - plugins, and `plugin.*` commands for scripts, the CLI and MCP; a Rust SDK and two example - plugins (Tidy names, Swatch grid) in `plugins/`, built by `cargo xtask plugins`. Then plugin - panels (ABI v2: fields and buttons drawn with FigCraft's widgets, the plugin called back for - each button or changed value as one undo step, `plugin.ui` / `plugin.event` / `plugin.close` - for scripts and MCP) and per-plugin settings kept with the preferences. Then widgets (API 3: - a plugin describes a layer of its own, validated and built as a frame with locked layers; clicks - on its named parts call the plugin back in a fresh sandbox with the state kept in the layer, one - undo step each; `widget.insert` / `widget.click` / `widget.list`; a Counter example), panels of - several plugins side by side and the colour picker on plugin colour fields. Then a plugin - directory on `figcraft-server`: bundles published with the server key and checked in full, - browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, downloads - checked against their SHA-256, updates by version. *Left:* a public catalogue, publisher - accounts and signatures; richer widget events; the directory in the browser. 8–26 h. -9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer - pages: engine, render and headless UI-frame timings). The canvas keeps its last image and - re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders - in ~0.2 ms instead of ~8 ms (0.8 vs 24 ms at 50k zoomed out), a 40 px pan in ~0.7 ms instead of - ~7.5 ms. The layers panel lays out only the rows in view (every screen expanded at 50k: 39 → - 0.6 ms a frame); text layouts are shared instead of re-formatted per lookup; overflowing text is - culled by its glyphs. At 10k: full 1440×900 renders 7–15 ms, single-layer edits ~10 µs, hit - tests ~26 µs. Zoomed out (50% and below) the canvas draws levels of detail: layers under 2 px - land as their averaged colour, text under 4 px is greeked into bars, small rectangles and clip - layers skip the path pipeline (exports and closer zooms stay exact): a whole 50k-layer page - renders in ~8 ms instead of ~50 ms. `.figcraft` at 50k layers saves in ~0.09 s instead of ~1 s - and loads in ~0.16 s instead of ~0.86 s (JSON streamed to and from the document without a - value tree, layer fields at their defaults left out: 63 → 22 MB of JSON, old files still - open). The retained canvas is held to a full render: a seeded random walk (edits, undo/redo, - pans and zooms over nested, rotated and clipping frames, groups, masks, booleans, auto layout, - effects, text, instances, variables and styles) checks every step, and found clip edges on a - pixel boundary letting a column through in some renders. Drags show live on the canvas (every - finished render is shown, not only the newest one asked for), and a button held still no - longer rebuilds the preview and re-renders every frame. - Components at scale (`cargo xtask bench`, synthetic design system of about 300 - components used by 2,000 instances): edits of mains reach their copies directly instead of - rebuilding every instance (value, size and layer added/removed edits; other edits rebuild each - affected instance once, inner mains first), bound variables no longer touch unchanged layers on - every edit, auto layout re-flows only where sizes or places changed: recolouring or resizing a - main's layer used ~900 times 188/226 → 4/18 ms, adding a layer to it 430 → 21 ms, a label style - in all 72 button variants (1,800 copies) 450 → 57 ms, switching a variant, reset, detach, push - and paste ~450 → under 3 ms, accepting a library update 460 → 64 ms; the Assets panel with 2,000 - assets 3.4 → 1.3 ms a frame. Accessibility: the custom-painted - controls name themselves for screen readers (AccessKit on desktop: role, name, value and state - for tools, layer rows with their type, level, selection and hidden/locked state, fields, - dropdowns, tabs, menu items, and the canvas, which describes the selection); keyboard navigation: - F6 / Shift+F6 move between the toolbar, layers, canvas and properties, Tab / Shift+Tab move - within a region with a focus ring, Enter or Space activate, the arrows go along the toolbar and - through the layer rows, menus and tool flyouts opened from the keyboard take it and give it back, - Escape returns to the canvas (where Tab selects the next layer again instead of wandering into - the tab strip); reduced motion follows the system's setting or a preference; secondary and - accent text meet WCAG AA in both themes. - An idle window draws no frames: it redrew ~240 times a second - (the window title was re-sent every frame, and each viewport command asks for another), ~33% of - a core in a debug build, and now sits at 0 frames and ~0% CPU; the toast, the autosave - checkpoint and the presenter's "after delay" timers wake it at their deadline, the interaction - editor's curve preview plays when it shows, changes or is hovered, and a screenshot keeps frames - coming only until it arrives (`ui.inspect` `framesDrawn` and `repaintCauses` show what asked for - frames; headless tests hold it). *Left:* a real-world file corpus, Windows/Linux/browser - QA, runs with real screen readers (NVDA, Narrator, VoiceOver, Orca), the AccessKit tree in the - browser (eframe's web backend doesn't send it yet). Landed since: F2 rename from the keyboard, - Shift+F10 context menus at the focused layer row or the canvas selection, keyboard box selection - (F8). 27–44 h. - -## Inventory: what exists today - -Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count there was stale: 963 `#[test]` functions as of this re-measure. - -- **Architecture:** 15 crates with enforced layering (`cargo xtask layers`): geom, color, doc, - layout, text, pathops, plugin, collab, render, format, svg, engine, ui-egui (+ mcp, pdf). Every action - is a command (449 command ids); undo is unlimited via structural sharing (`Arc` node snapshots); gestures are - one undo step; `command.batch` runs several commands as one step. -- **Document model:** pages, frames, groups, sections, rectangles, ellipses (arcs/rings), polygons, - stars, lines/arrows, vector networks, text with styled runs, boolean groups, components, component - sets (variants), instances with per-layer overrides, slices; fills (solid, linear/radial/angular/ - diamond gradients, images with fill/fit/crop/tile + adjustments), multiple strokes with - inside/center/outside alignment, per-side weights, dashes, caps/arrowheads, joins; effects (drop - and inner shadow with spread that offsets the outline exactly: ellipses, stars, vectors, text; - inner shadows over the fills and under the children and strokes; drop shadows hidden behind what - the layer covers, translucent fills included, unless shown behind transparent areas; layer blur; background blur - that sees the whole backdrop, inside clipping frames, faded groups and masks too; both blurs - uniform or progressive (the radius ramps between two points, dragged on the canvas); noise in - mono, duo and multi colour with grain size, density and blend mode; texture with clip to shape; - glass with light angle and intensity, refraction, depth, dispersion, frost and splay; the same in - PNG, PDF and SVG exports, SVG embedding renders of noise grains, texture, progressive and - background blur and glass); corner radii with - smoothing; constraints; auto layout (horizontal, vertical, wrap, grid; hug/fill/fixed; min/max; - absolute children); layout grids; export settings; prototype interactions, flows and transitions; - paint/text/effect/grid styles; variables with collections, modes and aliases. -- **Renderer:** CPU (tiny-skia) scene renderer used for canvas, export and the web: isolated layers - for opacity/blend/effects, clip content, masks, booleans (linesweeper), text (harfrust shaping, - skrifa outlines, font fallback), aligned strokes with caching. -- **Engine:** pointer tools (move/marquee/deep select/resize/rotate/Alt-duplicate with smart-guide - snapping, frame/section/slice/shapes/line/arrow with Shift/Alt, text, pen, pencil, hand, - eyedropper), Cmd/Ctrl-click and Cmd/Ctrl-marquee into nested layers, Shift-marquee toggling, - Select layer (every layer under the click), measuring (Alt-hover distances from the selection to - layers, parents and ruler guides, Cmd+Alt for nested layers, from a guide being dragged to the layers of its frame, extension lines, one box inside the - other, overlaps, live distances to the neighbours while Alt-dragging, Shift+Alt for auto layout - padding and gaps; labels that never overlap at any zoom), text editing (caret, selection, words, - lines), auto layout reorder by dragging, drop into frames, align/distribute/tidy, z-order, - flip/rotate, the scale tool (panel, and canvas handles that scale everything about its anchor - point or from the centre with Alt, with a live factor, one undo step per drag), booleans, flatten, - outline stroke, components (create, instances, sync with overrides, reset, detach, push changes, - variants, properties, slots), styles and variables, prototype commands, find/replace, batch rename, - copy/paste (internal + SVG/text), file new/open/save/import/export (native `.figcraft`, SVG - in/out, PNG/JPG/WebP), version snapshots. -- **UI (egui, swappable):** the incumbent's 2026 layout: tab strip that is also the window's title - bar (frameless on Windows/Linux with drawn window buttons, drag, double-click maximise and edge - resizing; macOS window buttons inset into it), icon rail, file header, pages and - layers tree (rename, visibility/lock, drag to reorder/reparent), Assets panel, floating bottom - toolbar with flyouts and mode switcher, Design / Prototype / Inspect panels (Dev Mode: properties with their styles and variables, annotations - with pinned properties and categories drawn as coloured callouts on the canvas (filtered by category), - a ready-for-dev list, a focus view on one frame with previous / next, Compare changes since a frame - was marked ready (before / after side by side or overlaid, property-level diffs), CSS/HTML/React/SwiftUI/Compose - code where components take their properties as parameters and instances are calls, components - connected to their code components showing the real snippet, read-only plugins), colour picker - (HSB square, hue/alpha, hex, gradient stops, image fills), main menu generated from the catalog, - context menu, Actions palette (⌘K), keyboard shortcuts from one keymap per platform (a tabbed, - searchable Keyboard shortcuts panel), keyboard navigation (F6 between regions, Tab within them, a - focus ring) and screen-reader names for the custom controls (AccessKit), presentation player with - transitions, light and dark themes, home screen, About/Preferences/shortcuts/variables dialogs. -- **Automation:** desktop control channel (`--control `: execute commands, inspect document/UI, - synthetic pointer/keys/typing, menu invocation, screenshots, renders). -- **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions. - -## Revision history - -| Date | Change | Summary | -|---|---|---| -| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | -| 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | -| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | -| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | -| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | -| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | -| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. | -| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. | -| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | -| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | -| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | -| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials: discoverability ×0.92 → ×0.94 (templates and first-run tips exist; no Community files, no tour, no user evidence), 77.8% × 0.92 × 0.94 × 0.88 = 59.2%, ~57% → ~59%; essentials hours ~60–110 h → ~55–100 h (the onboarding and templates item is done); menu breadth stays 211/211 (the two new menu items are FigCraft's own and not counted); every other number unchanged | -| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains | -| 2026-10-12 | minor | Text metrics, first measured fidelity data (gap 3): our example documents, exported and re-saved by other software, were read back and every text layer's stored box, line heights, baselines and glyph positions compared with ours. Three rules explained every row and were applied: a line's height is rounded to whole pixels (halves up; Auto, % and px alike; the baseline keeps its fraction inside the rounded line); glyph advances and kerning are each rounded to 1/1024 em before scaling by the font size; letter spacing is not added after a line's last visible glyph. Before: 24 of 131 text boxes of the design-system example matched (118 in width, 24 in height); after: 131 of 131, and all 230 self-sizing text boxes across the 7 documents (Inter only, 11–120 px, letter spacing −6% to 8%, one wrapped paragraph). Glyph positions within 0.001 px: 223 of 1,832 → 1,781; the rest are kerning pairs at weights where the installed font files differ slightly, at most 0.05 px. Frames and other layers matching in size and position: 115 of 231 → 208; the other 23 hang on one grid frame whose row positions came back as written. Not measured: other font families, fractional px line heights, centred or right-aligned text with letter spacing, lists, mixed sizes on a line. Wording only; no number moves | -| 2026-10-12 | minor | `.fig` export (gap 1): written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Wording only; no number moves | -| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | -| 2026-10-12 | minor | Experimental `.fig` export (gap 1): the whole document written from the same public format notes as the import, verified only by round trips through our own importer. Import/export breadth 84% → 86%, ready 57% → 58%; file formats 59% → 60%; file exchange with Figma users stays ×0.85 (nothing shows another app opens the files); mainstream hours ~130–244 h → ~120–229 h (`.fig` export 15–30 h → checking it elsewhere 5–15 h); feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta and full parity hours unchanged at this rounding | -| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding | -| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h | -| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h | -| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h | -| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h | -| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35% (drafts, not reviewed: graded low), 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1 by the dimension table); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h (Japanese and Korean leave the beta list; their native reviews need people) | -| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h | -| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%; 52.6 → 52.9); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h (Figma's languages left on the way to beta: Japanese and Korean, 7–12 h) | -| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page, latecomers included, until they move their own. Both are ephemeral presence the host validates and rate-limits, passed only to clients that read them. Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; the Collaboration dimension's hours 55–110 → 52–104 h (the dimension hours still add up to the total); feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h | -| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes: several layers dropped together in the layers panel land together (layers from apart split around the drop point); Collapse layers keeps the selection's rows open (it closed everything); the eye and padlock stay under the pointer (they vanished as it reached them, so clicks could miss) and dragging across them sets several layers in one undo step; several layers selected open a Rename layers dialog (match as text or a regular expression, Rename to, current name and number buttons, a start value, preview) instead of renaming the top one; `object.batchRename` counts `$n` up and `$N` down (they were swapped), pads with `$nnn`, takes `start`, regular expressions with `$1`…`$9`, `` $` `` / `$'` and codes in the replacement; pages are dragged to reorder, a blank page named with a hyphen is a divider (never opened, holds no layers), duplicates are named "Copy of …", the last page that isn't a divider can't be deleted; the Assets panel files components by page, frame and slash names in folders that fold away (`component.list` gives each `folder`); a leading `.` or `_` now keeps a component out of the library even after it was published, and removing it brings the component back. Pinned where the documentation is silent: Alt-click on an arrow, numbering in stacking order, hidden layers and locked frames taking drops, locking deselects. Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | -| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | -| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | -| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | -| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | -| 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) | -| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h | -| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, find, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h; mainstream weekly areas 71.0% → 71.1%; percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | -| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h; feature breadth (~80%), ready for real work (~49%), core editor (~68%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~320–615 h → ~319–614 h | -| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53%; Dev Mode 90%/64% → 93%/70%; file formats 55% → 56%; mainstream weekly areas 68.1% → 68.4%; feature breadth (~80%), ready for real work (~49%), essentials (~57%), mainstream (~46%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | -| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation (10 shape tests, 6 vector tests and 3 handle tests: about 70 table rows plus stepwise checks) and its fixes: rounded vector points are drawn and exported (the radius was stored only; a vector's corner radius now rounds every point); a boolean group looks like its top layer, a subtraction like the bottom one (always the bottom); Alt draws a line from its centre; stars keep to 3–60 points; delete and heal fits the curve through the deleted point; Shift-clicking a selected point at a corner of the points' box takes it out (it scaled); outline stroke turns the stroke's colour style into the fill's and keeps opacity and effects; the miter field shows the miter angle (it showed the ratio under that name); individual corners only for rectangles and frames; Option/Alt+Shift+F flattens; edit-mode keys reach the edit tools through MCP `key` (Delete removed the layer). Added: corner radius and star ratio handles on the canvas, Count and Ratio fields for polygons and stars, cut tool drags that divide a shape into layers, paint bucket drags across areas. Shapes & vector 97%/68% → 98%/72%, 1–2 h → 2–4 h (the suite found gaps the estimate didn't count: corner smoothing beyond rectangles, a join or cap per point). Totals recomputed by the doc's method, which earlier rows had left stale: the area table's weighted figures are breadth ~82% (core ~93%) and ready ~61% (core ~69%), not the ~80%/~58% (~91%/~68%) carried since #130; Features (depth) follows the weighted ready, 57% → 61%; ready for real work ~49% → ~51%; mainstream ~46% → ~48% (weekly areas 67.8% → 69.4%); the selection suite's area 2 hours (−3/−5) hadn't reached the totals, and neither it nor the text suite had reached the Features (depth) row: Features 60–115 h → 56–109 h, full parity ~321–618 h → ~319–615 h (−3/−5 for area 2, +1/+2 for area 4) | -| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation (14 paint and 9 effects tests with table rows for fills, opacity, every blend mode by its formula, pass-through and Normal containers, gradient handles and stops, image scale modes and adjustments, colour models, the colour profile, shadows, blurs, effect order and exports; 3 command tests) and its fixes: groups and frames set to Normal blend their children among themselves first, and clipping frames no longer isolate their children (blend modes inside a clipping frame met only the frame's children, not its fill or what is behind it) — canvas, SVG (`isolation`; clips put on what blends, since a clipped group isolates) and PDF (isolated groups); plus darker is drawn (it was multiply) on the canvas and in PNG exports; drop shadows hide behind what the layer covers, translucent fills included, unless shown behind transparent areas, in SVG too (it never cut them out) and the SVG filter region reaches as far as the effects; Fill images cover the box to its edges (upscaled ones faded out at the edge); "Normal" names Normal, not pass-through, in commands and the blend menu (the menu's Normal set pass-through). Added: a blend menu for each paint in the colour picker. Paint 66% → 70%, 4–7 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h | -| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h | -| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h | -| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h | -| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) | -| 2026-10-11 | minor | Embedded image colour profiles: placed and opened images are converted once, when decoded, from their own colour space into the file's (sRGB or Display P3): ICC profiles in PNG (`iCCP`), JPG (APP2), WebP and TIFF, matrix/curve and table profiles in RGB or grey (moxcms, already built for the `image` crate), and a PNG's `sRGB`, `gAMA` and `cHRM` chunks; images without a profile, or with one that can't be used (CMYK, damaged, over 4 MB), are taken as sRGB as before. The original file is kept (PDF exports still embed it); pixel exports carry the converted pixels with the file's profile. The image fill's details show the source profile (Unknown — treated as sRGB when there's none) and `image.place` reports it as `imageProfile`. Paint breadth 87% → 90%, ready 66% → 67%, 4–7 h → 3–6 h; feature breadth (~80%), ready for real work (~49%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h | -| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | -| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | -| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Import/export breadth 65% → 80%, ready 35% → 50%, 30–60 h → 8–15 h; file formats 40% → 55%, 30–60 h → 8–15 h; feature breadth ~79% → ~80% (core ~90% → ~91%), areas ready ~57% → ~58% (core ~67% → ~68%); ready for real work ~47% → ~49%; essentials ~53% → ~57% (opening files people send ×0.80 → ×0.88); mainstream ~43% → ~46% (weekly areas 66.8% → 67.8%, file exchange ×0.80 → ×0.85); beta ~195–355 h → ~173–310 h; full parity ~345–666 h → ~322–620 h | -| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | -| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | -| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | -| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | -| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | -| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | -| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~358–687 h | -| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | -| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | -| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | -| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | -| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | -| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for full (~400–750 h), mainstream (~180–330 h) and essentials (~85–160 h). Full number confirmed as an additive weighted sum; unchanged | -| 2026-10-10 | minor | Added the essentials user number (~53%: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send) | -| 2026-10-10 | minor | Added the mainstream practitioner number (~43%: weekly areas 66.5% × 0.9 interaction × 0.9 stability × 0.8 file exchange) and user-evidence count (none). Checked that ready for real work (~46%) is built from the written dimension weights and scores; unchanged | -| 2026-10-10 | minor | Stage checked against the alpha gate: stays alpha | -| 2026-10-10 | major | Created from the ROADMAP's parity rubric, honest assessment and area narrative. Full re-measure against Figma desktop 126.10.7: added multiplayer, plugins, team libraries, Figma Draw extras and AI as weighted areas; split breadth from ready-for-real-work; core depth re-graded ~79% → ~67%; hours calibrated from git history; full parity 115–195 h → 420–790 h | +# FigCraft parity with Figma + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) + +The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) +summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is +[`parity-checklist.md`](parity-checklist.md). Deeper checklists: +[`ui-parity.md`](ui-parity.md), [`file-format-parity.md`](file-format-parity.md), +[`hardware-parity.md`](hardware-parity.md), [`localization-parity.md`](localization-parity.md), +[`auto-layout-parity.md`](auto-layout-parity.md), [`components-parity.md`](components-parity.md), +[`prototyping-parity.md`](prototyping-parity.md), [`dev-mode-parity.md`](dev-mode-parity.md), +[`collaboration-parity.md`](collaboration-parity.md). + +## Headline + +| Number | Value | Kind | +|---|---|---| +| Menu/command breadth | **100%** (211 / 211 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-12) | +| Feature breadth (does each Figma Design feature exist, weighted) | **~85%** (core editor areas 1–16: ~94%) | estimated from the area table below | +| Ready for real work | **~53%** | estimated (dimension table below: written weights × written per-dimension scores) | +| Essentials user | **~59%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.94 × 0.88) | +| Mainstream practitioner | **~50%** | estimated ([below](#mainstream-practitioner): weekly areas 72.7% × discounts 0.9 × 0.9 × 0.85) | +| Remaining to beta | **~155–280 h** one Opus 5.5 agent (~32–64 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated | +| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | + +The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were +self-graded against a 16-area rubric that left out, by design, multiplayer, plugins, cloud +libraries and AI features, and that counted no file-format blocker. Figma users rely on all of +those every day, so this re-measure weights them in. The core-editor depth figure drops from ~79% +to ~67% because nothing has yet been checked against real Figma files or side by side tool by +tool; presence was being graded as behaviour in places. + +## Readiness by audience + +| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | +|---|---|---|---| +| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the experimental `.fig` export opens elsewhere (5–15) | +| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | + +Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour +per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see +[how the hours are calibrated](#how-the-hours-are-calibrated)). Each audience's work is a subset of +the next. About 70–80% parallelizes across 4–6 agents on disjoint crates (roughly ÷4 in +wall-clock); the `.fig` reader, the multiplayer core and a plugin runtime each need one owner, and +native-speaker review, real-file licensing and screen-reader QA need people. + +The full number is an additive weighted sum over the dimension table; mainstream and essentials use +multiplicative discounts, as the standard says. + +## What was measured, and how + +- **Target and version.** Figma desktop for macOS, `CFBundleShortVersionString` 126.10.7, + installed at `/Applications/Figma.app`. FigCraft is measured as of `origin/main` d452da3 + (2026-10-10, PR #99). +- **The installed app, names and public listings only.** FigCraft is clean-room + ([`AGENTS.md`](../AGENTS.md)): nothing inside the bundle's code or web assets was read. From + `Info.plist`: one document type, `com.figma.document`, extensions `fig`, `jam`, `deck` + (MIME `application/x-figma`), and the `figma://` URL scheme. The `.lproj` folders are + Electron/Chromium locale packs (60+), not Figma's UI languages; the UI languages come from + Figma's help center (see [`localization-parity.md`](localization-parity.md)). The bundle ships + GPU, Plugin and Renderer helper processes (GPU rendering; a sandboxed plugin runtime). +- **The menu catalog.** `crates/engine/src/catalog.rs` holds Figma's main menu tree (202 items in + File, Edit, View, Object, Text, Arrange, Vector, Plugins, Preferences, Libraries, Help), written + from public documentation and black-box observation (local notes in the gitignored + `plan/figma/03-menu-tree.md`). It leaves out the Widgets menu and dynamic plugin lists, so the + 100% overstates breadth against the full menu a little. +- **From source** (no build; 2026-10-10): 449 command ids registered with `spec!`, 963 `#[test]` + functions in 177 files, ~108,800 lines of Rust in 13 crates + 3 apps + xtask (before #101–#103, which added the `plugin` and `collab` crates and ~7k lines), 140 key bindings + and 13 gesture rows in `figcraft_engine::keymap`, 21 files in the SVG import corpus, 0 + translated UI strings (no localization layer exists). +- **Figma's feature set**: help center, release notes and Config 2025 announcements (Figma Draw, + grid auto layout, slots, glass/noise/texture effects, progressive blur), known to the assessor up + to mid-2026, plus web checks on 2026-10-10 (supported image/video types, UI languages, pen + pressure in Figma Draw). +- **Side by side.** Look & feel was compared with Figma UI3 on the owner's Mac (screenshots kept + local, never committed). No real Figma file has been opened in FigCraft (no `.fig` reader), and + no rendered frame has been compared pixel for pixel against Figma's own export. + +## Weights + +Weights are the share of a typical product designer's working time in Figma Design, written down +so they can be argued with. Areas 1–16 keep the original rubric's weights (they sum to 94, not the 100 the rubric claimed); +areas 17–21 are added (sum 19) because Figma users depend on them. Percentages below divide by the +total, 113. +Out of scope as separate products: FigJam, Figma Slides, Sites, Make, Buzz, Weave (the `.jam` and +`.deck` document kinds belong to them). + +## By feature area + +Breadth: does it exist. Ready: depth, correctness and fidelity for real projects. Hours: one Opus +5.5 agent, sequential, tests included. + +| # | Area | Weight | Breadth | Ready | Remaining | Hours | +|---|---|---|---|---|---|---| +| 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | +| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | +| 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | +| 4 | Shapes & vector | 8 | 99% | 73% | Outline-stroke compositing; corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (outline stroke rings covered by `outline_stroke`; behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | +| 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | +| 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | +| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | +| 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | +| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | +| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | +| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | +| 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | +| 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | +| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | +| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | +| 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | +| 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | +| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | +| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | +| 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | +| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | +| | **Weighted (÷113)** | 113 | **~85%** | **~63%** | | **117–255** | +| | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 32–74 | + +## By dimension (ready for real work) + +| Dimension | Weight | Ready | Hours | Evidence | Doc | +|---|---|---|---|---|---| +| Features (depth) | 35 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file | +| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | +| File formats | 12 | 60% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | +| Collaboration | 10 | 13% | 52–104 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | +| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | +| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | +| Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | +| Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese (none reviewed by a native speaker; every draft covers all current strings), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | +| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | +| Ecosystem/plugins | 3 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | +| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | +| **Ready for real work** | 100 | **~53%** | **~262–519** | | | + +Dimension hours add up to the full-parity total without double counting: the feature-area rows +for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are +counted under their dimensions. + +## Essentials user + +Someone who uses only the core of Figma: a casual or beginner user making a few screens, a social +graphic or a simple mockup. Same method, with the core features a casual user touches. + +**1. Core features, weights and depth** (depth from the area table and checklists above): + +| Feature | Weight | Depth | Notes | +|---|---|---|---| +| Create, open, save and reopen a file; pages | 10 | 85% | home screen, `.figcraft` round trips, Open recent | +| Pan and zoom | 10 | 80% | wheel, pinch, Space-drag, zoom shortcuts | +| Select, move, resize, rotate, align, group, duplicate | 15 | 75% | not yet compared tool by tool | +| Frames with device presets | 8 | 80% | | +| Rectangle, ellipse, line, arrow, corner radius | 8 | 80% | | +| Text: type, font, size, weight, colour, alignment | 12 | 70% | no spell check | +| Colour picker, fills and strokes; place and crop images | 10 | 72% | no HEIC images | +| Layers panel: rename, hide, lock, reorder | 7 | 80% | | +| Basic auto layout (add, gap, padding) | 6 | 70% | | +| Undo / redo | 5 | 90% | unlimited, one step per gesture | +| Export PNG, JPG, SVG, PDF | 6 | 85% | | +| Copy and paste, incl. images from other apps | 3 | 75% | | +| **Weighted** | 100 | **77.8%** | | + +Left out: components and variables beyond using one, prototyping, Dev Mode, advanced vector and +type, and everything the mainstream number leaves out. + +**2. Discounts a casual user actually hits:** + +| Discount | Factor | Evidence | +|---|---|---| +| Launch and stability | ×0.92 | signed and notarized macOS build, installers for every platform (v0.2.0), never-crash rules and recovery copies; no field record yet | +| Discoverability and UI clarity | ×0.94 | the UI3 layout Figma users know, Actions palette, keyboard shortcuts panel; a first run opens on the home screen with a Getting started card (six tips with their shortcuts, closable, back from the Help menu) and eight starter templates to open (also File ▸ New from template…); no Community files, no guided tour, and nobody new to the app has been watched using it | +| Opening files people send them | ×0.88 | what people send a Figma user is a Figma link or a `.fig`; a `.fig` opens (best effort, not yet checked on real files), a link doesn't. Images and SVG do | + +77.8% × 0.92 × 0.94 × 0.88 = **~59%** (estimated). User evidence: none yet (see above). + +## Mainstream practitioner + +The same question for the typical professional Figma Design user: a product designer who designs +screens, maintains or uses a design system, prototypes and hands off to developers every week. +Computed per craftrules `standards/progress-docs.md`. + +**1. Areas used weekly, with weights.** Feature areas 1–16 of the table above, with their weights +(sum 94). Left out as the standard says: plugins and widgets (18), AI (21), team and organization +libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a product designer), +localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration +and is left out as an area, but its effect on working with Figma users is counted in discount 3. + +**2. Average depth** ("Ready" column, weighted): **72.7%**. + +**3. Discounts for what still stops real work:** + +| Discount | Factor | Evidence | +|---|---|---| +| Interaction fidelity | ×0.90 | UI3 look compared side by side (~65% by the builders); no tool-by-tool comparison of cursors, modifier keys while dragging, snapping thresholds or popovers yet; Ctrl to suspend snapping on macOS missing ([ui-parity](ui-parity.md)) | +| Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way | +| File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) and writes an experimental one (written files open and draw in other software; effects, images and prototypes unchecked there); incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) | + +72.7% × 0.90 × 0.90 × 0.85 = **~50%** (estimated; 50.03%, rounded down). + +**4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments +from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is +private, 1 star, PRs only from maintainers). No outside evidence either way; nothing raises the +number. + +**Why it is lower than ready for real work (~53%), unusually.** The full number averages +dimensions additively, so the missing `.fig` exchange costs only the file-format dimension's 12% +weight, and FigCraft earns points the mainstream number doesn't count (platforms 75%: Linux, +FreeBSD and the web, where Figma has no desktop app). The mainstream number applies file exchange +as a multiplier on everything, because a Figma practitioner's whole working week is shared through +Figma files. Both are computed from written weights; neither was moved to match the other. The +mainstream number rises fastest with `.fig` import and the experimental `.fig` export checked on real files (the ×0.85 toward ×0.95: ~53%) and the +interaction pass. + +## How the hours are calibrated + +From this repository's own history (`git log`, `gh pr list`): + +- The whole app was built in ~25 wall-clock hours: first commit 2026-10-09 15:38 UTC, PR #99 + merged 2026-10-10 16:53 UTC; ~49k lines on day 1 before PRs, then 99 PRs adding ~81k lines. The + ROADMAP of that period records 4–6 agents in parallel, so ~110–150 agent-hours in all: about + **1 agent-hour per ~1,000 added lines**, and a typical feature PR (1,000–2,500 lines, e.g. + #84 arc handles, #92 PDF print options, #96 accessibility) took **1–2.5 agent-hours**. +- Between the ROADMAP revisions of 2026-10-09 22:19 UTC and 2026-10-10 16:53 UTC (~18.5 h wall, + 78 PRs, ~75–110 agent-hours) the self-graded depth went 58% → 79%: **~4–5 agent-hours per + self-graded point**. This re-measure grades about 0.6 points for each of those, which puts an + honest point nearer 7 agent-hours in the polish tail; new subsystems (a `.fig` reader, + multiplayer, a plugin runtime) are estimated bottom-up instead, since nothing comparable has been + built here yet. +- Parallelism: most of the work splits across 4–6 agents on disjoint crates (the history shows it + working); multiplayer, `.fig` import and the plugin runtime each need one owner for their core. + Needs a human: native-speaker review for every language, the plugin-runtime and AI-model + decisions, real-world Figma files to test with (licensing), Windows/Linux/screen-reader QA. + +## Area detail: what has landed and what is left + +Carried over from the ROADMAP's "Where we're lacking" list (2026-10-10, before this re-measure). +Hours in this narrative are the builders' estimates; the tables above supersede them. + +1. **Vector edit mode**: *landed:* edit-mode toolbar (move, pen, bend, paint bucket, lasso, cut), + pen that extends, branches and closes paths (vector networks), paint bucket fill/unfill of any + closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and + length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and + merge, delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder + (⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept), + variable width strokes (width profile presets with previews in the stroke settings, the Variable + width tool ⇧W: drag the path to add a width point, slide points, drag their handles, Delete; + smooth interpolation per subpath, dashes, caps and joins, canvas/PNG/SVG/PDF and outline stroke), + a transform box around several selected points (corners and edges scale them, Shift keeps + proportions, Alt from the centre; just outside a corner rotates, Shift in 15° steps; drag inside + to move; the Design panel's X/Y/W/H and flip/rotate act on the points), point snapping while + dragging points or handles and with both pens (to the points and segment midpoints of this and + other vectors, the usual layer targets and the pixel grid, with guides and a marker on the point + landed on; Ctrl/Cmd turns it off), arc handles on the canvas (sweep, start and ratio with a + live readout, Shift snapping, closing at the start, the other part from the gap; rotated and + flipped ellipses, any zoom), Design panel Arc fields (Start, Sweep, Ratio; bindable), live + boolean editing (operands selected by double-click or Cmd/Ctrl-click; moving, resizing or + editing an operand's points redraws the result during the drag with every operand outlined; + per-operand operations from the boolean controls; only the operands from the edited one up are + recombined; clicks hit a boolean group where its result is), the shape builder on strokes and open paths (strokes as their outlined areas), and + one-sided variable widths (Alt-drag a width handle; canvas, exports and outline stroke), and + filled regions with the Move tool (the area under the pointer hatched, clear of points and + segments, its spacing by octave folding fitted to measurements at several zoom levels and fixed + to the shape; click, Shift-click and drag to select and move areas with their points). + *Left:* vector QA with real files. 1–2 h. +2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case, + truncation/max lines, font picker with previews and recents, word/line selection, IME commit, + inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the + document only gets the committed text), variable fonts (axes and named instances read from the + font, named instances as styles, Type settings ▸ Variable sliders, per-range axis values applied + to shaping, outlines and PDF), range-aware Typography panel with "Mixed". Range fills in the Fill + section. Right-to-left and mixed-direction text (Unicode bidi: Arabic shaping, mirrored brackets, + caret, clicks and selection follow the runs; arrow keys move visually). Right-to-left and + mixed-direction names in the panels (layers, pages, Assets, tabs, the file header, canvas frame + titles: visual order, joined Arabic, the ellipsis at a right-to-left name's left end; rename fields + whose caret, clicks, drags and arrow keys follow the shown text; system fonts for scripts the UI + fonts lack). The text style browser in the Typography section (list and grid views in each + style's 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, "Mixed" when the + chars use several styles); text styles link chars, not just layers (editing what a style sets + unlinks the chars edited). Live previews of a hovered named instance or font style and of a + dragged axis slider (one undo step on release, Escape cancels). Visual QA: list markers keep the + paragraph's font, renames start with the name selected and centred, Max lines shows while + truncating. Right-to-left paragraphs mirror lists and indents: bullets and numbers (".1") on + the right, wrapped lines hanging from the right, the paragraph indent on the right, trailing + spaces hanging outside the line (canvas, PNG, PDF and SVG). A behaviour suite from the public + documentation (`crates/text/src/behaviour_tests.rs`, `crates/engine/src/text_behaviour_tests.rs`): + Auto / % / px line heights with the leading split above and below every line, letter spacing in + % and px, paragraph spacing and indent (start-aligned text only), resizing and truncation, + decorations from the font's metrics; CJK line breaking (between ideographs, kana and Hangul, no + break inside Latin words, kinsoku for closing and opening punctuation, small kana and the + prolonged sound mark); a style's metrics from its own font, not a fallback; carets, Delete and + Backspace by grapheme cluster; double-click and word moves by script run; IME composition with + CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`, + `crates/engine/src/text_tail_tests.rs`): lists per paragraph, nested up to 8 levels with Tab and + Shift+Tab (1. a. i. and • ◦ ▪ per level, counts restarting after plain paragraphs), Enter on an + empty item and Backspace at an item's start stepping out, `- `/`* `/`1. ` starting a list; list + spacing between items; hanging lists and hanging punctuation; decoration style (solid, dotted, + wavy), thickness, underline offset, skip ink and colour, with inspect, CSS and the REST import. + *Left:* visual QA; hanging punctuation in right-to-left paragraphs and centred text, list + numbering that continues across a plain paragraph and a list's own start number (documentation + silent); decoration colour in SVG exports (drawn with the text's fill, like run fills); Korean + keeps breaking by syllable and Han runs aren't split by a dictionary (documentation silent). 0.5 h. +3. **Auto layout and sections depth**: *landed:* on-canvas padding/gap handles (also wrapped-row + and grid column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, + hugging text wraps at its max), ignore auto layout (absolute position) with constraints, text + baseline alignment, grid tracks (fixed/hug/`fr`), grid cells and spans, wrap row gap UI, + wrap-aware drag-reorder (by row) and dragging into a grid cell, nested hug/fill settling in one + pass (wrapping fill text, three levels deep, grid rows), min/max redistribution, coherent + Fill/Hug rules, drag across nested auto layout frames (deepest one wins, insertion line, out to + the canvas or a plain frame). Arrow keys move auto layout children one place (along the flow + past hidden and absolute siblings, Up/Down between wrapped rows, to the neighbouring grid cell + (automatically placed children swap, anchored ones take or swap cells), and at the end of a + flow or across a row's axis into the neighbouring nested auto layout frame when the frame + around flows that way; one undo step, none when nothing can move). Visual QA of nested + hug/fill/fixed/min/max/wrap/grid samples against the documented rules (Fill turns a hugging + parent Fixed, min/max redistribution, wrap inside fill, absolute children following + constraints as hug frames grow). Sections: hide contents (name, outline and "Contents hidden" + on the canvas; contents skipped by rendering, hit testing and labels; dimmed in the layers + panel; Design panel checkbox and context menu), Wrap in new section, frames in sections get + canvas labels and are top-level frames (flows start there, navigation, Export frames to PDF), + "Navigate to" a section shows its frame shown last, else its first; ready for dev turns + "Changed" when the contents are edited, resolved with a reason; Compare changes shows what a + changed frame or section looked like when it was marked next to how it is now, with the + changed layers and their property changes. *Left:* whether `fr` rows of a fixed-height grid + should make room for fixed-size children (now `minmax(0, 1fr)`-like). 1–2 h. +4. **Components depth**: *landed:* component properties (edit/delete/bind) with the properties + panel, add variant, instance swap UI, per-layer overrides with reset, restore main, instance + structure protection; Assets panel with live thumbnails, search and drag-to-canvas (drops into + the frame or auto layout slot under the pointer); exposed nested instances (their properties + show on the main's instances); component descriptions (edited on the main, shown on + instances); local libraries (publish a file's components, sets and styles with per-asset + versions, enable library files, place library components and apply library styles as + read-only linked copies, "Library updates available" with overrides kept on update); slots + (a frame of a main made a slot property with preferred components; instances fill it with any + content of their own, kept across main updates, variant switches, copy/paste and detach, with + the main's default content until edited; reset slot; slot badges in the layers panel and on the + canvas; fill menu in the properties panel; drops from the Assets panel and the canvas land in + slots); nested overrides kept when the main moves or swaps a nested instance, and on nested + instances copied out of an instance (re-linked to their own main); canvas drags can no longer + pull layers out of an instance; library variables (publishing includes the file's variable + collections with their modes, aliases and scopes, versioned per variable and collection, without + variables hidden from publishing; files using the library see its collections read-only under + the library's name in the Variables editor and the variable pickers; binding, aliasing or setting + a library collection's mode on a frame copies the variables in as read-only linked copies, so the + file renders without the library; library components keep their variable bindings and modes; + "Library updates available" covers changed variables and bound layers follow). Explicit variable + modes in the Design panel: a frame's mode per collection, Auto (inherited) or its own, with + enabled libraries' collections listed under the library's name (picking a mode copies the + collection in). Instance boolean and text properties bound to boolean and string variables + (following modes, renames, reset and detach); layout grid settings in the Design panel whose + count, size, margin/offset and gutter bind to number variables; fills, strokes, effects and + layout grids reordered by dragging their rows, their variable bindings following. Components at + scale, QA'd with a synthetic design system (about 300 components: buttons, inputs, cards, navs + and badges as sets of size × state × type × theme variants, boolean/text/instance swap/slot + properties, instances nested four deep, text and paint styles, light/dark variables bound across + it) used 2,000 times with overrides at every level: main edits keep every override and match a + full rebuild exactly; values from variables (a theme switch, a bound corner radius) are no longer + recorded as overrides; detaching a variable on an instance layer is an override that survives main + edits; exposed nested instances stay exposed and swapped nested instances keep their layer names + through main edits; layers a swapped nested instance's main adds keep their ids and overrides. + Instance swap properties have preferred values (offered first). Alt-drop from the Assets panel + onto an instance swaps it (same-set instance preferred, highlighted while dragging); instances + pasted into other files follow their mains (library copies shared, published components used + from their library, other mains left behind as a restorable missing main). The Assets panel lays + out only the tiles in view and renders thumbnails as they show (cached, re-rendered only for + changed components). A behaviour suite from the public documentation checks overrides (what an + instance takes, what it refuses: geometry, constraints and resizing of its layers, its auto + layout direction), variant switches (overrides stay where both variants agreed; text layers keep + theirs), swaps (by layer name; from the Assets panel only text overrides), the top-left default + variant, detach, reset and push. *Left:* QA with real-world (not synthetic) libraries. 1 h. +5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor, + flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter. + Custom bezier and custom spring easings with editor fields; device frames (phone, tablet, + laptop, desktop bodies drawn in code) around the presented frame; overlays animate out with + their opening transition reversed; several selected layers edit their interactions together + (or replace mixed ones); scrolling inside device frames (the frame sized to the device's screen, + clipped or scrolling by wheel and drag, fixed layers pinned to the screen by their constraints, + nested "Scroll to", preserve scroll position); a resizable preview pane over the canvas that + plays the selected frame and follows edits live; device rotation (phones and tablets turn to + landscape: saved in the file, imported from REST files, drawn turned in the presenter and the + pane); presentation options (fit, fit width, fill, 100%, with Z to cycle them and R to + restart, the device frame shown or hidden, kept between presentations; what overflows the + window pans); scroll momentum (drags fling and slow down the same at any frame rate, the wheel + coasts, a drag past the ends stretches and springs back); responsive presentation (the presented + frame resized to the device's screen, or the window without a device, its constraints and auto + layout responding live; in the Options menu and the Prototype panel, saved in the file); device + colours (each model's colour variants, bodies drawn in code) and custom device sizes (a bare + screen with width and height fields, imported from REST files); flow descriptions in the + presenter's flow picker and under each flow; conditional actions (if / else if / else over + variable expressions) and set-variable-mode actions run in the presenter (from REST files or + commands). Conditionals are built in the interaction editor: an interaction's actions are a list + (add, reorder by dragging a row's grip, also inside conditional blocks, or from its menu, remove, + open one to edit it; one undo step per move), "Add action ▸ Conditional" adds an if whose + condition comes from a builder (a variable from the variable picker, an operator, a value or + another variable) or is typed as an expression (`count >= 3 and not done`, checked when entered, + what's wrong shown under the field), each block with its own actions (the same editors, + nested), plus "Else if" and "Else"; set variable values can be expressions (`count + 1`, + `"Hi " + name`); expressions read a variable's value in a given mode of its collection + (`accent[Dark]`, also from REST files' variable mode lookups) and the mode a collection is in + where the interaction runs (`mode(Theme) == "Dark"`, following set variable mode actions); every + edit is an undo step (`proto.action.*`, `proto.conditional.*`). Hotspot hints: a click where + nothing reacts flashes the screen's hotspots (an Options menu toggle, on by default); the + presenter's Inspect panel shows the hovered layer's properties. Overlays opened through commands + close on a click outside unless told not to, as in the editor. A behaviour suite written from + the public documentation covers triggers, actions, overlays, animations, easing, scrolling and + interactive components ([prototyping-parity](prototyping-parity.md)); its fixes: drag-driven + animations go their way and follow the pointer, key combinations, overlay timers from when they + open, instances play their variant's interactions, physical springs, smart animate overshoot, + fixed layers on top; scroll to takes an offset. *Left:* QA against larger real-world + prototypes. 1–2 h. +6. **Interop**: *landed:* image crop handles; REST API file JSON import (`file.open` / `file.import` + / CLI: pages, layer tree, geometry, paints, strokes, effects, text runs, vectors from geometry + paths, auto layout, constraints, grids, components, sets, instances with overrides, component + properties, styles; image fills from an `images` folder or placeholders; warnings for the rest), + now with prototyping (interactions with their triggers, actions, transitions and easing, flows, + scrolling, the prototype device; conditional actions (if / else if / else over expressions), + set variable with variable references and expressions, set variable mode, all run by the + presenter) and variables (the local variables JSON passed along, next to the file or embedded: + collections, modes, aliases, scopes, layer bindings and explicit modes). + Export panel: a row per export setting (typed or preset sizes like `2x` / `512w` / `512h`, PNG, + JPG, WebP, SVG and PDF, suffix, advanced options: contents only, include bounding box, and for + SVG outline text, id attributes and simplified strokes), a preview, and "Export N layers" (`file.exportLayers`) in the + panel and the Export dialog. Paste from other apps: images, SVG and text (the newest clipboard + content wins over our own copy; pasted SVG lands centred in the view). SVG import is editable and + checked against a corpus of synthetic files (expected pixels worked out by hand, layer structure, + export and re-import): groups named from their ids with transforms baked into the layers; + rectangles (corner radius), ellipses and lines kept as those shapes; gradients with exact + handles, `reflect`/`repeat` and focal points; patterns as image fills; clip paths and masks as + vector, luminance and alpha masks; shadow and blur filter chains as drop shadows, inner shadows + and layer blurs; blend modes; text as text layers with runs, anchors and stacked lines; images + and nested SVG images; import warnings for what's left out; linked files never read; hostile + input fuzzed. SVG export keeps alpha and luminance masks. PDF export: one page per frame in points (Export frames to + PDF, the Export panel, `figcraft-cli export -o x.pdf`), vectors, gradients and strokes as + vectors, real selectable text in subset fonts with clickable hyperlinks, images embedded once, + opacity and blend modes, clip content, alpha/luminance/vector masks, shadows and layer and + background blurs as images; checked by rendering the PDFs back against PNG exports. PDF print + options in the Export panel (per PDF export setting), the Export dialog, `file.export`, + `file.exportLayers`, `file.exportFramesPdf` and `figcraft-cli export`: a bleed showing what lies + past the frame (its fills reach out, its children are clipped at the bleed), crop and bleed + marks outside it, MediaBox / BleedBox / TrimBox, and CMYK colour (a documented, deterministic + naive conversion; images and rasterised effects too). + *Left:* .fig import (best effort), a CMYK output intent (no CMYK ICC profile can ship in the + repository), SVG filters other than shadows and blurs (left out with a warning). 8–16 h. +7. **Look & feel and interaction-fidelity pass**: *landed:* keyboard shortcuts audited against the + public shortcut reference: one keymap (`figcraft_engine::keymap`) drives the menus, the canvas, + the control channel and MCP, with macOS and Windows/Linux keys where they differ (mask, bring to + front/back, distribute, tidy up, layout grids, pixel preview), Text-only and canvas-only bindings + (Cmd/Ctrl+K: link while typing, Actions on the canvas), Cmd/Ctrl+/ for Actions, `+`/`-` zoom, + Shift+0/1/2 from any layout, Cmd/Ctrl+0, hold Z for the zoom tool (Alt zooms out, drag an area), + two quick digits for an exact opacity, boolean keys (Alt+Shift+U/S/I/E), remove fill/stroke + (Alt+/, Shift+/), outlines on Shift+Cmd+O, pixel grid on Shift+' (and Cmd+'), Shift+E / Shift+D and the other panel keys, Ctrl+Tab between files, Shift+B (and B) for the paint bucket in vector edit mode; the Keyboard + shortcuts panel docks at the bottom with a tab per group and search, keycaps per platform. + A shortcut parity suite (`shortcut_parity_tests.rs`) checks every documented shortcut on both + platforms (129 rows: 128 bound, including moving the rotation origin with Alt+R and placing an + object with Enter from the toolbar; 1 moved off a documented conflict) and pins the 48 undocumented + bindings; others' cursors (`Alt+Cmd+\`), list levels while editing (Cmd/Ctrl+] and [), the + keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, + Control to suspend snapping on macOS and Space/N/C/F while presenting landed with it + ([ui-parity](ui-parity.md#keyboard-shortcuts)). + *Left:* tool by tool, panel by panel against the incumbent (cursors, modifier keys while + dragging, hover states, menus, popovers); per-keyboard-layout shortcut tables. 40–60 h. +8. **Collaboration (local):** comments landed: pins pinned to frames and layers that move with them + (to other pages too) and stay where they were when the layer is deleted, your pins dragged onto + another frame, crowded pins clustered when zoomed out (a click zooms them apart), Shift+C to show + or hide them; threads where you edit and delete your own messages, emoji reactions, @mentions + with a picker of the names used in the file (your name from Preferences), read and unread state, + resolve, and "Copy link" (`figcraft://comment/`: pasting or `comment.open` goes to the page, + zooms to the frame and opens the thread); the Comments panel with search (text, names, frame), + filters (open, resolved, yours, unread), sorting by date or page, each thread's frame and page, + and Up/Down to step through threads; comments on the presented frame (Comments button or + Shift+C: read and reply). All of it through `comment.*` commands. Version history + landed (named versions saved in the `.figcraft` file; restore, rename, duplicate, delete; panel; + view a version on the canvas read-only before restoring it; autosave checkpoints every 30 + minutes, folded in the panel). A behaviour suite written from the public documentation covers + both: restoring keeps the comments and first keeps the replaced design as an autosave, a titled + autosave becomes a named version, merging a branch records the main file before and after, and + threads made in a live session can be replied to and resolved. Real-time collaboration is designed (`docs/collab-protocol.md`: + WebSocket sessions the app can host itself, server-ordered per-property changes, live cursors) + and its core landed (`crates/collab`, no networking yet): client-scoped node ids, every saved + layer field and the document-level data (styles, variables, comments, images…) as synced + properties, diffs after a command that cost the size of the edit, fractional sibling positions, + a session server that orders and validates batches (cycles, deleted layers, id spaces, sizes) + and a client that shows its own edits at once and replays them over others'; derived data + (auto layout, instances, bindings) is repaired by the author after concurrent edits; binary + messages (a moved layer is 43 bytes, a cursor 23) with a JSON form; a randomized multi-client + test over real commands converges. Live sessions run over the network (hosting from the app, + invite links, document sync, live cursors, selections, avatars, follow). Undo is per person in + a session (`edit.undo` takes back only your own steps, leaving what others changed since; + creations and deletions invert; a gesture or command group stays one step); refused edits roll + back with a toast; a dropped connection keeps editing and resumes as the same client with what + it missed (or a snapshot), then sends what was made offline. Randomized sessions with undo, + redo and dropped connections converge, and a checker confirms no undo reverts someone else's + change. + Plugins landed: sandboxed WebAssembly plugins (a metered wasmi + interpreter with a memory cap and no WASI, also in the browser) whose API is the command + registry; each run is one undo step and is rolled back when it fails (error, trap, endless loop, + memory); a `figcraft-plugin.json` manifest declares menu commands with parameters and read or + write access with an optional command allowlist, and files, other documents, the undo history, + dialogs and other plugins are always off limits. Plugins ▸ Development ▸ Import plugin from + manifest… (kept in the preferences, the module re-read on every run), the installed plugins in + the Plugins menu with a parameters form drawn from the manifest, Run last plugin (⌥⌘P), Manage + plugins, and `plugin.*` commands for scripts, the CLI and MCP; a Rust SDK and two example + plugins (Tidy names, Swatch grid) in `plugins/`, built by `cargo xtask plugins`. Then plugin + panels (ABI v2: fields and buttons drawn with FigCraft's widgets, the plugin called back for + each button or changed value as one undo step, `plugin.ui` / `plugin.event` / `plugin.close` + for scripts and MCP) and per-plugin settings kept with the preferences. Then widgets (API 3: + a plugin describes a layer of its own, validated and built as a frame with locked layers; clicks + on its named parts call the plugin back in a fresh sandbox with the state kept in the layer, one + undo step each; `widget.insert` / `widget.click` / `widget.list`; a Counter example), panels of + several plugins side by side and the colour picker on plugin colour fields. Then a plugin + directory on `figcraft-server`: bundles published with the server key and checked in full, + browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, downloads + checked against their SHA-256, updates by version. *Left:* a public catalogue, publisher + accounts and signatures; richer widget events; the directory in the browser. 8–26 h. +9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer + pages: engine, render and headless UI-frame timings). The canvas keeps its last image and + re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders + in ~0.2 ms instead of ~8 ms (0.8 vs 24 ms at 50k zoomed out), a 40 px pan in ~0.7 ms instead of + ~7.5 ms. The layers panel lays out only the rows in view (every screen expanded at 50k: 39 → + 0.6 ms a frame); text layouts are shared instead of re-formatted per lookup; overflowing text is + culled by its glyphs. At 10k: full 1440×900 renders 7–15 ms, single-layer edits ~10 µs, hit + tests ~26 µs. Zoomed out (50% and below) the canvas draws levels of detail: layers under 2 px + land as their averaged colour, text under 4 px is greeked into bars, small rectangles and clip + layers skip the path pipeline (exports and closer zooms stay exact): a whole 50k-layer page + renders in ~8 ms instead of ~50 ms. `.figcraft` at 50k layers saves in ~0.09 s instead of ~1 s + and loads in ~0.16 s instead of ~0.86 s (JSON streamed to and from the document without a + value tree, layer fields at their defaults left out: 63 → 22 MB of JSON, old files still + open). The retained canvas is held to a full render: a seeded random walk (edits, undo/redo, + pans and zooms over nested, rotated and clipping frames, groups, masks, booleans, auto layout, + effects, text, instances, variables and styles) checks every step, and found clip edges on a + pixel boundary letting a column through in some renders. Drags show live on the canvas (every + finished render is shown, not only the newest one asked for), and a button held still no + longer rebuilds the preview and re-renders every frame. + Components at scale (`cargo xtask bench`, synthetic design system of about 300 + components used by 2,000 instances): edits of mains reach their copies directly instead of + rebuilding every instance (value, size and layer added/removed edits; other edits rebuild each + affected instance once, inner mains first), bound variables no longer touch unchanged layers on + every edit, auto layout re-flows only where sizes or places changed: recolouring or resizing a + main's layer used ~900 times 188/226 → 4/18 ms, adding a layer to it 430 → 21 ms, a label style + in all 72 button variants (1,800 copies) 450 → 57 ms, switching a variant, reset, detach, push + and paste ~450 → under 3 ms, accepting a library update 460 → 64 ms; the Assets panel with 2,000 + assets 3.4 → 1.3 ms a frame. Accessibility: the custom-painted + controls name themselves for screen readers (AccessKit on desktop: role, name, value and state + for tools, layer rows with their type, level, selection and hidden/locked state, fields, + dropdowns, tabs, menu items, and the canvas, which describes the selection); keyboard navigation: + F6 / Shift+F6 move between the toolbar, layers, canvas and properties, Tab / Shift+Tab move + within a region with a focus ring, Enter or Space activate, the arrows go along the toolbar and + through the layer rows, menus and tool flyouts opened from the keyboard take it and give it back, + Escape returns to the canvas (where Tab selects the next layer again instead of wandering into + the tab strip); reduced motion follows the system's setting or a preference; secondary and + accent text meet WCAG AA in both themes. + An idle window draws no frames: it redrew ~240 times a second + (the window title was re-sent every frame, and each viewport command asks for another), ~33% of + a core in a debug build, and now sits at 0 frames and ~0% CPU; the toast, the autosave + checkpoint and the presenter's "after delay" timers wake it at their deadline, the interaction + editor's curve preview plays when it shows, changes or is hovered, and a screenshot keeps frames + coming only until it arrives (`ui.inspect` `framesDrawn` and `repaintCauses` show what asked for + frames; headless tests hold it). *Left:* a real-world file corpus, Windows/Linux/browser + QA, runs with real screen readers (NVDA, Narrator, VoiceOver, Orca), the AccessKit tree in the + browser (eframe's web backend doesn't send it yet). Landed since: F2 rename from the keyboard, + Shift+F10 context menus at the focused layer row or the canvas selection, keyboard box selection + (F8). 27–44 h. + +## Inventory: what exists today + +Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count there was stale: 963 `#[test]` functions as of this re-measure. + +- **Architecture:** 15 crates with enforced layering (`cargo xtask layers`): geom, color, doc, + layout, text, pathops, plugin, collab, render, format, svg, engine, ui-egui (+ mcp, pdf). Every action + is a command (449 command ids); undo is unlimited via structural sharing (`Arc` node snapshots); gestures are + one undo step; `command.batch` runs several commands as one step. +- **Document model:** pages, frames, groups, sections, rectangles, ellipses (arcs/rings), polygons, + stars, lines/arrows, vector networks, text with styled runs, boolean groups, components, component + sets (variants), instances with per-layer overrides, slices; fills (solid, linear/radial/angular/ + diamond gradients, images with fill/fit/crop/tile + adjustments), multiple strokes with + inside/center/outside alignment, per-side weights, dashes, caps/arrowheads, joins; effects (drop + and inner shadow with spread that offsets the outline exactly: ellipses, stars, vectors, text; + inner shadows over the fills and under the children and strokes; drop shadows hidden behind what + the layer covers, translucent fills included, unless shown behind transparent areas; layer blur; background blur + that sees the whole backdrop, inside clipping frames, faded groups and masks too; both blurs + uniform or progressive (the radius ramps between two points, dragged on the canvas); noise in + mono, duo and multi colour with grain size, density and blend mode; texture with clip to shape; + glass with light angle and intensity, refraction, depth, dispersion, frost and splay; the same in + PNG, PDF and SVG exports, SVG embedding renders of noise grains, texture, progressive and + background blur and glass); corner radii with + smoothing; constraints; auto layout (horizontal, vertical, wrap, grid; hug/fill/fixed; min/max; + absolute children); layout grids; export settings; prototype interactions, flows and transitions; + paint/text/effect/grid styles; variables with collections, modes and aliases. +- **Renderer:** CPU (tiny-skia) scene renderer used for canvas, export and the web: isolated layers + for opacity/blend/effects, clip content, masks, booleans (linesweeper), text (harfrust shaping, + skrifa outlines, font fallback), aligned strokes with caching. +- **Engine:** pointer tools (move/marquee/deep select/resize/rotate/Alt-duplicate with smart-guide + snapping, frame/section/slice/shapes/line/arrow with Shift/Alt, text, pen, pencil, hand, + eyedropper), Cmd/Ctrl-click and Cmd/Ctrl-marquee into nested layers, Shift-marquee toggling, + Select layer (every layer under the click), measuring (Alt-hover distances from the selection to + layers, parents and ruler guides, Cmd+Alt for nested layers, from a guide being dragged to the layers of its frame, extension lines, one box inside the + other, overlaps, live distances to the neighbours while Alt-dragging, Shift+Alt for auto layout + padding and gaps; labels that never overlap at any zoom), text editing (caret, selection, words, + lines), auto layout reorder by dragging, drop into frames, align/distribute/tidy, z-order, + flip/rotate, the scale tool (panel, and canvas handles that scale everything about its anchor + point or from the centre with Alt, with a live factor, one undo step per drag), booleans, flatten, + outline stroke, components (create, instances, sync with overrides, reset, detach, push changes, + variants, properties, slots), styles and variables, prototype commands, find/replace, batch rename, + copy/paste (internal + SVG/text), file new/open/save/import/export (native `.figcraft`, SVG + in/out, PNG/JPG/WebP), version snapshots. +- **UI (egui, swappable):** the incumbent's 2026 layout: tab strip that is also the window's title + bar (frameless on Windows/Linux with drawn window buttons, drag, double-click maximise and edge + resizing; macOS window buttons inset into it), icon rail, file header, pages and + layers tree (rename, visibility/lock, drag to reorder/reparent), Assets panel, floating bottom + toolbar with flyouts and mode switcher, Design / Prototype / Inspect panels (Dev Mode: properties with their styles and variables, annotations + with pinned properties and categories drawn as coloured callouts on the canvas (filtered by category), + a ready-for-dev list, a focus view on one frame with previous / next, Compare changes since a frame + was marked ready (before / after side by side or overlaid, property-level diffs), CSS/HTML/React/SwiftUI/Compose + code where components take their properties as parameters and instances are calls, components + connected to their code components showing the real snippet, read-only plugins), colour picker + (HSB square, hue/alpha, hex, gradient stops, image fills), main menu generated from the catalog, + context menu, Actions palette (⌘K), keyboard shortcuts from one keymap per platform (a tabbed, + searchable Keyboard shortcuts panel), keyboard navigation (F6 between regions, Tab within them, a + focus ring) and screen-reader names for the custom controls (AccessKit), presentation player with + transitions, light and dark themes, home screen, About/Preferences/shortcuts/variables dialogs. +- **Automation:** desktop control channel (`--control `: execute commands, inspect document/UI, + synthetic pointer/keys/typing, menu invocation, screenshots, renders). +- **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | +| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | +| 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | +| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | +| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | +| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | +| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | +| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. | +| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. | +| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | +| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | +| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | +| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials: discoverability ×0.92 → ×0.94 (templates and first-run tips exist; no Community files, no tour, no user evidence), 77.8% × 0.92 × 0.94 × 0.88 = 59.2%, ~57% → ~59%; essentials hours ~60–110 h → ~55–100 h (the onboarding and templates item is done); menu breadth stays 211/211 (the two new menu items are FigCraft's own and not counted); every other number unchanged | +| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains | +| 2026-10-12 | minor | Text metrics, first measured fidelity data (gap 3): our example documents, exported and re-saved by other software, were read back and every text layer's stored box, line heights, baselines and glyph positions compared with ours. Three rules explained every row and were applied: a line's height is rounded to whole pixels (halves up; Auto, % and px alike; the baseline keeps its fraction inside the rounded line); glyph advances and kerning are each rounded to 1/1024 em before scaling by the font size; letter spacing is not added after a line's last visible glyph. Before: 24 of 131 text boxes of the design-system example matched (118 in width, 24 in height); after: 131 of 131, and all 230 self-sizing text boxes across the 7 documents (Inter only, 11–120 px, letter spacing −6% to 8%, one wrapped paragraph). Glyph positions within 0.001 px: 223 of 1,832 → 1,781; the rest are kerning pairs at weights where the installed font files differ slightly, at most 0.05 px. Frames and other layers matching in size and position: 115 of 231 → 208; the other 23 hang on one grid frame whose row positions came back as written. Not measured: other font families, fractional px line heights, centred or right-aligned text with letter spacing, lists, mixed sizes on a line. Wording only; no number moves | +| 2026-10-12 | minor | `.fig` export (gap 1): written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Wording only; no number moves | +| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | +| 2026-10-12 | minor | Experimental `.fig` export (gap 1): the whole document written from the same public format notes as the import, verified only by round trips through our own importer. Import/export breadth 84% → 86%, ready 57% → 58%; file formats 59% → 60%; file exchange with Figma users stays ×0.85 (nothing shows another app opens the files); mainstream hours ~130–244 h → ~120–229 h (`.fig` export 15–30 h → checking it elsewhere 5–15 h); feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta and full parity hours unchanged at this rounding | +| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding | +| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h | +| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h | +| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h | +| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h | +| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35% (drafts, not reviewed: graded low), 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1 by the dimension table); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h (Japanese and Korean leave the beta list; their native reviews need people) | +| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h | +| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%; 52.6 → 52.9); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h (Figma's languages left on the way to beta: Japanese and Korean, 7–12 h) | +| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page, latecomers included, until they move their own. Both are ephemeral presence the host validates and rate-limits, passed only to clients that read them. Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; the Collaboration dimension's hours 55–110 → 52–104 h (the dimension hours still add up to the total); feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h | +| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes: several layers dropped together in the layers panel land together (layers from apart split around the drop point); Collapse layers keeps the selection's rows open (it closed everything); the eye and padlock stay under the pointer (they vanished as it reached them, so clicks could miss) and dragging across them sets several layers in one undo step; several layers selected open a Rename layers dialog (match as text or a regular expression, Rename to, current name and number buttons, a start value, preview) instead of renaming the top one; `object.batchRename` counts `$n` up and `$N` down (they were swapped), pads with `$nnn`, takes `start`, regular expressions with `$1`…`$9`, `` $` `` / `$'` and codes in the replacement; pages are dragged to reorder, a blank page named with a hyphen is a divider (never opened, holds no layers), duplicates are named "Copy of …", the last page that isn't a divider can't be deleted; the Assets panel files components by page, frame and slash names in folders that fold away (`component.list` gives each `folder`); a leading `.` or `_` now keeps a component out of the library even after it was published, and removing it brings the component back. Pinned where the documentation is silent: Alt-click on an arrow, numbering in stacking order, hidden layers and locked frames taking drops, locking deselects. Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | +| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | +| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | +| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | +| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | +| 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) | +| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h | +| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, find, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h; mainstream weekly areas 71.0% → 71.1%; percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | +| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h; feature breadth (~80%), ready for real work (~49%), core editor (~68%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~320–615 h → ~319–614 h | +| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53%; Dev Mode 90%/64% → 93%/70%; file formats 55% → 56%; mainstream weekly areas 68.1% → 68.4%; feature breadth (~80%), ready for real work (~49%), essentials (~57%), mainstream (~46%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | +| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation (10 shape tests, 6 vector tests and 3 handle tests: about 70 table rows plus stepwise checks) and its fixes: rounded vector points are drawn and exported (the radius was stored only; a vector's corner radius now rounds every point); a boolean group looks like its top layer, a subtraction like the bottom one (always the bottom); Alt draws a line from its centre; stars keep to 3–60 points; delete and heal fits the curve through the deleted point; Shift-clicking a selected point at a corner of the points' box takes it out (it scaled); outline stroke turns the stroke's colour style into the fill's and keeps opacity and effects; the miter field shows the miter angle (it showed the ratio under that name); individual corners only for rectangles and frames; Option/Alt+Shift+F flattens; edit-mode keys reach the edit tools through MCP `key` (Delete removed the layer). Added: corner radius and star ratio handles on the canvas, Count and Ratio fields for polygons and stars, cut tool drags that divide a shape into layers, paint bucket drags across areas. Shapes & vector 97%/68% → 98%/72%, 1–2 h → 2–4 h (the suite found gaps the estimate didn't count: corner smoothing beyond rectangles, a join or cap per point). Totals recomputed by the doc's method, which earlier rows had left stale: the area table's weighted figures are breadth ~82% (core ~93%) and ready ~61% (core ~69%), not the ~80%/~58% (~91%/~68%) carried since #130; Features (depth) follows the weighted ready, 57% → 61%; ready for real work ~49% → ~51%; mainstream ~46% → ~48% (weekly areas 67.8% → 69.4%); the selection suite's area 2 hours (−3/−5) hadn't reached the totals, and neither it nor the text suite had reached the Features (depth) row: Features 60–115 h → 56–109 h, full parity ~321–618 h → ~319–615 h (−3/−5 for area 2, +1/+2 for area 4) | +| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation (14 paint and 9 effects tests with table rows for fills, opacity, every blend mode by its formula, pass-through and Normal containers, gradient handles and stops, image scale modes and adjustments, colour models, the colour profile, shadows, blurs, effect order and exports; 3 command tests) and its fixes: groups and frames set to Normal blend their children among themselves first, and clipping frames no longer isolate their children (blend modes inside a clipping frame met only the frame's children, not its fill or what is behind it) — canvas, SVG (`isolation`; clips put on what blends, since a clipped group isolates) and PDF (isolated groups); plus darker is drawn (it was multiply) on the canvas and in PNG exports; drop shadows hide behind what the layer covers, translucent fills included, unless shown behind transparent areas, in SVG too (it never cut them out) and the SVG filter region reaches as far as the effects; Fill images cover the box to its edges (upscaled ones faded out at the edge); "Normal" names Normal, not pass-through, in commands and the blend menu (the menu's Normal set pass-through). Added: a blend menu for each paint in the colour picker. Paint 66% → 70%, 4–7 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h | +| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h | +| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h | +| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h | +| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) | +| 2026-10-11 | minor | Embedded image colour profiles: placed and opened images are converted once, when decoded, from their own colour space into the file's (sRGB or Display P3): ICC profiles in PNG (`iCCP`), JPG (APP2), WebP and TIFF, matrix/curve and table profiles in RGB or grey (moxcms, already built for the `image` crate), and a PNG's `sRGB`, `gAMA` and `cHRM` chunks; images without a profile, or with one that can't be used (CMYK, damaged, over 4 MB), are taken as sRGB as before. The original file is kept (PDF exports still embed it); pixel exports carry the converted pixels with the file's profile. The image fill's details show the source profile (Unknown — treated as sRGB when there's none) and `image.place` reports it as `imageProfile`. Paint breadth 87% → 90%, ready 66% → 67%, 4–7 h → 3–6 h; feature breadth (~80%), ready for real work (~49%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h | +| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | +| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | +| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Import/export breadth 65% → 80%, ready 35% → 50%, 30–60 h → 8–15 h; file formats 40% → 55%, 30–60 h → 8–15 h; feature breadth ~79% → ~80% (core ~90% → ~91%), areas ready ~57% → ~58% (core ~67% → ~68%); ready for real work ~47% → ~49%; essentials ~53% → ~57% (opening files people send ×0.80 → ×0.88); mainstream ~43% → ~46% (weekly areas 66.8% → 67.8%, file exchange ×0.80 → ×0.85); beta ~195–355 h → ~173–310 h; full parity ~345–666 h → ~322–620 h | +| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | +| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | +| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | +| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | +| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | +| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~358–687 h | +| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | +| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | +| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | +| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | +| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | +| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for full (~400–750 h), mainstream (~180–330 h) and essentials (~85–160 h). Full number confirmed as an additive weighted sum; unchanged | +| 2026-10-10 | minor | Added the essentials user number (~53%: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send) | +| 2026-10-10 | minor | Added the mainstream practitioner number (~43%: weekly areas 66.5% × 0.9 interaction × 0.9 stability × 0.8 file exchange) and user-evidence count (none). Checked that ready for real work (~46%) is built from the written dimension weights and scores; unchanged | +| 2026-10-10 | minor | Stage checked against the alpha gate: stays alpha | +| 2026-10-10 | major | Created from the ROADMAP's parity rubric, honest assessment and area narrative. Full re-measure against Figma desktop 126.10.7: added multiplayer, plugins, team libraries, Figma Draw extras and AI as weighted areas; split breadth from ready-for-real-work; core depth re-graded ~79% → ~67%; hours calibrated from git history; full parity 115–195 h → 420–790 h | From 9cb1dba1e27339a541858e80f30d81f9d2db0211 Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:07:13 +0200 Subject: [PATCH 002/208] M4: normalize contribution files to LF --- ROADMAP.md | 834 +++++++-------- crates/engine/src/cmd/structure.rs | 1306 +++++++++++------------ crates/engine/tests/outline_stroke.rs | 348 +++--- docs/gaps.md | 896 ++++++++-------- docs/roadmap.md | 250 ++--- docs/target-app-parity.md | 1406 ++++++++++++------------- 6 files changed, 2520 insertions(+), 2520 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 29de0e90..10b2d411 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,417 +1,417 @@ -# FigCraft Roadmap - -**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) - -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) - -FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, -native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through -the CLI, a JSON control channel and MCP. This page is the one-page summary; agents choosing work -start with [`docs/gaps.md`](docs/gaps.md). - -## Headline numbers - -| | Value | Kind | -|---|---|---| -| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | -| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | -| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) | -| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | -| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | -| Remaining to beta | **~155–280 h** one agent | estimated | -| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | - -### Readiness by audience - -| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | -|---|---|---|---| -| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | -| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) | -| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | - -Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour -per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see -[how the hours are calibrated](docs/target-app-parity.md#how-the-hours-are-calibrated)). Each audience's work is a subset of -the next. About 70–80% parallelizes across 4–6 agents on disjoint crates (roughly ÷4 in -wall-clock); the `.fig` reader, the multiplayer core and a plugin runtime each need one owner, and -native-speaker review, real-file licensing and screen-reader QA need people. - -Hours are Opus 5.5 agent wall-clock hours, calibrated from this repo's history: the app was built in -~25 wall-clock hours by 4–6 parallel agents (~110–150 agent-hours, ~1 agent-hour per 1,000 added -lines, a typical feature PR 1–2.5 h). Details and what needs a human: -[`docs/target-app-parity.md`](docs/target-app-parity.md#how-the-hours-are-calibrated). - -## Stage - -**Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components -with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but -FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked -against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs -~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured -fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h), -basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h); -native speakers' reviews of the six language drafts need people. - -## By dimension - -| Dimension | Ready | Hours | Doc | -|---|---|---|---| -| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) | -| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | -| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) | -| Stability | 55% | 20–40 | [gaps](docs/gaps.md) | -| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | -| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | -| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | -| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | -| AI features | 0% | 30–60 | [gaps](docs/gaps.md) | - -## Features - -| Area | Breadth | Ready | Hours | Doc | -|---|---|---|---|---| -| Canvas & navigation | 98% | 74% | 1–4 | [ui-parity](docs/ui-parity.md) | -| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) | -| Layers panel | 99% | 85% | 1–2 | [ui-parity](docs/ui-parity.md) | -| Shapes & vector | 99% | 73% | 1–3 | [target-app-parity](docs/target-app-parity.md) | -| Paint | 90% | 71% | 2–5 | [target-app-parity](docs/target-app-parity.md) | -| Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) | -| Effects | 100% | 75% | 2–5 | [target-app-parity](docs/target-app-parity.md) | -| Frames, groups, sections, masks | 97% | 80% | 1–3 | [target-app-parity](docs/target-app-parity.md) | -| Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) | -| Text | 90% | 70% | 3–7 | [target-app-parity](docs/target-app-parity.md) | -| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) | -| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) | -| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) | -| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) | -| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | -| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | -| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | -| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | -| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | -| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | - -## Languages - -| Language | Code | Strings | Status | -|---|---|---|---| -| English | en | 100% | full | -| Simplified Chinese | zh-Hans | 100% | draft (needs native review) | -| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) | -| Hindi | hi | 0% | none | -| Arabic | ar | 0% | none | -| French | fr | 100% | draft (needs native review) | -| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) | -| Indonesian | id | 0% | none | -| Japanese | ja | 100% | draft (needs native review) | -| German | de | 100% | draft (needs native review) | -| Korean | ko | 100% | draft (needs native review) | -| Vietnamese | vi | 0% | none | - -Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese -(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ -Language; Traditional Chinese has no catalog; CJK UI -text uses an installed CJK font, loaded only when needed; adding a language: -[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, -IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md). - -## Upcoming - -Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - -1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. -2. Real-file corpus and measured fidelity: 15–30 h. -3. Tool-by-tool interaction pass against Figma: 40–60 h. -4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). -5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. - -## Progress log - -- **2026-10-15** · M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. - -- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. -- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. -- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. -- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. -- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged. -- **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged. -- **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged. -- **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged. -- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. -- **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. -- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. -- **2026-10-13** · Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. -- **2026-10-13** · Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. -- **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. -- **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. -- **2026-10-13** · Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. -- **2026-10-13** · `.fig` export: instance sizes, variables, styles (gap 1): instances write what they change from - their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as - derived data and a scale factor when scaled as a whole; variable collections, modes, variables and bindings, - the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the - import reads all of it back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; - number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged. - text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged. -- **2026-10-13** · Starter templates and first-run help: eight templates built in code (mobile app, landing page, - dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter), a - Templates tab on the home screen, `template.list` and `file.newFromTemplate` for scripts and agents, and a Getting - started card that can be closed and comes back from the Help menu -- **2026-10-12** · Your online files: a file shared or published online stays yours after its tab closes or the app - quits. The app remembers it (with its owner token, kept with the preferences and never shown) and Home ▸ **Online** - lists what you own: open a file again as its owner, start or stop sharing, copy its code, rename it, remove it from - the server or forget it here. Servers are asked once, when the list opens; tabs mark files in a live session; a - crash reopens such a file from its server and puts back the changes that hadn't reached it. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an - upgrade only to tell which files are still there. The six draft catalogs translate every string again -- **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge - preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and - object bounds, Hide when publishing in the Assets panel, a cursor for every tool and modifier, panel widths that are - kept, and dark pages (white text on them) for files made in the dark theme -- **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or - **Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that - carries the host's address); others type it under Join with a code (the popover, the home screen), where a link - works too. The server names links with codes, keeps them hashed and limits wrong ones; `collab.share`, - `collab.join {code}`; servers must be upgraded to hand codes out (older ones keep working with links) -- **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and - compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing - goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before - in the design-system example). One font family; real files still unchecked (gap 3). No number moves -- **2026-10-12** · Live sessions are per document: every open file has its own session or none, several run at - once (host one, join another, publish a third) and keep syncing behind the tab in front, the Share popover, - people, cursors, chat, spotlight and follow are the file in front's, `collab.peers` lists them all (`sessions`), - and closing a file ends its session only (before, one session followed every tab and starting another replaced it) -- **2026-10-12** · A public `figcraft-server`: `open_publish = true` lets anyone publish without the server key - (Share ▸ Publish to server asks the server first and shows who hosts it, its terms and how long unopened files are - kept), with an owner token shown once that manages the file later (`collab.manageFile`); limits per address, - idle expiry, reports (Share ▸ Report this file…), `block-ip` and `files list --sort`, `GET /healthz`, and - `packaging/server/install.sh` that sets up a Debian/Ubuntu machine with Caddy, ufw and backups - ([server.md](docs/server.md#running-a-public-server)). Numbers unchanged (hosting, not a Figma feature). -- **2026-10-12** · `.fig` export opens elsewhere (gap 1): written files were checked by opening them in other - software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. The - export now says that the document and its pages are visible and opaque, numbers its schema as the format does, - and writes outlines, text settings, standard font style names and line height units; effects such as glass and - noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers - unchanged. -- **2026-10-12** · Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig - (experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document: layers, - geometry, paints with their images, strokes, effects, styled text, vector networks, auto layout, components and - instances with their overrides, and prototype interactions; what's left out (style links, variables, comments, - Dev Mode data…) comes back as warnings. Checked only by reading the files back with our own importer. File - formats 60%. -- **2026-10-12** · Keyboard shortcut parity (gap 4): a table test with a row for each of the 129 - shortcuts in the public documentation, macOS and Windows/Linux keys each, and the 48 bindings it - is silent on pinned; 126 bound (110 before), Run last plugin moved off a documented conflict on - Windows, 2 missing. New: others' cursors toggle, list levels on Cmd/Ctrl+] and [ while editing, - keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, Control - suspending snapping on macOS, Space/N/C/F while presenting, the Frame tool on A, pixel grid on - Shift+', link on Shift+Cmd+U, ungroup on Cmd/Ctrl+Backspace, suggest auto layout and go to main - component keys; shifted symbols on other keyboard layouts. -- **2026-10-12** · Plugin directory on `figcraft-server` (gap 6): publish a plugin folder with the server key - (`figcraft-cli plugin publish`), browse a server's plugins in Plugins ▸ Manage plugins ▸ Browse, install after a - confirmation that shows the permissions it asks for, update when a newer version appears, remove; bundles are - checked in full on the server and again in the app, downloads must match their SHA-256, installed plugins live - per user in the same sandbox; `plugin.directory.*` for scripts and MCP; the operator moderates with - `figcraft-server plugins`. Plugins & widgets 60%. -- **2026-10-12** · Plugin widgets and the panel tail (gap 6, plugin API 3; older plugins run unchanged): a widget - plugin puts a layer of its own on the canvas (Plugins ▸ Widgets, `widget.insert`), described by the plugin, - validated and built with locked layers inside; clicking a part it named calls it back with the widget's state - (kept in the layer, so it saves, copies, undoes, exports and syncs) as one undo step, and it redraws; - `widget.click` and `widget.list` for agents; a Counter example. Panels of several plugins open side by side and - plugin colour fields open the colour picker. Plugins & widgets 55%. -- **2026-10-12** · Layers and Assets panels behaviour suite (gap 4): several layers dropped together land - together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers - at once, a Rename layers dialog for several layers (number codes as documented, regular expressions, a start - value), pages dragged to reorder, dividers, "Copy of" duplicates, Assets folders by page, frame and slash names, - and hidden-from-publishing names that decide both ways. -- **2026-10-12** · Vector multi-edit and the vector edit tail (gap 9): several vector layers in edit - mode at once (select, marquee, drag, nudge, delete, mirroring and radius across them), several - Bezier handles dragged together, rounded points beside curved segments, a stroke cap and join per - point, and the Pen connecting to its own points or continuing a selected open path. -- **2026-10-11** · Canvas navigation, guides and grids behaviour suite (gap 4): zoom, panning, the pixel - grid (now from 400%), rulers, guides (top-level frames only; select, Delete, nudge, Remove guide, - Alt-drag copies, Alt measures while dragging), layout grids (Auto counts; hidden on rotated frames), - outline mode (clicks reach layers behind) and measuring, each rule from the documentation. -- **2026-10-11** · Export and inspect behaviour suite (gap 4): exports cover what a layer paints (shadows - only where they fall, true stroke widths, no room for hidden layers, a mask group's mask), slices export what's under - them, layer names with slashes export into folders; export settings gained contents only, include bounding box and - the SVG options (outline text, ids, simplified strokes); Inspect shows colours in hex, RGB, CSS, HSL or HSB, CSS in - rem, blend modes and positions from the frame, and lists icons and images as assets. Dev Mode 70%. -- **2026-10-11** · `.fig` import's tail (gap 1): vectors open as editable vector networks with their - corner radii, handle mirroring and end caps (outlines when the network is missing or damaged); sticky notes, shapes - with text, connectors, tables and similar layers import as frames, text and vectors instead of being dropped; - set-variable and conditional prototype actions come across. File formats 58%, ready ~49%. -- **2026-10-11** · Paint and effects behaviour suite: every documented rule for fills, blend modes, - gradients, images, colour models, shadows, blurs and effect exports is a table-driven case. Fixes: Normal - groups isolate and clipping frames don't, plus darker is drawn, drop shadows hide behind translucent - layers in every export, Fill images reach the box's edges, "Normal" no longer means pass-through. - Paint 70%, effects 75%. -- **2026-10-11** · `.fig` import, best effort (gap 1): `file.open`, `file.import`, drag and drop, the CLI and - MCP open `.fig` files. A generic kiwi decoder reads each file's own schema, and the decoded nodes map by field name - onto the REST importer: layers, paints and styles, text runs, vectors as outlines, auto layout, components and - instances (expanded with their overrides and property values), variables, prototyping and images from the archive; - the rest is a warning. File formats 55%, ready ~49%. -- **2026-10-11** · Gamut warnings (gap 18): the colour picker of a Display P3 file flags colours - outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas. Showing P3 itself waits on - eframe/egui-wgpu exposing the surface colour space wgpu 30 already supports. -- **2026-10-11** · Several windows (gap 18): File ▸ New window, Open in new window on a tab or a - tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it - needs to and asks about unsaved changes when it closes (the main window too); the control channel - and MCP open, list, focus and capture windows. Hardware 60%. -- **2026-10-11** · Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls, - certificates always verified; `FIGCRAFT_CA_FILE` for a private authority), releases ship the server for Linux - x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` walks through deploying it on - a rented Linux server (systemd unit, Caddy or nginx, firewall, Docker, upgrades, backups). -- **2026-10-11** · Plugin panels and settings (gap 6, ABI v2; v1 plugins run unchanged): a plugin shows a panel of - fields and buttons drawn with FigCraft's widgets and is called back for each button or changed value (one undo step - per callback, same sandbox limits), `plugin.ui` / `plugin.event` / `plugin.close` drive panels from scripts and MCP, - and per-plugin settings are kept with the preferences; Swatch grid gained a palette panel. Plugins & widgets 45%. -- **2026-10-11** · Canvas fixes: clicks hit boolean groups where their result is (a click in a - subtracted hole reaches what's behind), a layer dragged inside a group stays in it, View ▸ Frame - outlines and View ▸ Slices draw, and a flaky idle test now waits for the render thread (gap 13). -- **2026-10-11** · Team libraries on `figcraft-server` (gap 10): Assets ▸ Libraries ▸ Publish to server keeps - the file's library on the server under the file (per-asset versions; the server's version is followed); other files - enable it by pasting a share link (a view link is enough), get "Library updates available" from a background check - and accept with overrides kept; the server counts which files use which components (Show usage, `library.usage`); - `library.swap` re-points instances, styles and variables to another library by names. Team libraries 70%. -- **2026-10-11** · Animated GIF fills (first frame on the canvas and in exports, played in the - presenter and the preview, waking the window only for the next frame) and TIFF images (gap 12). -- **2026-10-11** · The last three menu items got commands: File ▸ Open recent (kept in the - preferences, missing files greyed, Clear recent), outlines for the selection and mask outlines - (gap 21). Menu breadth 203/203. -- **2026-10-11** · Extended variable collections (a library collection's modes and variables in a - collection of the file, overridden per mode with reset, following library updates), variables - import/export (the REST local variables JSON, merged by name; CSS custom properties), gradient - stops and variant properties bound to variables (gap 14). -- **2026-10-11** · Live boolean editing: operands stay editable layers (double-click or Cmd/Ctrl-click - selects one, moving, resizing or editing its points redraws the result during the drag with every - operand outlined, per-operand operations from the boolean controls, prefix-cached recompute); - Design panel arc fields; the shape builder on strokes and open paths; one-sided variable widths - (gap 13). Feature breadth ~79%. -- **2026-10-10** · WASM plugins (#101: sandboxed wasmi runtime, manifest permissions, Plugins menu, - Run last, Manage, Rust SDK and two examples), docs refresh (#102), the multiplayer core without - networking (#103: client-scoped ids, change sets, server ordering and validation, client - reconciliation, convergence tests). Menu breadth 200/203; ready for real work ~47%. -- **2026-10-10** · Full re-measure against Figma desktop 126.10.7 under the craftrules progress-docs - standard: ready for real work ~46%, feature breadth ~76%, stage alpha; progress docs split out - (`docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, the per-area parity - docs); `docs/parity.md` renamed `docs/parity-checklist.md`. -- **2026-10-10** · Day 2 (PRs #1–#99): vector edit mode depth (transform box, point snapping, arc - handles, shape builder, variable width), text depth (variable fonts, bidi, RTL names, style - browser), auto layout nesting and keyboard reordering, slots and library variables, components - at scale (2,000 instances), effects fidelity (glass, noise, texture, progressive blur), - prototyping (conditionals, expressions, device frames, responsive presentation), REST import of - prototypes and variables, SVG import fidelity, PDF print options, Dev Mode (code, annotations, - ready for dev, compare changes), comments polish, version history, keyboard shortcut parity, - accessibility, retained canvas and 50k-layer performance, no redraws while idle; v0.2.0 - released for every platform; multiplayer protocol designed. -- **2026-10-09** · Day 1: foundation (13 crates, layering, command registry, renderer, engine, - UI3 frontend, control channel), CLI and MCP, web build, PDF export, packaging, first depth - passes on text, components, prototyping and comments. - -## Revision history - -| Date | Change | Summary | -|---|---|---| -| 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | -| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | -| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. | -| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | -| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | -| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | -| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | -| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged | -| 2026-10-14 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen and leaves it again; FigCraft's own item. Numbers unchanged | -| 2026-10-14 | minor | Colour picker: wrapped rows of page and recent swatches stay inset on both sides instead of running past the picker's right edge. Numbers unchanged | -| 2026-10-14 | minor | Server snapshots (#188): figcraft-server never replaces a readable snapshot with one too large to be read back, and reads snapshots up to twice the file cap (recent batches kept for reconnecting clients may take a file over it). Numbers unchanged | -| 2026-10-14 | minor | Preferences owner-only on Unix (#200): `state.json`, which holds online owner tokens, is written with mode 0600 from the first byte, and an older world-readable file is narrowed. Numbers unchanged | -| 2026-10-14 | minor | ruzstd 0.9.1 (#182): `.fig` import requires the release that fixes RUSTSEC-2026-0344 (a zstd decompression bomb). Numbers unchanged | -| 2026-10-14 | minor | Layer cycles (#205): `validate` reports children listed twice, pages with a parent and layers not reachable from a page (cycles, detached subtrees); `repair` drops them in one linear pass. Numbers unchanged | -| 2026-10-14 | minor | AppImage CLI: `figcraft.AppImage cli ` runs the bundled `figcraft-cli` (MCP server included) on Linux without a second download. Numbers unchanged | -| 2026-10-14 | minor | Boolean outline depth (#193): nested boolean groups share one outline depth budget instead of each level getting its own. Numbers unchanged | -| 2026-10-14 | minor | Command batches roll back as one: a failed `command.batch` (MCP `batch`) restores every open document and its state, and puts the tab in front back. Numbers unchanged | -| 2026-10-14 | minor | Desktop builds no longer rebuild the text crate on every build when the craft-fonts manifest is missing. Numbers unchanged | -| 2026-10-14 | minor | Direct3D 12 on Windows: the desktop app asks wgpu for Direct3D 12 there instead of Vulkan, so it starts without Vulkan loader errors on machines with stray overlay layers. Numbers unchanged | -| 2026-10-14 | minor | Plugin metering (#192): every host call and every byte copied between a plugin and the host costs fuel, so a plugin can't stall the app through cheap-looking host calls. Numbers unchanged | -| 2026-10-14 | minor | SVG/PDF export depth (#208): explicit preflight refusal beyond 256 descendant levels, propagated through public APIs, commands and CLI, with boundary/content and destination-preservation regressions. Numbers unchanged. | -| 2026-10-13 | minor | MCP/control transport hardening (#201): frame limits during reads, bounded connections and both UI request queues, socket inactivity timeouts; percentages and estimates unchanged | -| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | -| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | -| 2026-10-13 | minor | Repeat last command (M1, #181): keep the underlying command and parameters across repeats, reject self-reference, and retain one undo step per repeated edit. Numbers unchanged. | -| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | -| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | -| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | -| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. | -| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. | -| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | -| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | -| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | -| 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | -| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials ~57% → ~59% (discoverability ×0.92 → ×0.94), its hours ~60–110 h → ~55–100 h; other numbers unchanged | -| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged | -| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains | -| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged | -| 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged | -| 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged | -| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged | -| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | -| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder mirroring the decoder and the schema of the fields written; what's left out is a warning; verified only by round trips through our own importer. File formats 59% → 60%; import/export 84%/57% → 86%/58%; file exchange stays ×0.85; mainstream hours ~130–244 h → ~120–229 h; feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta (~155–280 h) and full parity (~265–523 h) unchanged at this rounding | -| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding | -| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h | -| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h | -| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h | -| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h | -| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35%, 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h | -| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h | -| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h | -| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page until they move their own. Ephemeral presence the host validates and rate-limits, passed only to clients that read it (older clients keep working). Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; collaboration 13% unchanged, 49–98 h → 46–92 h; feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h | -| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes (several layers dropped together, Collapse layers, eye and padlock sweeps, Rename layers dialog with the documented number codes, page dragging, dividers, "Copy of" duplicates, Assets folders, hidden-from-publishing names both ways). Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | -| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | -| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | -| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | -| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h (the Features table here still showed 80%/58%/4–8 h from before branches landed) | -| 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) | -| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h | -| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h (this table still showed 80%/58%/4–8 h from before branches landed); percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | -| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h (this table still showed 95%/72%/3–6 h from before the outlines landed); percentages unchanged at this rounding; full parity ~320–615 h → ~319–614 h | -| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53% (the row still showed the figures from before `.fig` import); Dev Mode 90%/64% → 93%/70%; mainstream weekly areas 68.1% → 68.4%; percentages and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | -| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation and its fixes: rounded vector points are drawn and exported, a boolean group looks like its top layer (a subtraction like the bottom one), Alt draws lines from the centre, stars keep to 3–60 points, delete and heal follows the curve, Shift-click on a point at a corner of the points' box deselects it, outline stroke keeps the stroke's colour style, opacity and effects, the miter field shows degrees, individual corners only for rectangles and frames, Option/Alt+Shift+F flattens, MCP `key` reaches the edit tools. Added: corner radius and star ratio handles, Count and Ratio fields, cut drags that divide shapes into layers, paint bucket drags. Shapes & vector 97%/68% → 98%/72%, 1–3 h → 2–4 h. Totals recomputed from the area table, which earlier rows had left stale: feature breadth ~80% → ~82% (core ~91% → ~93%), Features (depth) 57% → 61% (core 68% → 69%), 60–115 h → 56–109 h, ready for real work ~49% → ~51%, mainstream ~46% → ~48%; full parity ~321–618 h → ~319–615 h (the selection suite's −3/−5 h hadn't reached the totals) | -| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation and its fixes: Normal groups and frames isolate their children and clipping frames don't (canvas, SVG, PDF); plus darker drawn on the canvas and in PNG exports; drop shadows hidden behind what the layer covers unless shown behind transparent areas, SVG included, with filter regions that reach as far as the effects; Fill images cover the box to its edges; "Normal" names Normal in commands and the blend menu. Paint 85%/66% → 87%/70% (breadth from the GIF/TIFF row), 5–9 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h | -| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h | -| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h | -| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h | -| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) | -| 2026-10-11 | minor | Embedded image colour profiles (ICC in PNG/JPG/WebP/TIFF, PNG sRGB/gAMA/cHRM) converted into the file's colour space when decoded. Paint 85%/66% → 90%/67% (the row had lagged the parity doc), 5–9 h → 3–6 h; totals unchanged at this rounding | -| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h | -| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | -| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | -| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged | -| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | -| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | -| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | -| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | -| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | -| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | -| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~358–687 h | -| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | -| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | -| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | -| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | -| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | -| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | -| 2026-10-10 | minor | Readiness by audience with hours to ~95% (full ~400–750, mainstream ~180–330, essentials ~85–160) | -| 2026-10-10 | minor | Added essentials user (~53%) and mainstream practitioner (~43%); ready for real work unchanged at ~46% | -| 2026-10-10 | minor | Alpha gate (core workflows) checked under the revised stage rules: passes, stays alpha | -| 2026-10-10 | major | Rewritten to the craftrules progress-docs shape after a full re-measure; rubric, area narrative and inventory moved to docs/target-app-parity.md, gaps to docs/gaps.md, milestones to docs/roadmap.md. Depth ~79% (self-graded, 16 areas) → ready for real work ~46% (all dimensions); full parity 115–195 h → 420–790 h | -| 2026-10-10 | minor | Day-2 refreshes after each batch of PRs (depth 58% → 79%) | -| 2026-10-09 | major | First roadmap | +# FigCraft Roadmap + +**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) + +FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, +native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through +the CLI, a JSON control channel and MCP. This page is the one-page summary; agents choosing work +start with [`docs/gaps.md`](docs/gaps.md). + +## Headline numbers + +| | Value | Kind | +|---|---|---| +| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) | +| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) | +| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) | +| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | +| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | +| Remaining to beta | **~155–280 h** one agent | estimated | +| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | + +### Readiness by audience + +| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | +|---|---|---|---| +| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) | +| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | + +Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour +per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see +[how the hours are calibrated](docs/target-app-parity.md#how-the-hours-are-calibrated)). Each audience's work is a subset of +the next. About 70–80% parallelizes across 4–6 agents on disjoint crates (roughly ÷4 in +wall-clock); the `.fig` reader, the multiplayer core and a plugin runtime each need one owner, and +native-speaker review, real-file licensing and screen-reader QA need people. + +Hours are Opus 5.5 agent wall-clock hours, calibrated from this repo's history: the app was built in +~25 wall-clock hours by 4–6 parallel agents (~110–150 agent-hours, ~1 agent-hour per 1,000 added +lines, a typical feature PR 1–2.5 h). Details and what needs a human: +[`docs/target-app-parity.md`](docs/target-app-parity.md#how-the-hours-are-calibrated). + +## Stage + +**Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components +with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but +FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked +against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs +~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured +fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h), +basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h); +native speakers' reviews of the six language drafts need people. + +## By dimension + +| Dimension | Ready | Hours | Doc | +|---|---|---|---| +| Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) | +| UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | +| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) | +| Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) | +| Stability | 55% | 20–40 | [gaps](docs/gaps.md) | +| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | +| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | +| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | +| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | +| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | +| AI features | 0% | 30–60 | [gaps](docs/gaps.md) | + +## Features + +| Area | Breadth | Ready | Hours | Doc | +|---|---|---|---|---| +| Canvas & navigation | 98% | 74% | 1–4 | [ui-parity](docs/ui-parity.md) | +| Selection & transform | 97% | 72% | 3–5 | [ui-parity](docs/ui-parity.md) | +| Layers panel | 99% | 85% | 1–2 | [ui-parity](docs/ui-parity.md) | +| Shapes & vector | 99% | 73% | 1–3 | [target-app-parity](docs/target-app-parity.md) | +| Paint | 90% | 71% | 2–5 | [target-app-parity](docs/target-app-parity.md) | +| Strokes | 95% | 75% | 1–2 | [target-app-parity](docs/target-app-parity.md) | +| Effects | 100% | 75% | 2–5 | [target-app-parity](docs/target-app-parity.md) | +| Frames, groups, sections, masks | 97% | 80% | 1–3 | [target-app-parity](docs/target-app-parity.md) | +| Auto layout & constraints | 98% | 74% | 3–6 | [auto-layout-parity](docs/auto-layout-parity.md) | +| Text | 90% | 70% | 3–7 | [target-app-parity](docs/target-app-parity.md) | +| Components & variants | 96% | 72% | 4–9 | [components-parity](docs/components-parity.md) | +| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) | +| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) | +| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) | +| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) | +| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | +| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | +| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | +| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | +| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | +| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | + +## Languages + +| Language | Code | Strings | Status | +|---|---|---|---| +| English | en | 100% | full | +| Simplified Chinese | zh-Hans | 100% | draft (needs native review) | +| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) | +| Hindi | hi | 0% | none | +| Arabic | ar | 0% | none | +| French | fr | 100% | draft (needs native review) | +| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) | +| Indonesian | id | 0% | none | +| Japanese | ja | 100% | draft (needs native review) | +| German | de | 100% | draft (needs native review) | +| Korean | ko | 100% | draft (needs native review) | +| Vietnamese | vi | 0% | none | + +Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), +French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ +Language; Traditional Chinese has no catalog; CJK UI +text uses an installed CJK font, loaded only when needed; adding a language: +[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, +IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md). + +## Upcoming + +Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). + +1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h. +2. Real-file corpus and measured fidelity: 15–30 h. +3. Tool-by-tool interaction pass against Figma: 40–60 h. +4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h). +5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h. + +## Progress log + +- **2026-10-15** · M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. + +- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. +- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. +- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. +- **2026-10-14** · SVG/PDF export depth (M0.9, #208): visible descendants deeper than 256 levels below an exported root are refused before bounds or drawing, including masks and raster fallbacks. SVG has a checked API; the engine and CLI report the limit and suggest selecting an inner layer. Existing destination files survive refusal. Boundary, inner-root, hidden-subtree, multi-root/page and command/CLI regressions cover the change. Numbers unchanged. +- **2026-10-13** · MCP/control transport hardening (#201): 32 MiB incoming frames are bounded while reading; oversized frames return an error and close without draining. The opt-in loopback control listener caps active workers at 8, applies 60 s socket read/write inactivity timeouts and caps both request queues at 8, discarding timed-out requests including those deferred to hidden windows. Reader, queue, idle-client and blocked-writer regressions cover the limits. Numbers unchanged. +- **2026-10-13** · Attached MCP (M0.9, #194): a transport failure no longer replays a request that may already have edited the document. The caller gets an outcome-unknown error and must inspect before retrying; the next explicit call reconnects. Loopback tests cover a lost mutation reply, a single applied edit, reconnect and a connection failure before sending. Numbers unchanged. +- **2026-10-13** · Constrain proportions in the Layout panel (M1, #220): with the W/H link on, typing a width or height scales the other side by the layer's own ratio; the property edit dropped the ratio, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep. Regression test in the transform behaviour suite. Numbers unchanged. +- **2026-10-13** · Repeat last command (M1, #181): repeated edits keep the underlying command and parameters, so repeated nudges accumulate with one undo step each. A stored self-reference is refused instead of recursing. Regression tests cover three repeats, their undo steps, and missing or self-referencing commands. Numbers unchanged. +- **2026-10-13** · Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. +- **2026-10-13** · Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. +- **2026-10-13** · Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. +- **2026-10-13** · Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. +- **2026-10-13** · Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. +- **2026-10-13** · Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. +- **2026-10-13** · Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. +- **2026-10-13** · Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. +- **2026-10-13** · `.fig` export: instance sizes, variables, styles (gap 1): instances write what they change from + their main (size, name, paints) as an override addressed by the main's id, what follows for their layers as + derived data and a scale factor when scaled as a whole; variable collections, modes, variables and bindings, + the modes frames and pages set, shared styles with their links and variable-width strokes are written, and the + import reads all of it back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; + number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged. + text, effect and grid styles, number, string and boolean bindings, bindings on effects, layout guides and gradient stops, and variable-width strokes are not yet checked there. Numbers unchanged. +- **2026-10-13** · Starter templates and first-run help: eight templates built in code (mobile app, landing page, + dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter), a + Templates tab on the home screen, `template.list` and `file.newFromTemplate` for scripts and agents, and a Getting + started card that can be closed and comes back from the Help menu +- **2026-10-12** · Your online files: a file shared or published online stays yours after its tab closes or the app + quits. The app remembers it (with its owner token, kept with the preferences and never shown) and Home ▸ **Online** + lists what you own: open a file again as its owner, start or stop sharing, copy its code, rename it, remove it from + the server or forget it here. Servers are asked once, when the list opens; tabs mark files in a live session; a + crash reopens such a file from its server and puts back the changes that hadn't reached it. `collab.files`, `collab.openFile`, `collab.forgetFile`; servers need an + upgrade only to tell which files are still there. The six draft catalogs translate every string again +- **2026-10-12** · Everyday editing partials: number fields (scrub rates with Shift and Alt, arrow keys by the nudge + preferences, arithmetic with brackets, relative edits of Mixed values per layer), outline mode's hidden layers and + object bounds, Hide when publishing in the Assets panel, a cursor for every tool and modifier, panel widths that are + kept, and dark pages (white text on them) for files made in the dark theme +- **2026-10-12** · Sharing with a join code: Share ▸ **Online** (through the server in Preferences, built in) or + **Local network**, one Start sharing button and a short code to read out (`K7M-2QX-9TD`; a longer one locally that + carries the host's address); others type it under Join with a code (the popover, the home screen), where a link + works too. The server names links with codes, keeps them hashed and limits wrong ones; `collab.share`, + `collab.join {code}`; servers must be upgraded to hand codes out (older ones keep working with links) +- **2026-10-12** · Text metrics measured: our example documents, re-saved by other software, were read back and + compared layer by layer. Line heights are now whole pixels, advances sit on a 1024-per-em grid and letter spacing + goes between glyphs only; all 230 self-sizing text boxes in the 7 documents match to the pixel (24 of 131 before + in the design-system example). One font family; real files still unchecked (gap 3). No number moves +- **2026-10-12** · Live sessions are per document: every open file has its own session or none, several run at + once (host one, join another, publish a third) and keep syncing behind the tab in front, the Share popover, + people, cursors, chat, spotlight and follow are the file in front's, `collab.peers` lists them all (`sessions`), + and closing a file ends its session only (before, one session followed every tab and starting another replaced it) +- **2026-10-12** · A public `figcraft-server`: `open_publish = true` lets anyone publish without the server key + (Share ▸ Publish to server asks the server first and shows who hosts it, its terms and how long unopened files are + kept), with an owner token shown once that manages the file later (`collab.manageFile`); limits per address, + idle expiry, reports (Share ▸ Report this file…), `block-ip` and `files list --sort`, `GET /healthz`, and + `packaging/server/install.sh` that sets up a Debian/Ubuntu machine with Caddy, ufw and backups + ([server.md](docs/server.md#running-a-public-server)). Numbers unchanged (hosting, not a Figma feature). +- **2026-10-12** · `.fig` export opens elsewhere (gap 1): written files were checked by opening them in other + software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. The + export now says that the document and its pages are visible and opaque, numbers its schema as the format does, + and writes outlines, text settings, standard font style names and line height units; effects such as glass and + noise, images and prototype interactions are still unchecked there, and variables are not written. Numbers + unchanged. +- **2026-10-12** · Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig + (experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document: layers, + geometry, paints with their images, strokes, effects, styled text, vector networks, auto layout, components and + instances with their overrides, and prototype interactions; what's left out (style links, variables, comments, + Dev Mode data…) comes back as warnings. Checked only by reading the files back with our own importer. File + formats 60%. +- **2026-10-12** · Keyboard shortcut parity (gap 4): a table test with a row for each of the 129 + shortcuts in the public documentation, macOS and Windows/Linux keys each, and the 48 bindings it + is silent on pinned; 126 bound (110 before), Run last plugin moved off a documented conflict on + Windows, 2 missing. New: others' cursors toggle, list levels on Cmd/Ctrl+] and [ while editing, + keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, Control + suspending snapping on macOS, Space/N/C/F while presenting, the Frame tool on A, pixel grid on + Shift+', link on Shift+Cmd+U, ungroup on Cmd/Ctrl+Backspace, suggest auto layout and go to main + component keys; shifted symbols on other keyboard layouts. +- **2026-10-12** · Plugin directory on `figcraft-server` (gap 6): publish a plugin folder with the server key + (`figcraft-cli plugin publish`), browse a server's plugins in Plugins ▸ Manage plugins ▸ Browse, install after a + confirmation that shows the permissions it asks for, update when a newer version appears, remove; bundles are + checked in full on the server and again in the app, downloads must match their SHA-256, installed plugins live + per user in the same sandbox; `plugin.directory.*` for scripts and MCP; the operator moderates with + `figcraft-server plugins`. Plugins & widgets 60%. +- **2026-10-12** · Plugin widgets and the panel tail (gap 6, plugin API 3; older plugins run unchanged): a widget + plugin puts a layer of its own on the canvas (Plugins ▸ Widgets, `widget.insert`), described by the plugin, + validated and built with locked layers inside; clicking a part it named calls it back with the widget's state + (kept in the layer, so it saves, copies, undoes, exports and syncs) as one undo step, and it redraws; + `widget.click` and `widget.list` for agents; a Counter example. Panels of several plugins open side by side and + plugin colour fields open the colour picker. Plugins & widgets 55%. +- **2026-10-12** · Layers and Assets panels behaviour suite (gap 4): several layers dropped together land + together, Collapse layers keeps the selection's rows open, dragging across eyes or padlocks sets several layers + at once, a Rename layers dialog for several layers (number codes as documented, regular expressions, a start + value), pages dragged to reorder, dividers, "Copy of" duplicates, Assets folders by page, frame and slash names, + and hidden-from-publishing names that decide both ways. +- **2026-10-12** · Vector multi-edit and the vector edit tail (gap 9): several vector layers in edit + mode at once (select, marquee, drag, nudge, delete, mirroring and radius across them), several + Bezier handles dragged together, rounded points beside curved segments, a stroke cap and join per + point, and the Pen connecting to its own points or continuing a selected open path. +- **2026-10-11** · Canvas navigation, guides and grids behaviour suite (gap 4): zoom, panning, the pixel + grid (now from 400%), rulers, guides (top-level frames only; select, Delete, nudge, Remove guide, + Alt-drag copies, Alt measures while dragging), layout grids (Auto counts; hidden on rotated frames), + outline mode (clicks reach layers behind) and measuring, each rule from the documentation. +- **2026-10-11** · Export and inspect behaviour suite (gap 4): exports cover what a layer paints (shadows + only where they fall, true stroke widths, no room for hidden layers, a mask group's mask), slices export what's under + them, layer names with slashes export into folders; export settings gained contents only, include bounding box and + the SVG options (outline text, ids, simplified strokes); Inspect shows colours in hex, RGB, CSS, HSL or HSB, CSS in + rem, blend modes and positions from the frame, and lists icons and images as assets. Dev Mode 70%. +- **2026-10-11** · `.fig` import's tail (gap 1): vectors open as editable vector networks with their + corner radii, handle mirroring and end caps (outlines when the network is missing or damaged); sticky notes, shapes + with text, connectors, tables and similar layers import as frames, text and vectors instead of being dropped; + set-variable and conditional prototype actions come across. File formats 58%, ready ~49%. +- **2026-10-11** · Paint and effects behaviour suite: every documented rule for fills, blend modes, + gradients, images, colour models, shadows, blurs and effect exports is a table-driven case. Fixes: Normal + groups isolate and clipping frames don't, plus darker is drawn, drop shadows hide behind translucent + layers in every export, Fill images reach the box's edges, "Normal" no longer means pass-through. + Paint 70%, effects 75%. +- **2026-10-11** · `.fig` import, best effort (gap 1): `file.open`, `file.import`, drag and drop, the CLI and + MCP open `.fig` files. A generic kiwi decoder reads each file's own schema, and the decoded nodes map by field name + onto the REST importer: layers, paints and styles, text runs, vectors as outlines, auto layout, components and + instances (expanded with their overrides and property values), variables, prototyping and images from the archive; + the rest is a warning. File formats 55%, ready ~49%. +- **2026-10-11** · Gamut warnings (gap 18): the colour picker of a Display P3 file flags colours + outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas. Showing P3 itself waits on + eframe/egui-wgpu exposing the surface colour space wgpu 30 already supports. +- **2026-10-11** · Several windows (gap 18): File ▸ New window, Open in new window on a tab or a + tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it + needs to and asks about unsaved changes when it closes (the main window too); the control channel + and MCP open, list, focus and capture windows. Hardware 60%. +- **2026-10-11** · Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls, + certificates always verified; `FIGCRAFT_CA_FILE` for a private authority), releases ship the server for Linux + x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` walks through deploying it on + a rented Linux server (systemd unit, Caddy or nginx, firewall, Docker, upgrades, backups). +- **2026-10-11** · Plugin panels and settings (gap 6, ABI v2; v1 plugins run unchanged): a plugin shows a panel of + fields and buttons drawn with FigCraft's widgets and is called back for each button or changed value (one undo step + per callback, same sandbox limits), `plugin.ui` / `plugin.event` / `plugin.close` drive panels from scripts and MCP, + and per-plugin settings are kept with the preferences; Swatch grid gained a palette panel. Plugins & widgets 45%. +- **2026-10-11** · Canvas fixes: clicks hit boolean groups where their result is (a click in a + subtracted hole reaches what's behind), a layer dragged inside a group stays in it, View ▸ Frame + outlines and View ▸ Slices draw, and a flaky idle test now waits for the render thread (gap 13). +- **2026-10-11** · Team libraries on `figcraft-server` (gap 10): Assets ▸ Libraries ▸ Publish to server keeps + the file's library on the server under the file (per-asset versions; the server's version is followed); other files + enable it by pasting a share link (a view link is enough), get "Library updates available" from a background check + and accept with overrides kept; the server counts which files use which components (Show usage, `library.usage`); + `library.swap` re-points instances, styles and variables to another library by names. Team libraries 70%. +- **2026-10-11** · Animated GIF fills (first frame on the canvas and in exports, played in the + presenter and the preview, waking the window only for the next frame) and TIFF images (gap 12). +- **2026-10-11** · The last three menu items got commands: File ▸ Open recent (kept in the + preferences, missing files greyed, Clear recent), outlines for the selection and mask outlines + (gap 21). Menu breadth 203/203. +- **2026-10-11** · Extended variable collections (a library collection's modes and variables in a + collection of the file, overridden per mode with reset, following library updates), variables + import/export (the REST local variables JSON, merged by name; CSS custom properties), gradient + stops and variant properties bound to variables (gap 14). +- **2026-10-11** · Live boolean editing: operands stay editable layers (double-click or Cmd/Ctrl-click + selects one, moving, resizing or editing its points redraws the result during the drag with every + operand outlined, per-operand operations from the boolean controls, prefix-cached recompute); + Design panel arc fields; the shape builder on strokes and open paths; one-sided variable widths + (gap 13). Feature breadth ~79%. +- **2026-10-10** · WASM plugins (#101: sandboxed wasmi runtime, manifest permissions, Plugins menu, + Run last, Manage, Rust SDK and two examples), docs refresh (#102), the multiplayer core without + networking (#103: client-scoped ids, change sets, server ordering and validation, client + reconciliation, convergence tests). Menu breadth 200/203; ready for real work ~47%. +- **2026-10-10** · Full re-measure against Figma desktop 126.10.7 under the craftrules progress-docs + standard: ready for real work ~46%, feature breadth ~76%, stage alpha; progress docs split out + (`docs/target-app-parity.md`, `gaps.md`, `roadmap.md`, `architecture.md`, the per-area parity + docs); `docs/parity.md` renamed `docs/parity-checklist.md`. +- **2026-10-10** · Day 2 (PRs #1–#99): vector edit mode depth (transform box, point snapping, arc + handles, shape builder, variable width), text depth (variable fonts, bidi, RTL names, style + browser), auto layout nesting and keyboard reordering, slots and library variables, components + at scale (2,000 instances), effects fidelity (glass, noise, texture, progressive blur), + prototyping (conditionals, expressions, device frames, responsive presentation), REST import of + prototypes and variables, SVG import fidelity, PDF print options, Dev Mode (code, annotations, + ready for dev, compare changes), comments polish, version history, keyboard shortcut parity, + accessibility, retained canvas and 50k-layer performance, no redraws while idle; v0.2.0 + released for every platform; multiplayer protocol designed. +- **2026-10-09** · Day 1: foundation (13 crates, layering, command registry, renderer, engine, + UI3 frontend, control channel), CLI and MCP, web build, PDF export, packaging, first depth + passes on text, components, prototyping and comments. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | +| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | +| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. | +| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | +| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | +| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | +| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | +| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged | +| 2026-10-14 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen and leaves it again; FigCraft's own item. Numbers unchanged | +| 2026-10-14 | minor | Colour picker: wrapped rows of page and recent swatches stay inset on both sides instead of running past the picker's right edge. Numbers unchanged | +| 2026-10-14 | minor | Server snapshots (#188): figcraft-server never replaces a readable snapshot with one too large to be read back, and reads snapshots up to twice the file cap (recent batches kept for reconnecting clients may take a file over it). Numbers unchanged | +| 2026-10-14 | minor | Preferences owner-only on Unix (#200): `state.json`, which holds online owner tokens, is written with mode 0600 from the first byte, and an older world-readable file is narrowed. Numbers unchanged | +| 2026-10-14 | minor | ruzstd 0.9.1 (#182): `.fig` import requires the release that fixes RUSTSEC-2026-0344 (a zstd decompression bomb). Numbers unchanged | +| 2026-10-14 | minor | Layer cycles (#205): `validate` reports children listed twice, pages with a parent and layers not reachable from a page (cycles, detached subtrees); `repair` drops them in one linear pass. Numbers unchanged | +| 2026-10-14 | minor | AppImage CLI: `figcraft.AppImage cli ` runs the bundled `figcraft-cli` (MCP server included) on Linux without a second download. Numbers unchanged | +| 2026-10-14 | minor | Boolean outline depth (#193): nested boolean groups share one outline depth budget instead of each level getting its own. Numbers unchanged | +| 2026-10-14 | minor | Command batches roll back as one: a failed `command.batch` (MCP `batch`) restores every open document and its state, and puts the tab in front back. Numbers unchanged | +| 2026-10-14 | minor | Desktop builds no longer rebuild the text crate on every build when the craft-fonts manifest is missing. Numbers unchanged | +| 2026-10-14 | minor | Direct3D 12 on Windows: the desktop app asks wgpu for Direct3D 12 there instead of Vulkan, so it starts without Vulkan loader errors on machines with stray overlay layers. Numbers unchanged | +| 2026-10-14 | minor | Plugin metering (#192): every host call and every byte copied between a plugin and the host costs fuel, so a plugin can't stall the app through cheap-looking host calls. Numbers unchanged | +| 2026-10-14 | minor | SVG/PDF export depth (#208): explicit preflight refusal beyond 256 descendant levels, propagated through public APIs, commands and CLI, with boundary/content and destination-preservation regressions. Numbers unchanged. | +| 2026-10-13 | minor | MCP/control transport hardening (#201): frame limits during reads, bounded connections and both UI request queues, socket inactivity timeouts; percentages and estimates unchanged | +| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | +| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | +| 2026-10-13 | minor | Repeat last command (M1, #181): keep the underlying command and parameters across repeats, reject self-reference, and retain one undo step per repeated edit. Numbers unchanged. | +| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | +| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | +| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | +| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. | +| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. | +| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | +| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | +| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | +| 2026-10-13 | minor | `.fig` export (gap 1): instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes are written and read back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Numbers unchanged | +| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials ~57% → ~59% (discoverability ×0.92 → ×0.94), its hours ~60–110 h → ~55–100 h; other numbers unchanged | +| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. The draft catalogs (French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese) translate the 71 strings and 5 command labels they lacked and the new ones. Numbers unchanged | +| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains | +| 2026-10-12 | minor | Sharing with a join code (M9, from the owner's use: sharing wasn't intuitive): the Share popover is one flow, **Online** (the file is published to the server in Preferences, built in `wss://collab.craftapps.dev/`, and the server names its edit and view links with nine-symbol codes like `K7M-2QX-9TD`) or **Local network** (the app hosts; a 20-symbol code carries its IPv4 address, port and a 44-bit secret, with check bits), one Start sharing button, the code large with Copy code and Copy link, who is here, can edit / can view online, and Stop sharing (online: the others' links and codes are revoked and they are disconnected, while the file stays on the server with its owner in it and can be shared again with new codes; removing it is a separate, confirmed step); **Join with a code** is one field (popover, home screen, Join dialog) for a code of either kind or a link; addresses, keys and ports moved under Advanced and Preferences ▸ Server for online sharing (reset, or empty to turn online sharing off). Protocol additions within version 2 (`Resolve` / `Resolved`, `Admin code`): the server keeps a stretched hash and the wrapped token, never the code, and limits wrong codes (10 a minute per address, a 15-minute lockout after 10 wrong, 1,000 wrong an hour for everyone); codes go with their link and file. Older servers leave the links in place of a code; older apps join by link. `collab.share`, `collab.join {code}`, `code` and `share` in `collab.peers`; the browser joins by code. Numbers unchanged | +| 2026-10-12 | minor | Live sessions per document (M9, a fix from real use): the engine keeps one session per open document instead of one for the app, the link carries several under their own ids (desktop, browser, tests), the window and the `collab.*` commands follow the active document, `collab.peers` gains `session` and `sessions`, `ui.inspect`'s `live` gains `session` and `sessions`; joining opens the session's file in its own tab without touching other sessions and closing a file ends its session only. Numbers unchanged | +| 2026-10-12 | minor | `.fig` export checked by opening written files in other software (gap 1): layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after fixes to what the export writes (visible document and pages, the format's schema numbering, outlines, text settings, font style names, line height units); effects such as glass and noise, images and prototype interactions still unchecked there. Numbers unchanged | +| 2026-10-12 | minor | Public `figcraft-server` hardened against resource exhaustion after a security review: messages capped by what a connection is for (a file's size when open), byte-bounded send queues and a bound on bytes waiting for the hub, files checked as they grow (refused batches roll back with the reason; per-address and total caps count growth), the core's recent-batch memory bounded in bytes, no confirmation for changes that couldn't be written, team libraries only in key-published files on public servers with unpacking bounded by the file cap, usage reports kept in memory, layered IPv6 limits (/64, /56, /48), a global publish rate, connections reserved for sessions, per-address download shares, library connections cut on revoke and removal, a private data directory and key, and safer install.sh, systemd and backups. Percentages and hours unchanged | +| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | +| 2026-10-12 | minor | Experimental `.fig` export (gap 1): `file.export {format: "fig"}`, File ▸ Export… ▸ .fig (experimental), the CLI and MCP write the whole document (layer tree, geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto layout, constraints, components, sets, instances with their overrides and property values, prototype interactions) with a kiwi encoder mirroring the decoder and the schema of the fields written; what's left out is a warning; verified only by round trips through our own importer. File formats 59% → 60%; import/export 84%/57% → 86%/58%; file exchange stays ×0.85; mainstream hours ~130–244 h → ~120–229 h; feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta (~155–280 h) and full parity (~265–523 h) unchanged at this rounding | +| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding | +| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h | +| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h | +| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h | +| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h | +| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35%, 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h | +| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h | +| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h | +| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page until they move their own. Ephemeral presence the host validates and rate-limits, passed only to clients that read it (older clients keep working). Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; collaboration 13% unchanged, 49–98 h → 46–92 h; feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h | +| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes (several layers dropped together, Collapse layers, eye and padlock sweeps, Rename layers dialog with the documented number codes, page dragging, dividers, "Copy of" duplicates, Assets folders, hidden-from-publishing names both ways). Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | +| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | +| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | +| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | +| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h (the Features table here still showed 80%/58%/4–8 h from before branches landed) | +| 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) | +| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h | +| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h (this table still showed 80%/58%/4–8 h from before branches landed); percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | +| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h (this table still showed 95%/72%/3–6 h from before the outlines landed); percentages unchanged at this rounding; full parity ~320–615 h → ~319–614 h | +| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53% (the row still showed the figures from before `.fig` import); Dev Mode 90%/64% → 93%/70%; mainstream weekly areas 68.1% → 68.4%; percentages and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | +| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation and its fixes: rounded vector points are drawn and exported, a boolean group looks like its top layer (a subtraction like the bottom one), Alt draws lines from the centre, stars keep to 3–60 points, delete and heal follows the curve, Shift-click on a point at a corner of the points' box deselects it, outline stroke keeps the stroke's colour style, opacity and effects, the miter field shows degrees, individual corners only for rectangles and frames, Option/Alt+Shift+F flattens, MCP `key` reaches the edit tools. Added: corner radius and star ratio handles, Count and Ratio fields, cut drags that divide shapes into layers, paint bucket drags. Shapes & vector 97%/68% → 98%/72%, 1–3 h → 2–4 h. Totals recomputed from the area table, which earlier rows had left stale: feature breadth ~80% → ~82% (core ~91% → ~93%), Features (depth) 57% → 61% (core 68% → 69%), 60–115 h → 56–109 h, ready for real work ~49% → ~51%, mainstream ~46% → ~48%; full parity ~321–618 h → ~319–615 h (the selection suite's −3/−5 h hadn't reached the totals) | +| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation and its fixes: Normal groups and frames isolate their children and clipping frames don't (canvas, SVG, PDF); plus darker drawn on the canvas and in PNG exports; drop shadows hidden behind what the layer covers unless shown behind transparent areas, SVG included, with filter regions that reach as far as the effects; Fill images cover the box to its edges; "Normal" names Normal in commands and the blend menu. Paint 85%/66% → 87%/70% (breadth from the GIF/TIFF row), 5–9 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h | +| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h | +| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h | +| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h | +| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) | +| 2026-10-11 | minor | Embedded image colour profiles (ICC in PNG/JPG/WebP/TIFF, PNG sRGB/gAMA/cHRM) converted into the file's colour space when decoded. Paint 85%/66% → 90%/67% (the row had lagged the parity doc), 5–9 h → 3–6 h; totals unchanged at this rounding | +| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h | +| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | +| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | +| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 in the desktop app and through MCP; eight fixes: recovery copies of unsaved documents reopen after a crash or a forced quit (`file.recover`), Ctrl moves and deletes by word in text on Windows and Linux, scrubbed fields keep their value, typing on a field Tab reached edits it, Escape closes dialogs, linear gradients keep square bands, panel hints wrap, the window-close question and "Open in new window" are translated. Percentages and hours unchanged | +| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | +| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | +| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | +| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | +| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | +| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~358–687 h | +| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | +| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | +| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | +| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | +| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | +| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | +| 2026-10-10 | minor | Readiness by audience with hours to ~95% (full ~400–750, mainstream ~180–330, essentials ~85–160) | +| 2026-10-10 | minor | Added essentials user (~53%) and mainstream practitioner (~43%); ready for real work unchanged at ~46% | +| 2026-10-10 | minor | Alpha gate (core workflows) checked under the revised stage rules: passes, stays alpha | +| 2026-10-10 | major | Rewritten to the craftrules progress-docs shape after a full re-measure; rubric, area narrative and inventory moved to docs/target-app-parity.md, gaps to docs/gaps.md, milestones to docs/roadmap.md. Depth ~79% (self-graded, 16 areas) → ready for real work ~46% (all dimensions); full parity 115–195 h → 420–790 h | +| 2026-10-10 | minor | Day-2 refreshes after each batch of PRs (depth 58% → 79%) | +| 2026-10-09 | major | First roadmap | diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index fd766735..ca91549b 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -1,653 +1,653 @@ -//! Structure: groups, frames, masks, boolean groups, flatten, outline stroke, auto layout, pages. - -use figcraft_color::{Color, Paint}; -use figcraft_doc::*; -use figcraft_geom::{Affine, BezPath, Rect, Shape}; -use serde_json::{Value, json}; - -use super::edit::{roots, z_sorted}; -use super::{CommandSpec, ok}; -use crate::params::*; -use crate::{EngineError, Result, Session, bad, spec}; - -pub fn specs() -> Vec { - vec![ - spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)), - spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup), - spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)), - spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup), - spec!( - "object.convertToGroup", - "Convert to group", - "{ids?} (frames become groups in place, their layers staying where they are; what only a frame has goes: auto layout, fills, strokes, corner radius, clipping, layout grids, guides and scrolling. Groups are left alone; instances, main components, layers inside instances and empty frames are refused)", - true, - |s, p| convert(s, p, "object.convertToGroup", false) - ), - spec!( - "object.convertToFrame", - "Convert to frame", - "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)", - true, - |s, p| convert(s, p, "object.convertToFrame", true) - ), - spec!( - "object.wrapInSection", - "Wrap in new section", - "{ids?, name?} (layers on the page or in a section; the section wraps them with some room)", - true, - |s, p| wrap(s, p, Wrap::Section) - ), - spec!( - "object.resizeToFit", - "Resize to fit", - "{ids?} (frames, sections and components shrink or grow to their contents; the contents stay put)", - true, - resize_to_fit - ), - spec!("object.mask", "Use as mask", "{ids?}", true, mask), - spec!("object.releaseMask", "Release mask", "{ids?}", true, |s, p| { - for i in req_targets("object.releaseMask", s, p)? { - s.doc_mut()?.get_mut(i)?.is_mask = false; - } - ok() - }), - spec!("bool.union", "Union selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.union", p, BoolOp::Union)), - spec!("bool.subtract", "Subtract selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.subtract", p, BoolOp::Subtract)), - spec!("bool.intersect", "Intersect selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.intersect", p, BoolOp::Intersect)), - spec!("bool.exclude", "Exclude selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.exclude", p, BoolOp::Exclude)), - spec!("bool.flatten", "Flatten boolean", "{ids?}", true, flatten), - spec!("vector.flatten", "Flatten", "{ids?}", true, flatten), - spec!("vector.outlineStroke", "Outline stroke", "{ids?}", true, outline_stroke), - spec!("layout.add", "Add auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add), - spec!("layout.remove", "Remove auto layout", "{ids?}", true, |s, p| { - for i in req_targets("layout.remove", s, p)? { - // Hugging needs auto layout and the children that filled have nothing to fill: both - // become fixed (the frame keeps filling its own auto layout parent). - let doc = s.doc_mut()?; - doc.get_mut(i)?.layout = None; - figcraft_layout::coherent_sizing(doc, i); - } - ok() - }), - spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add), - spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page), - spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| { - let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?; - let doc = s.doc_mut()?; - for n in doc.ids().collect::>() { - if let Some(node) = doc.node(n) - && node.plugin_data.contains_key("figcraft.thumbnail") - { - doc.get_mut(n)?.plugin_data.remove("figcraft.thumbnail"); - } - } - doc.get_mut(id)?.plugin_data.insert("figcraft.thumbnail".into(), "true".into()); - ok() - }), - spec!("object.rasterize", "Rasterize selection", "{ids?, scale? (default 2)}", true, rasterize), - spec!("vector.reverse", "Reverse path direction", "{ids?}", true, |s, p| { - for i in req_targets("vector.reverse", s, p)? { - if let NodeKind::Vector { network } = &mut s.doc_mut()?.get_mut(i)?.kind { - for seg in &mut network.segments { - std::mem::swap(&mut seg.a, &mut seg.b); - std::mem::swap(&mut seg.ta, &mut seg.tb); - } - } - } - ok() - }), - spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify), - spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path), - spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| { - crate::vectoredit::join_in_edit_mode(s, false).unwrap_or_else(|| join(s, p)) - }), - spec!("vector.smoothJoin", "Smooth join selection", "{ids?} (in vector edit mode: joins the selected points smoothly)", true, |s, p| { - crate::vectoredit::join_in_edit_mode(s, true).unwrap_or_else(|| join(s, p)) - }), - ] -} - -enum Wrap { - Group, - Frame, - Section, - Bool(BoolOp), -} - -/// Room a section made around layers leaves on each side. -const SECTION_PAD: f64 = 40.0; - -/// What the boolean commands do with what they're given. -const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group"; - -/// Wrap the selection in a new boolean group doing `op`. -pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result { - wrap(s, p, Wrap::Bool(op)) -} - -/// Wrap the selection in a new container at the topmost selected layer's place. -fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result { - let ids = req_targets("object.group", s, p)?; - let doc = s.doc_mut()?; - let ids = z_sorted(doc, &roots(doc, &ids)); - let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?; - let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?; - // Sections sit on the page or in sections, never in frames or groups. - let board = |doc: &Document, i: NodeId| { - doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) - }; - if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) { - return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section")); - } - if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) { - return Err(bad("object.group", "sections can't go in groups or frames")); - } - let index = doc.index_in_parent(top).map(|i| i + 1); - let mut bounds = doc.bounds_of(&ids).unwrap_or_default(); - if matches!(w, Wrap::Section) { - bounds = bounds.inflate(SECTION_PAD, SECTION_PAD); - } - let pw = doc.world_transform(parent); - let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds); - let (kind, base) = match &w { - Wrap::Group => (NodeKind::Group, "Group"), - Wrap::Frame => (NodeKind::Frame, "Frame"), - Wrap::Section => (NodeKind::Section { contents_hidden: false }, "Section"), - Wrap::Bool(op) => (NodeKind::BooleanOp { op: *op }, op.label()), - }; - let name = string(p, "name").unwrap_or_else(|| if matches!(w, Wrap::Bool(_)) { base.to_string() } else { doc.next_name(base) }); - let gid = doc.alloc_id(); - let mut g = match w { - Wrap::Section => Node::new_section(gid, name, local.width(), local.height()), - _ => Node::new(gid, kind, name, local.width(), local.height()), - }; - g.transform = Affine::translate((local.x0, local.y0)); - if let Wrap::Bool(op) = w { - // A boolean group looks like its top layer; a subtraction like the bottom one it cuts. - let source = if op == BoolOp::Subtract { ids.first() } else { ids.last() }; - if let Some(first) = source.and_then(|i| doc.node(*i)) { - g.fills = if first.fills.is_empty() { vec![Paint::solid(Color::DEFAULT_SHAPE)] } else { first.fills.clone() }; - g.strokes = first.strokes.clone(); - g.stroke = first.stroke.clone(); - g.effects = first.effects.clone(); - } - g.blend = figcraft_color::BlendMode::Normal; - } - if let Wrap::Frame = w { - g.clip = true; - } - // Insert the container where the topmost layer is. - doc.insert(g, parent, index)?; - for i in &ids { - doc.reparent(*i, gid, None)?; - } - if !matches!(w, Wrap::Frame | Wrap::Section) { - let _ = doc.fit_to_children(gid); - } - s.set_selection(vec![gid]); - Ok(json!({"id": gid})) -} - -/// Turn frames into groups or groups into frames in place (same id, name, layers, effects, -/// opacity, exports and interactions), the layers inside staying where they are on the page. -/// Nothing changes unless every layer can convert. -fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Result { - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let mut todo = Vec::new(); - for i in ids { - let n = doc.get(i)?; - let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); - match (&n.kind, to_frame) { - (NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"), - (NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"), - (NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => { - return refuse("is inside an instance: edit its main component, or detach the instance"); - } - (NodeKind::Frame, false) if n.children.is_empty() => return refuse("is empty: a group needs layers"), - (NodeKind::Frame, false) | (NodeKind::Group, true) => todo.push(i), - (NodeKind::Frame | NodeKind::Group, _) => {} - _ => return refuse("is neither a frame nor a group"), - } - } - for i in &todo { - if to_frame { - // The group's box as it is; nothing painted or clipped, so the look stays. - let n = doc.get_mut(*i)?; - n.kind = NodeKind::Frame; - n.clip = false; - n.fills.clear(); - n.strokes.clear(); - continue; - } - let n = doc.get_mut(*i)?; - n.kind = NodeKind::Group; - (n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None); - n.fills.clear(); - n.strokes.clear(); - n.grids.clear(); - n.guides.clear(); - n.corners = Corners::default(); - (n.styles.fill, n.styles.stroke, n.styles.grid) = (None, None, None); - // Variables bound to what went would bring it back. - n.bound.retain(|k, _| matches!(k.as_str(), "opacity" | "visible")); - let kids = n.children.clone(); - for k in kids { - // Out of the flow there is nothing to fill or to be absolute against. - doc.get_mut(k)?.child_layout.absolute = false; - } - figcraft_layout::coherent_sizing(doc, *i); - doc.fit_to_children(*i)?; - } - Ok(json!({"ids": todo})) -} - -fn ungroup(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("object.ungroup", s, p)?; - let doc = s.doc_mut()?; - let mut out = Vec::new(); - for g in ids { - let n = doc.get(g)?; - if !(matches!(n.kind, NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Frame | NodeKind::Section { .. })) { - continue; - } - let Some(parent) = n.parent else { continue }; - let idx = doc.index_in_parent(g).unwrap_or(0); - let kids = n.children.clone(); - // Ungrouping a group keeps its opacity on the children (multiplied). - let op = n.opacity; - for (k, c) in kids.iter().enumerate() { - doc.reparent(*c, parent, Some(idx + k))?; - // Released operands no longer combine with anything. - if doc.get(*c)?.bool_op.is_some() { - doc.get_mut(*c)?.bool_op = None; - } - if op < 1.0 { - let cn = doc.get_mut(*c)?; - cn.opacity *= op; - } - out.push(*c); - } - doc.remove(g)?; - } - s.set_selection(out.clone()); - Ok(json!({"ids": out})) -} - -fn mask(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("object.mask", s, p)?; - if ids.len() == 1 { - let doc = s.doc_mut()?; - let id = ids.first().copied().unwrap_or(NodeId(0)); - let n = doc.get_mut(id)?; - n.is_mask = !n.is_mask; - return ok(); - } - // Group, then the bottom-most child is the mask. - let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?; - let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?; - let doc = s.doc_mut()?; - if let Some(first) = doc.get(gid)?.children.first().copied() { - doc.get_mut(first)?.is_mask = true; - } - Ok(r) -} - -/// The outline of a node in its parent's space (shapes, text, booleans, groups). -pub fn outline_in_parent(doc: &Document, id: NodeId) -> Option { - let n = doc.node(id)?; - let (mut p, _) = figcraft_render::subtree_outline(doc, n, 0)?; - p.apply_affine(n.transform); - Some(p) -} - -fn flatten(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("vector.flatten", s, p)?; - let doc = s.doc_mut()?; - let ids = z_sorted(doc, &roots(doc, &ids)); - let top = *ids.last().ok_or_else(|| bad("vector.flatten", "nothing selected"))?; - let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?; - let index = doc.index_in_parent(top).map(|i| i + 1); - let pw = doc.world_transform(parent); - let mut path = BezPath::new(); - let mut even = false; - for i in &ids { - let Some(n) = doc.node(*i) else { continue }; - // Booleans flatten to their result; strokes don't get outlined. - let Some((mut op, rule)) = (match &n.kind { - NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0), - _ => figcraft_render::node_outline(doc, n), - }) else { - continue; - }; - even |= rule == figcraft_render::FillRule::EvenOdd; - // Into the common parent's space. - let w = doc.world_transform(*i); - op.apply_affine(pw.inverse() * w); - path.extend(op.elements().iter().copied()); - } - if path.elements().is_empty() { - return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into())); - } - let first = ids.first().and_then(|i| doc.node(*i)).cloned(); - let bb = path.bounding_box(); - path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut net = VectorNetwork::from_path(&path); - for r in &mut net.regions { - r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero }; - } - let vid = doc.alloc_id(); - let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into()); - let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height()); - v.transform = Affine::translate((bb.x0, bb.y0)); - if let Some(f) = first { - v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() { - vec![Paint::solid(Color::DEFAULT_SHAPE)] - } else { - f.fills.clone() - }; - v.strokes = f.strokes.clone(); - v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() }; - v.effects = f.effects.clone(); - v.opacity = f.opacity; - v.blend = if matches!(f.blend, figcraft_color::BlendMode::PassThrough) { figcraft_color::BlendMode::Normal } else { f.blend }; - } - doc.insert(v, parent, index)?; - for i in &ids { - doc.remove(*i)?; - } - s.set_selection(vec![vid]); - Ok(json!({"id": vid})) -} - -fn outline_stroke(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("vector.outlineStroke", s, p)?; - let mut out = Vec::new(); - for i in ids { - let doc = s.doc_mut()?; - let n = doc.get(i)?.clone(); - if !n.has_visible_stroke() { - continue; - } - let closed = figcraft_doc::geometry::is_closed(&n); - let Some((area, rule)) = (match &n.kind { - NodeKind::Text(_) => figcraft_render::node_outline(doc, &n) - .map(|(p, _)| (figcraft_render::draw::stroke_area(&n, &p, closed), figcraft_render::FillRule::Winding)), - _ => figcraft_render::stroke_fill(doc, &n), - }) else { - continue; - }; - let mut path = (*area).clone(); - // Simplify non-zero strokes into even-odd contours. Inside/outside rings already use - // even-odd: interpreting their same-direction contours as non-zero would fill the hole. - if rule == figcraft_render::FillRule::Winding - && let Ok(u) = figcraft_pathops::simplify(&path) - { - path = u; - } - let bb = path.bounding_box(); - path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut net = VectorNetwork::from_path(&path); - for r in &mut net.regions { - r.winding = WindingRule::EvenOdd; - } - let parent = n.parent.ok_or_else(|| bad("vector.outlineStroke", "page"))?; - let idx = doc.index_in_parent(i); - let vid = doc.alloc_id(); - let mut v = Node::new(vid, NodeKind::Vector { network: net }, n.name.clone(), bb.width(), bb.height()); - v.transform = n.transform * Affine::translate((bb.x0, bb.y0)); - // The stroke's paints (and its colour style) fill the outline. - v.fills = n.strokes.clone(); - v.strokes.clear(); - v.styles.fill = n.styles.stroke; - if n.fills.iter().any(|f| f.visible) && closed { - // Keep the fill as its own layer under the outline, grouped. - doc.insert(v, parent, idx.map(|x| x + 1))?; - let fill_id = i; - { - let f = doc.get_mut(fill_id)?; - f.strokes.clear(); - } - let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?; - if let Some(g) = id_of(&r["id"]) { - out.push(g); - } - continue; - } - // Standing in for the layer, the outline keeps how it is drawn and placed. - (v.opacity, v.blend, v.effects, v.styles.effect) = (n.opacity, n.blend, n.effects.clone(), n.styles.effect); - (v.constraints, v.child_layout, v.visible, v.locked) = (n.constraints, n.child_layout.clone(), n.visible, n.locked); - doc.insert(v, parent, idx.map(|x| x + 1))?; - doc.remove(i)?; - out.push(vid); - } - s.set_selection(out.clone()); - Ok(json!({"ids": out})) -} - -/// Infer direction, gap and padding from where children are (Figma's "Add auto layout"). -fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout { - let Some(f) = doc.node(frame) else { return AutoLayout::default() }; - let boxes: Vec = - f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect(); - if boxes.is_empty() { - return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() }; - } - let span_x = boxes.iter().map(|b| b.x1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.x0).fold(f64::MAX, f64::min); - let span_y = boxes.iter().map(|b| b.y1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.y0).fold(f64::MAX, f64::min); - let vertical = span_y > span_x; - let mut sorted = boxes.clone(); - sorted.sort_by(|a, b| if vertical { a.y0.total_cmp(&b.y0) } else { a.x0.total_cmp(&b.x0) }); - let gaps: Vec = - sorted.windows(2).filter_map(|w| Some(if vertical { w.get(1)?.y0 - w.first()?.y1 } else { w.get(1)?.x0 - w.first()?.x1 })).collect(); - let gap = if gaps.is_empty() { 10.0 } else { (gaps.iter().sum::() / gaps.len() as f64).max(0.0).round() }; - let all = boxes.iter().copied().reduce(|a, b| a.union(b)).unwrap_or_default(); - let pad = [all.y0.max(0.0), (f.width - all.x1).max(0.0), (f.height - all.y1).max(0.0), all.x0.max(0.0)].map(f64::round); - AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() } -} - -fn layout_add(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("layout.add", s, p)?; - let dir = string(p, "direction"); - let doc = s.doc()?; - let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())); - let targets = if all_frames { - ids - } else { - // Wrap non-frames in a new frame without a fill, then add auto layout to it. - let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?; - let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?; - let f = s.doc_mut()?.get_mut(fid)?; - f.fills.clear(); - f.clip = false; - vec![fid] - }; - let doc = s.doc_mut()?; - for f in &targets { - let mut al = infer_layout(doc, *f); - if let Some(d) = &dir { - al.mode = match d.to_ascii_lowercase().as_str() { - "vertical" | "column" => LayoutMode::Vertical, - "grid" => LayoutMode::Grid, - _ => LayoutMode::Horizontal, - }; - } - // Children in reading order along the axis. - let mut kids = doc.get(*f)?.children.clone(); - let vertical = al.mode == LayoutMode::Vertical; - kids.sort_by(|a, b| { - let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); - let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); - if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) } - }); - let n = doc.get_mut(*f)?; - n.children = kids; - n.layout = Some(al); - n.child_layout.sizing_h = Sizing::Hug; - n.child_layout.sizing_v = Sizing::Hug; - } - s.set_selection(targets.clone()); - Ok(json!({"ids": targets})) -} - -fn move_to_page(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("object.moveToPage", s, p)?; - let page = req_id("object.moveToPage", p, "page")?; - let doc = s.doc_mut()?; - if !doc.node(page).is_some_and(Node::is_page) { - return Err(bad("object.moveToPage", format!("{page} is not a page"))); - } - if doc.is_divider(page) { - return Err(bad("object.moveToPage", "a divider holds no layers")); - } - for i in z_sorted(doc, &roots(doc, &ids)) { - doc.reparent(i, page, None)?; - } - s.set_selection(vec![]); - ok() -} - -fn rasterize(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("object.rasterize", s, p)?; - let scale = num_or(p, "scale", 2.0); - let mut out = Vec::new(); - for i in ids { - let doc = s.doc()?; - let Some(pix) = figcraft_render::render_node(doc, i, scale) else { continue }; - let png = figcraft_render::encode_png(&pix).map_err(EngineError::Other)?; - let b = figcraft_render::export_bounds(doc, i).unwrap_or_default(); - let parent = doc.get(i)?.parent.ok_or_else(|| bad("object.rasterize", "page"))?; - let idx = doc.index_in_parent(i); - let pw = doc.world_transform(parent); - let name = doc.get(i)?.name.clone(); - let docm = s.doc_mut()?; - let (hash, _, _) = figcraft_format::add_image(docm, png).map_err(|e| EngineError::Other(e.to_string()))?; - let rid = docm.alloc_id(); - let mut r = Node::new(rid, NodeKind::Rectangle, name, b.width(), b.height()); - r.transform = pw.inverse() * Affine::translate((b.x0, b.y0)); - r.fills = vec![Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; - docm.insert(r, parent, idx.map(|x| x + 1))?; - docm.remove(i)?; - out.push(rid); - } - s.set_selection(out.clone()); - Ok(json!({"ids": out})) -} - -fn simplify(s: &mut Session, p: &Value) -> Result { - let tol = num_or(p, "tolerance", 0.5).max(0.01); - for i in req_targets("vector.simplify", s, p)? { - let doc = s.doc_mut()?; - let n = doc.get_mut(i)?; - if let NodeKind::Vector { network } = &mut n.kind { - let fill = network.fill_path(); - let src = if fill.elements().is_empty() { network.stroke_path() } else { fill }; - let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default()); - let regions = !network.regions.is_empty(); - let mut net = VectorNetwork::from_path(&simplified); - if !regions { - net.regions.clear(); - } - *network = net; - } - } - ok() -} - -fn offset_path(s: &mut Session, p: &Value) -> Result { - let d = req_num("vector.offset", p, "offset")?; - for i in req_targets("vector.offset", s, p)? { - let doc = s.doc_mut()?; - let n = doc.get(i)?.clone(); - let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue }; - let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() }; - let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props); - let res = if d >= 0.0 { - figcraft_pathops::boolean(&[path, ring], BoolOp::Union) - } else { - figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract) - }; - let Ok(mut res) = res else { continue }; - let bb = res.bounding_box(); - res.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let nm = doc.get_mut(i)?; - let mut net = VectorNetwork::from_path(&res); - for r in &mut net.regions { - r.winding = WindingRule::EvenOdd; - } - nm.kind = NodeKind::Vector { network: net }; - nm.transform *= Affine::translate((bb.x0, bb.y0)); - nm.width = bb.width(); - nm.height = bb.height(); - } - ok() -} - -/// Join open vectors whose end points touch into one vector. -fn join(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("vector.join", s, p)?; - if ids.len() < 2 { - // Close a single open path. - if let Some(i) = ids.first() { - let n = s.doc_mut()?.get_mut(*i)?; - if let NodeKind::Vector { network } = &mut n.kind - && network.regions.is_empty() - && network.vertices.len() > 2 - { - let last = network.vertices.len() - 1; - network.segments.push(Segment::line(last, 0)); - let all: Vec = (0..network.segments.len()).collect(); - network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero }); - } - } - return ok(); - } - flatten(s, &json!({"ids": ids})) -} - -/// Fit containers to their visible children's bounds, keeping the children where they are on the -/// page (auto layout frames hug instead); rotated containers fit in their own axes. -fn resize_to_fit(s: &mut Session, p: &Value) -> Result { - let cmd = "object.resizeToFit"; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let mut fitted = Vec::new(); - for id in ids { - let Some(n) = doc.node(id).cloned() else { continue }; - let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }); - if !container || n.children.is_empty() || matches!(n.kind, NodeKind::Instance { .. }) { - continue; - } - // Auto layout frames fit by hugging their contents. - if n.layout.is_some() { - let node = doc.get_mut(id)?; - node.child_layout.sizing_h = Sizing::Hug; - node.child_layout.sizing_v = Sizing::Hug; - fitted.push(id); - continue; - } - // Children's bounds in the container's own coordinates. - let mut b: Option = None; - for c in n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible) { - let r = figcraft_geom::transform_rect_bbox(c.transform, c.local_rect()); - if r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite() { - b = Some(b.map_or(r, |u| u.union(r))); - } - } - let Some(b) = b else { continue }; - let (w, h) = (b.width().max(0.01), b.height().max(0.01)); - // Move the container by the offset, and its children back by it. - let shift = Affine::translate((b.x0, b.y0)); - let node = doc.get_mut(id)?; - node.transform *= shift; - node.width = w; - node.height = h; - for c in &n.children { - if let Ok(cn) = doc.get_mut(*c) { - cn.transform = shift.inverse() * cn.transform; - } - } - fitted.push(id); - } - Ok(json!({"ids": fitted})) -} +//! Structure: groups, frames, masks, boolean groups, flatten, outline stroke, auto layout, pages. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::*; +use figcraft_geom::{Affine, BezPath, Rect, Shape}; +use serde_json::{Value, json}; + +use super::edit::{roots, z_sorted}; +use super::{CommandSpec, ok}; +use crate::params::*; +use crate::{EngineError, Result, Session, bad, spec}; + +pub fn specs() -> Vec { + vec![ + spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)), + spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup), + spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)), + spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup), + spec!( + "object.convertToGroup", + "Convert to group", + "{ids?} (frames become groups in place, their layers staying where they are; what only a frame has goes: auto layout, fills, strokes, corner radius, clipping, layout grids, guides and scrolling. Groups are left alone; instances, main components, layers inside instances and empty frames are refused)", + true, + |s, p| convert(s, p, "object.convertToGroup", false) + ), + spec!( + "object.convertToFrame", + "Convert to frame", + "{ids?} (groups become frames of the same box in place, their layers staying where they are: no fill, not clipping. Frames are left alone; instances, main components and layers inside instances are refused)", + true, + |s, p| convert(s, p, "object.convertToFrame", true) + ), + spec!( + "object.wrapInSection", + "Wrap in new section", + "{ids?, name?} (layers on the page or in a section; the section wraps them with some room)", + true, + |s, p| wrap(s, p, Wrap::Section) + ), + spec!( + "object.resizeToFit", + "Resize to fit", + "{ids?} (frames, sections and components shrink or grow to their contents; the contents stay put)", + true, + resize_to_fit + ), + spec!("object.mask", "Use as mask", "{ids?}", true, mask), + spec!("object.releaseMask", "Release mask", "{ids?}", true, |s, p| { + for i in req_targets("object.releaseMask", s, p)? { + s.doc_mut()?.get_mut(i)?.is_mask = false; + } + ok() + }), + spec!("bool.union", "Union selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.union", p, BoolOp::Union)), + spec!("bool.subtract", "Subtract selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.subtract", p, BoolOp::Subtract)), + spec!("bool.intersect", "Intersect selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.intersect", p, BoolOp::Intersect)), + spec!("bool.exclude", "Exclude selection", BOOL_PARAMS, true, |s, p| crate::booleans::apply(s, "bool.exclude", p, BoolOp::Exclude)), + spec!("bool.flatten", "Flatten boolean", "{ids?}", true, flatten), + spec!("vector.flatten", "Flatten", "{ids?}", true, flatten), + spec!("vector.outlineStroke", "Outline stroke", "{ids?}", true, outline_stroke), + spec!("layout.add", "Add auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add), + spec!("layout.remove", "Remove auto layout", "{ids?}", true, |s, p| { + for i in req_targets("layout.remove", s, p)? { + // Hugging needs auto layout and the children that filled have nothing to fill: both + // become fixed (the frame keeps filling its own auto layout parent). + let doc = s.doc_mut()?; + doc.get_mut(i)?.layout = None; + figcraft_layout::coherent_sizing(doc, i); + } + ok() + }), + spec!("layout.suggest", "Suggest auto layout", "{ids?}", true, layout_add), + spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page), + spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| { + let id = targets(s, p).first().copied().ok_or_else(|| bad("object.setThumbnail", "select a frame"))?; + let doc = s.doc_mut()?; + for n in doc.ids().collect::>() { + if let Some(node) = doc.node(n) + && node.plugin_data.contains_key("figcraft.thumbnail") + { + doc.get_mut(n)?.plugin_data.remove("figcraft.thumbnail"); + } + } + doc.get_mut(id)?.plugin_data.insert("figcraft.thumbnail".into(), "true".into()); + ok() + }), + spec!("object.rasterize", "Rasterize selection", "{ids?, scale? (default 2)}", true, rasterize), + spec!("vector.reverse", "Reverse path direction", "{ids?}", true, |s, p| { + for i in req_targets("vector.reverse", s, p)? { + if let NodeKind::Vector { network } = &mut s.doc_mut()?.get_mut(i)?.kind { + for seg in &mut network.segments { + std::mem::swap(&mut seg.a, &mut seg.b); + std::mem::swap(&mut seg.ta, &mut seg.tb); + } + } + } + ok() + }), + spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify), + spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path), + spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| { + crate::vectoredit::join_in_edit_mode(s, false).unwrap_or_else(|| join(s, p)) + }), + spec!("vector.smoothJoin", "Smooth join selection", "{ids?} (in vector edit mode: joins the selected points smoothly)", true, |s, p| { + crate::vectoredit::join_in_edit_mode(s, true).unwrap_or_else(|| join(s, p)) + }), + ] +} + +enum Wrap { + Group, + Frame, + Section, + Bool(BoolOp), +} + +/// Room a section made around layers leaves on each side. +const SECTION_PAD: f64 = 40.0; + +/// What the boolean commands do with what they're given. +const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group"; + +/// Wrap the selection in a new boolean group doing `op`. +pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result { + wrap(s, p, Wrap::Bool(op)) +} + +/// Wrap the selection in a new container at the topmost selected layer's place. +fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result { + let ids = req_targets("object.group", s, p)?; + let doc = s.doc_mut()?; + let ids = z_sorted(doc, &roots(doc, &ids)); + let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?; + let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?; + // Sections sit on the page or in sections, never in frames or groups. + let board = |doc: &Document, i: NodeId| { + doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) + }; + if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) { + return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section")); + } + if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) { + return Err(bad("object.group", "sections can't go in groups or frames")); + } + let index = doc.index_in_parent(top).map(|i| i + 1); + let mut bounds = doc.bounds_of(&ids).unwrap_or_default(); + if matches!(w, Wrap::Section) { + bounds = bounds.inflate(SECTION_PAD, SECTION_PAD); + } + let pw = doc.world_transform(parent); + let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds); + let (kind, base) = match &w { + Wrap::Group => (NodeKind::Group, "Group"), + Wrap::Frame => (NodeKind::Frame, "Frame"), + Wrap::Section => (NodeKind::Section { contents_hidden: false }, "Section"), + Wrap::Bool(op) => (NodeKind::BooleanOp { op: *op }, op.label()), + }; + let name = string(p, "name").unwrap_or_else(|| if matches!(w, Wrap::Bool(_)) { base.to_string() } else { doc.next_name(base) }); + let gid = doc.alloc_id(); + let mut g = match w { + Wrap::Section => Node::new_section(gid, name, local.width(), local.height()), + _ => Node::new(gid, kind, name, local.width(), local.height()), + }; + g.transform = Affine::translate((local.x0, local.y0)); + if let Wrap::Bool(op) = w { + // A boolean group looks like its top layer; a subtraction like the bottom one it cuts. + let source = if op == BoolOp::Subtract { ids.first() } else { ids.last() }; + if let Some(first) = source.and_then(|i| doc.node(*i)) { + g.fills = if first.fills.is_empty() { vec![Paint::solid(Color::DEFAULT_SHAPE)] } else { first.fills.clone() }; + g.strokes = first.strokes.clone(); + g.stroke = first.stroke.clone(); + g.effects = first.effects.clone(); + } + g.blend = figcraft_color::BlendMode::Normal; + } + if let Wrap::Frame = w { + g.clip = true; + } + // Insert the container where the topmost layer is. + doc.insert(g, parent, index)?; + for i in &ids { + doc.reparent(*i, gid, None)?; + } + if !matches!(w, Wrap::Frame | Wrap::Section) { + let _ = doc.fit_to_children(gid); + } + s.set_selection(vec![gid]); + Ok(json!({"id": gid})) +} + +/// Turn frames into groups or groups into frames in place (same id, name, layers, effects, +/// opacity, exports and interactions), the layers inside staying where they are on the page. +/// Nothing changes unless every layer can convert. +fn convert(s: &mut Session, p: &Value, cmd: &'static str, to_frame: bool) -> Result { + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut todo = Vec::new(); + for i in ids { + let n = doc.get(i)?; + let refuse = |why: &str| Err(bad(cmd, format!("\"{}\" {why}", n.name))); + match (&n.kind, to_frame) { + (NodeKind::Instance { .. }, _) => return refuse("is an instance: its layers come from its main component (detach it first)"), + (NodeKind::Component { .. }, _) => return refuse("is a main component, which stays a frame"), + (NodeKind::Frame | NodeKind::Group, _) if crate::components::owning_instance(doc, i).is_some() => { + return refuse("is inside an instance: edit its main component, or detach the instance"); + } + (NodeKind::Frame, false) if n.children.is_empty() => return refuse("is empty: a group needs layers"), + (NodeKind::Frame, false) | (NodeKind::Group, true) => todo.push(i), + (NodeKind::Frame | NodeKind::Group, _) => {} + _ => return refuse("is neither a frame nor a group"), + } + } + for i in &todo { + if to_frame { + // The group's box as it is; nothing painted or clipped, so the look stays. + let n = doc.get_mut(*i)?; + n.kind = NodeKind::Frame; + n.clip = false; + n.fills.clear(); + n.strokes.clear(); + continue; + } + let n = doc.get_mut(*i)?; + n.kind = NodeKind::Group; + (n.layout, n.clip, n.overflow) = (None, false, proto::Overflow::None); + n.fills.clear(); + n.strokes.clear(); + n.grids.clear(); + n.guides.clear(); + n.corners = Corners::default(); + (n.styles.fill, n.styles.stroke, n.styles.grid) = (None, None, None); + // Variables bound to what went would bring it back. + n.bound.retain(|k, _| matches!(k.as_str(), "opacity" | "visible")); + let kids = n.children.clone(); + for k in kids { + // Out of the flow there is nothing to fill or to be absolute against. + doc.get_mut(k)?.child_layout.absolute = false; + } + figcraft_layout::coherent_sizing(doc, *i); + doc.fit_to_children(*i)?; + } + Ok(json!({"ids": todo})) +} + +fn ungroup(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("object.ungroup", s, p)?; + let doc = s.doc_mut()?; + let mut out = Vec::new(); + for g in ids { + let n = doc.get(g)?; + if !(matches!(n.kind, NodeKind::Group | NodeKind::BooleanOp { .. } | NodeKind::Frame | NodeKind::Section { .. })) { + continue; + } + let Some(parent) = n.parent else { continue }; + let idx = doc.index_in_parent(g).unwrap_or(0); + let kids = n.children.clone(); + // Ungrouping a group keeps its opacity on the children (multiplied). + let op = n.opacity; + for (k, c) in kids.iter().enumerate() { + doc.reparent(*c, parent, Some(idx + k))?; + // Released operands no longer combine with anything. + if doc.get(*c)?.bool_op.is_some() { + doc.get_mut(*c)?.bool_op = None; + } + if op < 1.0 { + let cn = doc.get_mut(*c)?; + cn.opacity *= op; + } + out.push(*c); + } + doc.remove(g)?; + } + s.set_selection(out.clone()); + Ok(json!({"ids": out})) +} + +fn mask(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("object.mask", s, p)?; + if ids.len() == 1 { + let doc = s.doc_mut()?; + let id = ids.first().copied().unwrap_or(NodeId(0)); + let n = doc.get_mut(id)?; + n.is_mask = !n.is_mask; + return ok(); + } + // Group, then the bottom-most child is the mask. + let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?; + let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?; + let doc = s.doc_mut()?; + if let Some(first) = doc.get(gid)?.children.first().copied() { + doc.get_mut(first)?.is_mask = true; + } + Ok(r) +} + +/// The outline of a node in its parent's space (shapes, text, booleans, groups). +pub fn outline_in_parent(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + let (mut p, _) = figcraft_render::subtree_outline(doc, n, 0)?; + p.apply_affine(n.transform); + Some(p) +} + +fn flatten(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("vector.flatten", s, p)?; + let doc = s.doc_mut()?; + let ids = z_sorted(doc, &roots(doc, &ids)); + let top = *ids.last().ok_or_else(|| bad("vector.flatten", "nothing selected"))?; + let parent = doc.get(top)?.parent.ok_or_else(|| bad("vector.flatten", "can't flatten a page"))?; + let index = doc.index_in_parent(top).map(|i| i + 1); + let pw = doc.world_transform(parent); + let mut path = BezPath::new(); + let mut even = false; + for i in &ids { + let Some(n) = doc.node(*i) else { continue }; + // Booleans flatten to their result; strokes don't get outlined. + let Some((mut op, rule)) = (match &n.kind { + NodeKind::Group | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } => figcraft_render::subtree_outline(doc, n, 0), + _ => figcraft_render::node_outline(doc, n), + }) else { + continue; + }; + even |= rule == figcraft_render::FillRule::EvenOdd; + // Into the common parent's space. + let w = doc.world_transform(*i); + op.apply_affine(pw.inverse() * w); + path.extend(op.elements().iter().copied()); + } + if path.elements().is_empty() { + return Err(EngineError::Disabled("vector.flatten".into(), "nothing to flatten".into())); + } + let first = ids.first().and_then(|i| doc.node(*i)).cloned(); + let bb = path.bounding_box(); + path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); + let mut net = VectorNetwork::from_path(&path); + for r in &mut net.regions { + r.winding = if even { WindingRule::EvenOdd } else { WindingRule::NonZero }; + } + let vid = doc.alloc_id(); + let name = first.as_ref().map(|f| f.name.clone()).unwrap_or_else(|| "Vector".into()); + let mut v = Node::new(vid, NodeKind::Vector { network: net }, name, bb.width(), bb.height()); + v.transform = Affine::translate((bb.x0, bb.y0)); + if let Some(f) = first { + v.fills = if matches!(f.kind, NodeKind::Group | NodeKind::Line) && f.fills.is_empty() { + vec![Paint::solid(Color::DEFAULT_SHAPE)] + } else { + f.fills.clone() + }; + v.strokes = f.strokes.clone(); + v.stroke = StrokeProps { align: StrokeAlign::Center, ..f.stroke.clone() }; + v.effects = f.effects.clone(); + v.opacity = f.opacity; + v.blend = if matches!(f.blend, figcraft_color::BlendMode::PassThrough) { figcraft_color::BlendMode::Normal } else { f.blend }; + } + doc.insert(v, parent, index)?; + for i in &ids { + doc.remove(*i)?; + } + s.set_selection(vec![vid]); + Ok(json!({"id": vid})) +} + +fn outline_stroke(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("vector.outlineStroke", s, p)?; + let mut out = Vec::new(); + for i in ids { + let doc = s.doc_mut()?; + let n = doc.get(i)?.clone(); + if !n.has_visible_stroke() { + continue; + } + let closed = figcraft_doc::geometry::is_closed(&n); + let Some((area, rule)) = (match &n.kind { + NodeKind::Text(_) => figcraft_render::node_outline(doc, &n) + .map(|(p, _)| (figcraft_render::draw::stroke_area(&n, &p, closed), figcraft_render::FillRule::Winding)), + _ => figcraft_render::stroke_fill(doc, &n), + }) else { + continue; + }; + let mut path = (*area).clone(); + // Simplify non-zero strokes into even-odd contours. Inside/outside rings already use + // even-odd: interpreting their same-direction contours as non-zero would fill the hole. + if rule == figcraft_render::FillRule::Winding + && let Ok(u) = figcraft_pathops::simplify(&path) + { + path = u; + } + let bb = path.bounding_box(); + path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); + let mut net = VectorNetwork::from_path(&path); + for r in &mut net.regions { + r.winding = WindingRule::EvenOdd; + } + let parent = n.parent.ok_or_else(|| bad("vector.outlineStroke", "page"))?; + let idx = doc.index_in_parent(i); + let vid = doc.alloc_id(); + let mut v = Node::new(vid, NodeKind::Vector { network: net }, n.name.clone(), bb.width(), bb.height()); + v.transform = n.transform * Affine::translate((bb.x0, bb.y0)); + // The stroke's paints (and its colour style) fill the outline. + v.fills = n.strokes.clone(); + v.strokes.clear(); + v.styles.fill = n.styles.stroke; + if n.fills.iter().any(|f| f.visible) && closed { + // Keep the fill as its own layer under the outline, grouped. + doc.insert(v, parent, idx.map(|x| x + 1))?; + let fill_id = i; + { + let f = doc.get_mut(fill_id)?; + f.strokes.clear(); + } + let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?; + if let Some(g) = id_of(&r["id"]) { + out.push(g); + } + continue; + } + // Standing in for the layer, the outline keeps how it is drawn and placed. + (v.opacity, v.blend, v.effects, v.styles.effect) = (n.opacity, n.blend, n.effects.clone(), n.styles.effect); + (v.constraints, v.child_layout, v.visible, v.locked) = (n.constraints, n.child_layout.clone(), n.visible, n.locked); + doc.insert(v, parent, idx.map(|x| x + 1))?; + doc.remove(i)?; + out.push(vid); + } + s.set_selection(out.clone()); + Ok(json!({"ids": out})) +} + +/// Infer direction, gap and padding from where children are (Figma's "Add auto layout"). +fn infer_layout(doc: &Document, frame: NodeId) -> AutoLayout { + let Some(f) = doc.node(frame) else { return AutoLayout::default() }; + let boxes: Vec = + f.children.iter().filter_map(|c| doc.node(*c)).map(|c| figcraft_geom::transform_rect_bbox(c.transform, c.local_rect())).collect(); + if boxes.is_empty() { + return AutoLayout { padding: [10.0; 4], gap: 10.0, ..Default::default() }; + } + let span_x = boxes.iter().map(|b| b.x1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.x0).fold(f64::MAX, f64::min); + let span_y = boxes.iter().map(|b| b.y1).fold(f64::MIN, f64::max) - boxes.iter().map(|b| b.y0).fold(f64::MAX, f64::min); + let vertical = span_y > span_x; + let mut sorted = boxes.clone(); + sorted.sort_by(|a, b| if vertical { a.y0.total_cmp(&b.y0) } else { a.x0.total_cmp(&b.x0) }); + let gaps: Vec = + sorted.windows(2).filter_map(|w| Some(if vertical { w.get(1)?.y0 - w.first()?.y1 } else { w.get(1)?.x0 - w.first()?.x1 })).collect(); + let gap = if gaps.is_empty() { 10.0 } else { (gaps.iter().sum::() / gaps.len() as f64).max(0.0).round() }; + let all = boxes.iter().copied().reduce(|a, b| a.union(b)).unwrap_or_default(); + let pad = [all.y0.max(0.0), (f.width - all.x1).max(0.0), (f.height - all.y1).max(0.0), all.x0.max(0.0)].map(f64::round); + AutoLayout { mode: if vertical { LayoutMode::Vertical } else { LayoutMode::Horizontal }, gap, padding: pad, ..Default::default() } +} + +fn layout_add(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("layout.add", s, p)?; + let dir = string(p, "direction"); + let doc = s.doc()?; + let all_frames = ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none())); + let targets = if all_frames { + ids + } else { + // Wrap non-frames in a new frame without a fill, then add auto layout to it. + let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?; + let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?; + let f = s.doc_mut()?.get_mut(fid)?; + f.fills.clear(); + f.clip = false; + vec![fid] + }; + let doc = s.doc_mut()?; + for f in &targets { + let mut al = infer_layout(doc, *f); + if let Some(d) = &dir { + al.mode = match d.to_ascii_lowercase().as_str() { + "vertical" | "column" => LayoutMode::Vertical, + "grid" => LayoutMode::Grid, + _ => LayoutMode::Horizontal, + }; + } + // Children in reading order along the axis. + let mut kids = doc.get(*f)?.children.clone(); + let vertical = al.mode == LayoutMode::Vertical; + kids.sort_by(|a, b| { + let pa = doc.node(*a).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); + let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default(); + if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) } + }); + let n = doc.get_mut(*f)?; + n.children = kids; + n.layout = Some(al); + n.child_layout.sizing_h = Sizing::Hug; + n.child_layout.sizing_v = Sizing::Hug; + } + s.set_selection(targets.clone()); + Ok(json!({"ids": targets})) +} + +fn move_to_page(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("object.moveToPage", s, p)?; + let page = req_id("object.moveToPage", p, "page")?; + let doc = s.doc_mut()?; + if !doc.node(page).is_some_and(Node::is_page) { + return Err(bad("object.moveToPage", format!("{page} is not a page"))); + } + if doc.is_divider(page) { + return Err(bad("object.moveToPage", "a divider holds no layers")); + } + for i in z_sorted(doc, &roots(doc, &ids)) { + doc.reparent(i, page, None)?; + } + s.set_selection(vec![]); + ok() +} + +fn rasterize(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("object.rasterize", s, p)?; + let scale = num_or(p, "scale", 2.0); + let mut out = Vec::new(); + for i in ids { + let doc = s.doc()?; + let Some(pix) = figcraft_render::render_node(doc, i, scale) else { continue }; + let png = figcraft_render::encode_png(&pix).map_err(EngineError::Other)?; + let b = figcraft_render::export_bounds(doc, i).unwrap_or_default(); + let parent = doc.get(i)?.parent.ok_or_else(|| bad("object.rasterize", "page"))?; + let idx = doc.index_in_parent(i); + let pw = doc.world_transform(parent); + let name = doc.get(i)?.name.clone(); + let docm = s.doc_mut()?; + let (hash, _, _) = figcraft_format::add_image(docm, png).map_err(|e| EngineError::Other(e.to_string()))?; + let rid = docm.alloc_id(); + let mut r = Node::new(rid, NodeKind::Rectangle, name, b.width(), b.height()); + r.transform = pw.inverse() * Affine::translate((b.x0, b.y0)); + r.fills = vec![Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; + docm.insert(r, parent, idx.map(|x| x + 1))?; + docm.remove(i)?; + out.push(rid); + } + s.set_selection(out.clone()); + Ok(json!({"ids": out})) +} + +fn simplify(s: &mut Session, p: &Value) -> Result { + let tol = num_or(p, "tolerance", 0.5).max(0.01); + for i in req_targets("vector.simplify", s, p)? { + let doc = s.doc_mut()?; + let n = doc.get_mut(i)?; + if let NodeKind::Vector { network } = &mut n.kind { + let fill = network.fill_path(); + let src = if fill.elements().is_empty() { network.stroke_path() } else { fill }; + let simplified = figcraft_geom::kurbo::simplify::simplify_bezpath(src.iter(), tol, &Default::default()); + let regions = !network.regions.is_empty(); + let mut net = VectorNetwork::from_path(&simplified); + if !regions { + net.regions.clear(); + } + *network = net; + } + } + ok() +} + +fn offset_path(s: &mut Session, p: &Value) -> Result { + let d = req_num("vector.offset", p, "offset")?; + for i in req_targets("vector.offset", s, p)? { + let doc = s.doc_mut()?; + let n = doc.get(i)?.clone(); + let Some((path, _)) = figcraft_render::node_outline(doc, &n) else { continue }; + let props = StrokeProps { weight: d.abs() * 2.0, join: StrokeJoin::Round, align: StrokeAlign::Center, ..Default::default() }; + let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props); + let res = if d >= 0.0 { + figcraft_pathops::boolean(&[path, ring], BoolOp::Union) + } else { + figcraft_pathops::boolean(&[path, ring], BoolOp::Subtract) + }; + let Ok(mut res) = res else { continue }; + let bb = res.bounding_box(); + res.apply_affine(Affine::translate((-bb.x0, -bb.y0))); + let nm = doc.get_mut(i)?; + let mut net = VectorNetwork::from_path(&res); + for r in &mut net.regions { + r.winding = WindingRule::EvenOdd; + } + nm.kind = NodeKind::Vector { network: net }; + nm.transform *= Affine::translate((bb.x0, bb.y0)); + nm.width = bb.width(); + nm.height = bb.height(); + } + ok() +} + +/// Join open vectors whose end points touch into one vector. +fn join(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("vector.join", s, p)?; + if ids.len() < 2 { + // Close a single open path. + if let Some(i) = ids.first() { + let n = s.doc_mut()?.get_mut(*i)?; + if let NodeKind::Vector { network } = &mut n.kind + && network.regions.is_empty() + && network.vertices.len() > 2 + { + let last = network.vertices.len() - 1; + network.segments.push(Segment::line(last, 0)); + let all: Vec = (0..network.segments.len()).collect(); + network.regions.push(Region { loops: vec![all], winding: WindingRule::NonZero }); + } + } + return ok(); + } + flatten(s, &json!({"ids": ids})) +} + +/// Fit containers to their visible children's bounds, keeping the children where they are on the +/// page (auto layout frames hug instead); rotated containers fit in their own axes. +fn resize_to_fit(s: &mut Session, p: &Value) -> Result { + let cmd = "object.resizeToFit"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut fitted = Vec::new(); + for id in ids { + let Some(n) = doc.node(id).cloned() else { continue }; + let container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }); + if !container || n.children.is_empty() || matches!(n.kind, NodeKind::Instance { .. }) { + continue; + } + // Auto layout frames fit by hugging their contents. + if n.layout.is_some() { + let node = doc.get_mut(id)?; + node.child_layout.sizing_h = Sizing::Hug; + node.child_layout.sizing_v = Sizing::Hug; + fitted.push(id); + continue; + } + // Children's bounds in the container's own coordinates. + let mut b: Option = None; + for c in n.children.iter().filter_map(|c| doc.node(*c)).filter(|c| c.visible) { + let r = figcraft_geom::transform_rect_bbox(c.transform, c.local_rect()); + if r.x0.is_finite() && r.y0.is_finite() && r.x1.is_finite() && r.y1.is_finite() { + b = Some(b.map_or(r, |u| u.union(r))); + } + } + let Some(b) = b else { continue }; + let (w, h) = (b.width().max(0.01), b.height().max(0.01)); + // Move the container by the offset, and its children back by it. + let shift = Affine::translate((b.x0, b.y0)); + let node = doc.get_mut(id)?; + node.transform *= shift; + node.width = w; + node.height = h; + for c in &n.children { + if let Ok(cn) = doc.get_mut(*c) { + cn.transform = shift.inverse() * cn.transform; + } + } + fitted.push(id); + } + Ok(json!({"ids": fitted})) +} diff --git a/crates/engine/tests/outline_stroke.rs b/crates/engine/tests/outline_stroke.rs index 49e8a8b2..477f77fe 100644 --- a/crates/engine/tests/outline_stroke.rs +++ b/crates/engine/tests/outline_stroke.rs @@ -1,174 +1,174 @@ -//! Outline stroke keeps the renderer's holes and existing property ownership. -#[cfg(test)] -mod tests { - use figcraft_doc::{Document, NodeId, NodeKind}; - use figcraft_engine::Session; - use figcraft_render::{Options, View, render_view}; - use serde_json::{Value, json}; - - fn run(s: &mut Session, command: &str, params: Value) -> Value { - s.execute(command, ¶ms).unwrap() - } - - fn rectangle(s: &mut Session, opacity: f64) -> NodeId { - NodeId( - run( - s, - "shape.rectangle", - json!({ - "x": 24, "y": 24, "width": 64, "height": 64, - "props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8, "opacity": opacity} - }), - )["id"] - .as_u64() - .unwrap(), - ) - } - - fn pixels(doc: &Document) -> Vec { - render_view(doc, doc.first_page().unwrap(), &View { origin: (0.0, 0.0), scale: 1.0, width: 112, height: 112 }, &Options::default()) - .unwrap() - .data() - .to_vec() - } - - fn outlined(s: &mut Session, id: NodeId) -> NodeId { - NodeId(run(s, "vector.outlineStroke", json!({"ids": [id]}))["ids"][0].as_u64().unwrap()) - } - - fn same_pixels(actual: &[u8], expected: &[u8]) { - assert_eq!(actual.len(), expected.len()); - let differences: Vec<_> = actual.iter().zip(expected).enumerate().filter(|(_, (a, b))| a != b).take(8).collect(); - assert!(differences.is_empty(), "rendered channels changed (byte, actual/expected): {differences:?}"); - } - - #[test] - fn outline_keeps_rings_through_undo_and_native_reopen() { - for vector in [false, true] { - let mut s = Session::new(); - let mut id = rectangle(&mut s, 1.0); - if vector { - id = NodeId(run(&mut s, "vector.flatten", json!({"ids": [id]}))["id"].as_u64().unwrap()); - } - let before = s.doc().unwrap().clone(); - let expected = pixels(&before); - let depth = s.undo_depth(); - let group = outlined(&mut s, id); - let after = s.doc().unwrap().clone(); - same_pixels(&pixels(&after), &expected); - assert!(after.node(id).unwrap().strokes.is_empty()); - assert_eq!(s.selection(), vec![group]); - assert_eq!(s.undo_depth(), (depth.0 + 1, 0)); - let (loaded, notes) = figcraft_format::load_bytes(&figcraft_format::save_bytes(&after, None).unwrap()).unwrap(); - assert!(notes.is_empty(), "{notes:?}"); - same_pixels(&pixels(&loaded), &expected); - assert!(s.undo().unwrap()); - assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&before).unwrap()); - assert!(s.redo().unwrap()); - assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&after).unwrap()); - } - } - - #[test] - fn stroke_only_keeps_holes_at_each_opacity() { - for opacity in [0.0, 0.25, 0.5, 1.0] { - let mut s = Session::new(); - let id = rectangle(&mut s, opacity); - run(&mut s, "object.removeFill", json!({"ids": [id]})); - let before = pixels(s.doc().unwrap()); - let vector = outlined(&mut s, id); - let doc = s.doc().unwrap(); - assert!(matches!(doc.node(vector).unwrap().kind, NodeKind::Vector { .. })); - assert_eq!(doc.node(vector).unwrap().opacity, opacity as f32); - same_pixels(&pixels(doc), &before); - } - } - - #[test] - fn outline_keeps_stroke_holes_and_alignment() { - for props in [ - json!({"strokeAlign": "inside"}), - json!({"strokeAlign": "outside"}), - json!({"strokeAlign": "center"}), - json!({"strokeSides": [2, 4, 8, 16]}), - json!({"strokeAlign": "center", "dashes": [8, 4]}), - ] { - for filled in [false, true] { - let mut s = Session::new(); - let id = rectangle(&mut s, 1.0); - run(&mut s, "node.set", json!({"ids": [id], "props": props})); - if !filled { - run(&mut s, "object.removeFill", json!({"ids": [id]})); - } - let before = pixels(s.doc().unwrap()); - outlined(&mut s, id); - same_pixels(&pixels(s.doc().unwrap()), &before); - } - } - } - - #[test] - fn outlining_an_instance_keeps_its_inherited_opacity_link() { - let mut s = Session::new(); - let component = NodeId( - run( - &mut s, - "frame.create", - json!({ - "width": 64, "height": 64, - "props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8, "opacity": 0.5} - }), - )["id"] - .as_u64() - .unwrap(), - ); - run(&mut s, "component.create", json!({"ids": [component]})); - let instance = NodeId(run(&mut s, "instance.create", json!({"component": component, "x": 200}))["id"].as_u64().unwrap()); - outlined(&mut s, instance); - assert_eq!(s.doc().unwrap().node(instance).unwrap().opacity, 0.5); - assert!(!s.doc().unwrap().node(instance).unwrap().overrides.contains("opacity")); - run(&mut s, "node.set", json!({"id": component, "props": {"opacity": 0.25}})); - let n = s.doc().unwrap().node(instance).unwrap(); - assert_eq!(n.opacity, 0.25, "the instance still follows its main"); - assert!(!n.overrides.contains("opacity")); - } - - #[test] - fn outlining_a_main_layer_keeps_its_instance_override() { - let mut s = Session::new(); - let frame = NodeId(run(&mut s, "frame.create", json!({"width": 100, "height": 100}))["id"].as_u64().unwrap()); - let layer = NodeId( - run( - &mut s, - "shape.rectangle", - json!({ - "parent": frame, "x": 24, "y": 24, "width": 64, "height": 64, - "props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8} - }), - )["id"] - .as_u64() - .unwrap(), - ); - let component = run(&mut s, "component.create", json!({"ids": [frame]}))["id"].clone(); - run(&mut s, "instance.create", json!({"component": component, "x": 200})); - let copy = s.doc().unwrap().nodes().find(|n| n.source == Some(layer)).unwrap().id; - run(&mut s, "node.set", json!({"id": copy, "props": {"opacity": 0.25}})); - let before = s.doc().unwrap().clone(); - let view = View { origin: (0.0, 0.0), scale: 1.0, width: 312, height: 112 }; - let expected = render_view(&before, s.page().unwrap(), &view, &Options::default()).unwrap(); - let group = outlined(&mut s, layer); - let doc = s.doc().unwrap(); - let actual = render_view(doc, s.page().unwrap(), &view, &Options::default()).unwrap(); - same_pixels(actual.data(), expected.data()); - let instance_group = doc.node(copy).unwrap(); - assert!(matches!(instance_group.kind, NodeKind::Group)); - assert_eq!(instance_group.source, Some(group)); - assert_eq!(instance_group.opacity, 0.25); - assert!(instance_group.overrides.contains("opacity")); - assert!(s.undo().unwrap()); - assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&before).unwrap()); - assert!(s.redo().unwrap()); - let actual = render_view(s.doc().unwrap(), s.page().unwrap(), &view, &Options::default()).unwrap(); - same_pixels(actual.data(), expected.data()); - } -} +//! Outline stroke keeps the renderer's holes and existing property ownership. +#[cfg(test)] +mod tests { + use figcraft_doc::{Document, NodeId, NodeKind}; + use figcraft_engine::Session; + use figcraft_render::{Options, View, render_view}; + use serde_json::{Value, json}; + + fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap() + } + + fn rectangle(s: &mut Session, opacity: f64) -> NodeId { + NodeId( + run( + s, + "shape.rectangle", + json!({ + "x": 24, "y": 24, "width": 64, "height": 64, + "props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8, "opacity": opacity} + }), + )["id"] + .as_u64() + .unwrap(), + ) + } + + fn pixels(doc: &Document) -> Vec { + render_view(doc, doc.first_page().unwrap(), &View { origin: (0.0, 0.0), scale: 1.0, width: 112, height: 112 }, &Options::default()) + .unwrap() + .data() + .to_vec() + } + + fn outlined(s: &mut Session, id: NodeId) -> NodeId { + NodeId(run(s, "vector.outlineStroke", json!({"ids": [id]}))["ids"][0].as_u64().unwrap()) + } + + fn same_pixels(actual: &[u8], expected: &[u8]) { + assert_eq!(actual.len(), expected.len()); + let differences: Vec<_> = actual.iter().zip(expected).enumerate().filter(|(_, (a, b))| a != b).take(8).collect(); + assert!(differences.is_empty(), "rendered channels changed (byte, actual/expected): {differences:?}"); + } + + #[test] + fn outline_keeps_rings_through_undo_and_native_reopen() { + for vector in [false, true] { + let mut s = Session::new(); + let mut id = rectangle(&mut s, 1.0); + if vector { + id = NodeId(run(&mut s, "vector.flatten", json!({"ids": [id]}))["id"].as_u64().unwrap()); + } + let before = s.doc().unwrap().clone(); + let expected = pixels(&before); + let depth = s.undo_depth(); + let group = outlined(&mut s, id); + let after = s.doc().unwrap().clone(); + same_pixels(&pixels(&after), &expected); + assert!(after.node(id).unwrap().strokes.is_empty()); + assert_eq!(s.selection(), vec![group]); + assert_eq!(s.undo_depth(), (depth.0 + 1, 0)); + let (loaded, notes) = figcraft_format::load_bytes(&figcraft_format::save_bytes(&after, None).unwrap()).unwrap(); + assert!(notes.is_empty(), "{notes:?}"); + same_pixels(&pixels(&loaded), &expected); + assert!(s.undo().unwrap()); + assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&before).unwrap()); + assert!(s.redo().unwrap()); + assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&after).unwrap()); + } + } + + #[test] + fn stroke_only_keeps_holes_at_each_opacity() { + for opacity in [0.0, 0.25, 0.5, 1.0] { + let mut s = Session::new(); + let id = rectangle(&mut s, opacity); + run(&mut s, "object.removeFill", json!({"ids": [id]})); + let before = pixels(s.doc().unwrap()); + let vector = outlined(&mut s, id); + let doc = s.doc().unwrap(); + assert!(matches!(doc.node(vector).unwrap().kind, NodeKind::Vector { .. })); + assert_eq!(doc.node(vector).unwrap().opacity, opacity as f32); + same_pixels(&pixels(doc), &before); + } + } + + #[test] + fn outline_keeps_stroke_holes_and_alignment() { + for props in [ + json!({"strokeAlign": "inside"}), + json!({"strokeAlign": "outside"}), + json!({"strokeAlign": "center"}), + json!({"strokeSides": [2, 4, 8, 16]}), + json!({"strokeAlign": "center", "dashes": [8, 4]}), + ] { + for filled in [false, true] { + let mut s = Session::new(); + let id = rectangle(&mut s, 1.0); + run(&mut s, "node.set", json!({"ids": [id], "props": props})); + if !filled { + run(&mut s, "object.removeFill", json!({"ids": [id]})); + } + let before = pixels(s.doc().unwrap()); + outlined(&mut s, id); + same_pixels(&pixels(s.doc().unwrap()), &before); + } + } + } + + #[test] + fn outlining_an_instance_keeps_its_inherited_opacity_link() { + let mut s = Session::new(); + let component = NodeId( + run( + &mut s, + "frame.create", + json!({ + "width": 64, "height": 64, + "props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8, "opacity": 0.5} + }), + )["id"] + .as_u64() + .unwrap(), + ); + run(&mut s, "component.create", json!({"ids": [component]})); + let instance = NodeId(run(&mut s, "instance.create", json!({"component": component, "x": 200}))["id"].as_u64().unwrap()); + outlined(&mut s, instance); + assert_eq!(s.doc().unwrap().node(instance).unwrap().opacity, 0.5); + assert!(!s.doc().unwrap().node(instance).unwrap().overrides.contains("opacity")); + run(&mut s, "node.set", json!({"id": component, "props": {"opacity": 0.25}})); + let n = s.doc().unwrap().node(instance).unwrap(); + assert_eq!(n.opacity, 0.25, "the instance still follows its main"); + assert!(!n.overrides.contains("opacity")); + } + + #[test] + fn outlining_a_main_layer_keeps_its_instance_override() { + let mut s = Session::new(); + let frame = NodeId(run(&mut s, "frame.create", json!({"width": 100, "height": 100}))["id"].as_u64().unwrap()); + let layer = NodeId( + run( + &mut s, + "shape.rectangle", + json!({ + "parent": frame, "x": 24, "y": 24, "width": 64, "height": 64, + "props": {"fill": "#FF0000", "stroke": "#0000FF", "strokeWeight": 8} + }), + )["id"] + .as_u64() + .unwrap(), + ); + let component = run(&mut s, "component.create", json!({"ids": [frame]}))["id"].clone(); + run(&mut s, "instance.create", json!({"component": component, "x": 200})); + let copy = s.doc().unwrap().nodes().find(|n| n.source == Some(layer)).unwrap().id; + run(&mut s, "node.set", json!({"id": copy, "props": {"opacity": 0.25}})); + let before = s.doc().unwrap().clone(); + let view = View { origin: (0.0, 0.0), scale: 1.0, width: 312, height: 112 }; + let expected = render_view(&before, s.page().unwrap(), &view, &Options::default()).unwrap(); + let group = outlined(&mut s, layer); + let doc = s.doc().unwrap(); + let actual = render_view(doc, s.page().unwrap(), &view, &Options::default()).unwrap(); + same_pixels(actual.data(), expected.data()); + let instance_group = doc.node(copy).unwrap(); + assert!(matches!(instance_group.kind, NodeKind::Group)); + assert_eq!(instance_group.source, Some(group)); + assert_eq!(instance_group.opacity, 0.25); + assert!(instance_group.overrides.contains("opacity")); + assert!(s.undo().unwrap()); + assert_eq!(serde_json::to_value(s.doc().unwrap()).unwrap(), serde_json::to_value(&before).unwrap()); + assert!(s.redo().unwrap()); + let actual = render_view(s.doc().unwrap(), s.page().unwrap(), &view, &Options::default()).unwrap(); + same_pixels(actual.data(), expected.data()); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 96620b05..cdd882a2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,448 +1,448 @@ -# Where FigCraft falls short of Figma - -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) - -Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from -doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The -numbers behind the ranking are in [`target-app-parity.md`](target-app-parity.md). Hours are one -Opus 5.5 agent working sequentially, tests included, calibrated as described there. "Estimated" -everywhere unless marked measured. - -## Ranked - -No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passes. Beta blockers are marked. - -| # | Gap | Kind | Hours | Doc | -|---|---|---|---|---| -| 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed, and an experimental export (round trips through our own importer; written files opened in other software, where layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, instances keep their size and variables with modes and colour styles come across); real-file checks and the small tail left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) | -| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | -| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | -| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | -| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts | Localization | 29–51 | [localization-parity](localization-parity.md) | -| 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | -| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | -| 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | -| 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | -| 10 | Team libraries: no team-wide lists or accounts; analytics are counts | Feature | 4–8 | [components-parity](components-parity.md) | -| 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) | -| 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) | -| 13 | Vector tail: outline-stroke compositing (stroke rings preserved) | Feature | — | [target-app-parity](target-app-parity.md) | -| 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | -| 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | -| 16 | Dev Mode: import lines inside generated layer trees; the editor extension is left to the MCP server (code connect, Dev Mode plugins and dev resources landed) | Feature | 0–1 | [dev-mode-parity](dev-mode-parity.md) | -| 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | -| 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | -| 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | -| 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) | -| 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) | -| 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) | -| 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) | - -## Entries - -### 1. `.fig` files: the import's tail -- **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`, - `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved - over. -- **Landed (2026-10-12):** an experimental export: `file.export {format: "fig"}`, File ▸ Export… ▸ - .fig (experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document - with a kiwi encoder mirroring the decoder and a schema of the fields written: the layer tree, - geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto - layout, constraints, components, sets, instances with their overrides and property values, and - prototype interactions; shared style links, variables, comments, Dev Mode data and the rest are - left out with a warning ([file-format-parity](file-format-parity.md#export-to-fig-experimental)). - The import gained ellipse arcs, boolean operation types and component set descriptions. Then - (2026-10-12): written files were checked by opening them in other software: layers, fills, - gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after the export - learned to say that the document and its pages are visible and opaque, to number its schema as - the format does, to write outlines, text settings, standard font style names and line height - units. Then (2026-10-13): instances keep their own size (an override addressed by the main's id, - derived data for their layers, a scale factor), variables with modes and bindings, shared styles - and variable-width strokes are written and read back; checked there: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there), - like effects such as glass and noise, images and prototype interactions. -- **Missing:** connector labels, elbowed or curved connectors without a saved path (drawn straight), - table cells beyond their own layers, filled areas of a vector with their own fills, per-point stroke - joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks and video - fills aren't read; text lays out again with - local fonts. Nothing has been checked against real-world files yet (gap 3). The export is checked - only against our own importer: whether other apps open its files is unknown. -- **Evidence:** fixtures generated in code (`crates/format/src/fig/tests.rs` and `tail_tests.rs`: kiwi - round trips, every mapped feature, vector network blobs, truncation, bit flips and junk never panic); `crates/engine/src/fig_import_tests.rs`. - Export: 60 generated documents round trip through the importer layer by layer, written files - decode with the generic decoder and list parents first, the encoder agrees with the decoder, - junk documents never panic (`crates/format/src/fig/export_tests.rs`, - `crates/engine/src/fig_export_tests.rs`). -- **Impact:** existing work can be brought across, but only real files will show how much of it. -- **Estimate:** 5–10 h for the read tail (real-file fixes, connector labels, table cells); checking the export against real files a further 5–15 h (with gap 3). - -### 2. Multiplayer and sharing are a preview, without accounts -- **Missing:** accounts, teams and projects, per-person permissions, audio, - shared comments and version history. Comments and versions are local to the file. -- **Evidence:** the core landed in #103 (`crates/collab`: client-scoped ids, change sets, server - ordering and validation, client reconciliation, binary messages, randomized convergence tests over - real commands), and live sessions run over the network (`crates/collab-net`: hosting from the - app, invite links that open the app, document sync between apps, live cursors, selections, - text carets, avatars and follow, cursor chat and spotlight, and the browser build joins) with per-person undo, refused - edits rolled back with a reason, reconnects that resume and send what was made offline, and - styles, variables and comments that several people create at once all kept; sessions are per - document (each open file has its own or none, several at once, closing a file ends only its - own); files are shared with a short join code (online through the server in Preferences, or on - the local network with a code that carries the host's address) and joined by typing it; the app - remembers the files it put online and lists them on the home screen (open again as owner, stop - and start sharing, rename, remove, forget), per computer; a standalone - `figcraft-server` keeps many files (snapshot plus log, compacted; restarts lose nothing - confirmed), takes files published from the app and hands out share links that let people edit - or only view; the desktop app and the browser reach it over `wss://` through a TLS reverse - proxy, and releases ship it with a deployment guide (`docs/server.md`). But there are no - accounts (a link is the access) (`docs/collab-protocol.md`). -- **Impact:** Figma's defining feature; most teams use it every day. Solo designers can work - without it. -- **Estimate:** 42–84 h: accounts, teams and per-person permissions, shared comments and versions - (numbers from before the server landed; re-measured next). - -### 3. Nothing checked against real Figma files -- **Missing:** a corpus of real documents (design systems, app screens, prototypes) imported and - compared with Figma's own PNG/SVG/PDF exports. -- **Evidence:** every corpus in the repo is synthetic (`crates/svg/tests/corpus`, the synthetic - design system in `crates/engine/src/synthetic_ds.rs`). Scores were self-graded by the agents that - built the features. -- **Impact:** unknown fidelity in auto layout sizing, text metrics, effects and component - resolution on real files; the numbers in this repo can't be trusted beyond "estimated" until it - exists. -- **Measured so far:** text box sizes and auto layout results of our own example documents, - against the same documents re-saved by other software (which lays text out again). Three text - rules explained every difference and are applied (whole-pixel line heights, advances on a - 1024-per-em grid, letter spacing between glyphs only): all 230 self-sizing text boxes in 7 - documents now come out to the pixel (24 of 131 before, in the design-system example), and 208 of - its 231 other layers match in size and position (115 before). The other 23 hang on one grid - auto layout frame whose row positions came back as written while its cells got shorter. One font - family (Inter), left-aligned text; real files, other fonts, effects and component resolution are - still unmeasured (`crates/text/src/metrics_tests.rs`, and a local check over such documents in - `crates/engine/src/text_metrics_local_tests.rs`). -- **Estimate:** 15–30 h once `.fig` or REST import of real files is available; needs files whose - licence allows use (owner to supply or approve; never committed if not redistributable, see - craftrules `standards/test-corpora.md`). - -### 4. Interaction fidelity not compared tool by tool -- **Missing:** a side-by-side pass of every tool and panel: cursors, modifier keys while dragging, - hover states, popovers, menus, snapping thresholds. -- **Landed (2026-10-11):** behaviour suites written from the public documentation for auto layout, - selection and transform, text, and canvas navigation with rulers, guides, layout grids, outline - mode and measuring, then (2026-10-12) comments and version history, and the layers and Assets - panels (`crates/engine/src/*_behaviour_tests.rs`, `crates/ui-egui/src/layers_behaviour_tests.rs`), - each with its fixes; then keyboard shortcuts per platform (`shortcut_parity_tests.rs`: 129 - documented rows, 126 bound, 1 moved off a documented conflict, 2 missing, 48 undocumented - bindings pinned), with 16 rows newly bound, Control suspending snapping on macOS and shifted - symbols on other keyboard layouts; then the everyday partials of - [`ui-parity.md`](ui-parity.md): number fields, outline mode's options, Hide when publishing, a - cursor for every tool and modifier, panel widths, dark pages (`field_tests`, `cursor_tests`, - `cursors_tests`, `outline_options_tests`, `hide_publishing_tests`, `panel_width_tests`, - `theme_pages_tests`). -- **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). -- **Impact:** Figma users notice the feel within minutes; it decides whether they stay. -- **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). - -### 5. Localization: English, reviewed Russian, plus unreviewed drafts -- **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, - Korean and Simplified Chinese drafts and the Latin American Spanish overlay, the 25 newest strings - in five drafts, Hindi, Arabic, Indonesian and Vietnamese, localized engine messages and number - formatting; CJK UI text on the web without the craft-fonts CJK build. -- **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in - `crates/ui-egui/locales/` (1,371 UI keys plus 524 command labels and 29 menu wordings), - Preferences ▸ Language, `pref.language`; `en.toml` is complete, and `es.toml`, `fr.toml`, - `de.toml`, `pt-BR.toml`, `ja.toml`, `ko.toml` and `zh-Hans.toml` (2026-10-12) are machine drafts - (all but `zh-Hans.toml` lack the 25 newest strings), with `es-419.toml` a Latin American overlay - listing only what differs from Spanish (Spanish and Russian are held to completeness by the check; the - others show English for strings added later). Russian adds all 2,165 current entries, - reviewed and approved by VanTan, with one/few/many integer plurals. - CJK UI text uses an installed CJK face the desktop app loads when something on screen needs it - (`crates/ui-egui/src/cjk.rs`). Engine error messages and Dev Mode values stay English. -- **Impact:** non-English designers get machine drafts nobody has reviewed yet, and English - errors. -- **Estimate:** 3–7 h per language plus native review, 4–6 h for engine messages and numbers; - 29–51 h for the key languages left and the drafts' follow-ups. Detail in - [`localization-parity.md`](localization-parity.md); how to add one in - [`translating.md`](translating.md). - -### 6. Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run -- **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in - the browser) acting through the command registry, one undo step per run, manifest permissions, - Plugins menu, Run last plugin, Manage plugins, `plugin.*` commands, a Rust SDK and two examples - ([`plugins.md`](plugins.md)). Plugin panels and settings (ABI v2, 2026-10-11): a plugin shows a - panel of fields and buttons drawn with FigCraft's widgets and is called back for each button or - changed value (one undo step per callback, same sandbox limits), driven headless with - `plugin.ui` / `plugin.event` / `plugin.close`; per-plugin settings kept with the preferences. - Widgets (plugin API 3, 2026-10-12): a plugin draws a layer of its own on the canvas (Plugins ▸ - Widgets, `widget.insert`), validated and built with locked layers inside, called back for clicks - on the parts it names (one undo step each, a fresh metered sandbox, the state kept in the layer - so it saves, copies, undoes, exports and syncs), `widget.click` / `widget.list` for agents, a - Counter example; panels of several plugins side by side; the colour picker on plugin colour - fields. A plugin directory on `figcraft-server` (2026-10-12): bundles published with the server - key and checked in full (`figcraft-cli plugin publish`), browsed, installed after a permissions - confirmation and updated from Plugins ▸ Manage plugins ▸ Browse, downloads checked against their - SHA-256, `plugin.directory.*` for scripts and MCP, moderation with `figcraft-server plugins`. -- **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings - and install counts, background update checks, the directory in the browser build; widgets take only clicks (no - text input, property menus, hover or timers) and draw only frames, text, shapes and images; - no images or sliders in panels. Figma's own plugins are JavaScript against Figma's plugin API and - don't run. -- **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks) - can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better - than Figma does. -- **Estimate:** 8–26 h for the missing pieces of FigCraft's own API. Running Figma's plugins - needs a JS engine and an API shim: **owner decision needed**. - -### 7. CPU canvas where Figma uses the GPU -- **Missing:** GPU rendering of the canvas. FigCraft renders with tiny-skia on the CPU (banded, - parallel, retained) and shows the image through wgpu. A new zoom can't reuse the retained - image; when renders from scratch take a frame or more, the whole view is previewed with a - sixteenth of the pixels first, then refined. -- **Evidence (measured, `cargo xtask bench`):** a 50k-layer page ~8 ms zoomed out with levels of - detail, single-layer edits at 10k layers ~0.2 ms; full 1440×900 renders 7–15 ms at 10k layers. - Heavy effects (glass, background blur, noise) and 4K/5K canvases at 2x are where CPU time grows. -- **Impact:** large files and effect-heavy designs; battery on laptops. -- **Estimate:** 30–60 h for a GPU path (vello or wgpu shaders) held to the CPU renderer as the - oracle. - -### 8. Platform QA: Linux and the browser left -- **Missing:** regular runs on Linux and the browser; real screen readers; a repeat of the Windows - pass on other machines (scale factors above 100%, pen tablets, file drops from Explorer). -- **Evidence:** CI tests on Linux only (`.github/workflows/ci.yml`); release builds exist for every - platform (v0.2.0) but no issue has been filed yet. Windows: QA pass done 2026-10-11, findings in - PR (Windows 11, the desktop app driven over the control channel, the same workflow through MCP): - eight bugs fixed, among them unsaved changes lost when the app was killed or crashed, - Ctrl+arrows and Ctrl+Backspace acting on the whole line in text, scrubbed fields snapping back - and skewed linear gradients. -- **Impact:** unknown bugs on the platforms where FigCraft is most attractive (Linux has no Figma - desktop app). -- **Estimate:** 10–20 h, partly human (hardware). - -### 9. Figma Draw: tablet pressure on macOS and Linux -- **Missing:** pen pressure on macOS and Linux tablets (winit reports none there). -- **Landed (2026-10-12):** multi-edit: several vector layers in edit mode at once (Enter or - double-click with them selected, `vector.edit.begin {ids}`); points selected, marqueed, dragged, - nudged, deleted, mirrored and rounded across layers in one undo step; the pen, bend, paint bucket, - cut and eraser work in the layer last pressed (`vector_multi_behaviour_tests`). -- **Landed:** text on a path (`text.onPath`, `text.pathOptions`, `text.removeFromPath`; the - Design panel's Typography section; canvas, PNG, SVG and PDF as outlines; SVG `` imports - as text on a path), pattern fills (`PaintKind::Pattern`, `fill.pattern`, the colour picker's - Pattern tab; SVG ``, PDF tiling patterns), stretch and scatter brushes (`stroke.brush`, - eight presets drawn in code; canvas, PNG, SVG, PDF and outline stroke) and pen pressure for the - Pencil (Windows pens and browser styluses; recorded as the stroke's width profile, which brushes - follow). -- **Evidence:** winit reports no pressure on macOS and Linux tablets. -- **Impact:** illustration-flavoured work moved into Figma Draw in 2025. -- **Estimate:** 1–3 h once winit (or a platform tablet API behind it) reports pressure there. - -### 10. Team libraries: no team-wide lists or accounts; analytics are counts -- **Missing:** libraries listed for a whole team or organization (each file enables a library by - its link), accounts and per-person library permissions, analytics beyond instance counts per - component and file (insertions and detaches over time), update notices pushed by the server - (apps ask every 30 seconds while the Assets panel shows). -- **Evidence:** a file's library is published to `figcraft-server` under the file and enabled in - other files by a share link, with per-asset versions, updates with overrides kept, usage counts - and swapping a whole library by names ([`server.md`](server.md#team-libraries), - [`components-parity.md`](components-parity.md)). -- **Impact:** design-system teams. -- **Estimate:** 4–8 h, the accounts part after gap 2. - -### 11. Spell check -- **Missing:** spelling underline and suggestions while editing text. -- **Evidence:** no spell-check code in the repo. -- **Estimate:** 4–8 h with a permissively licensed dictionary and checker (provenance per - `ATTRIBUTION.md`). - -### 12. Video fills; HEIC images -- **Landed (2026-10-11):** animated GIF fills (the file kept as is, the first frame on the canvas - and in exports, played in the presenter and the preview) and TIFF images. -- **Missing:** Figma takes JPG, PNG, HEIC, WebP, GIF and TIFF images and MP4, MOV and WebM video - (help center, checked 2026-10-10). FigCraft decodes PNG, JPEG, WebP, GIF, TIFF and BMP; HEIC and - video need decoders that are C libraries (libheif, FFmpeg) or each platform's media framework. -- **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock). - -### 13. Vector tail: outline-stroke compositing -- **Landed (2026-10-15):** Outline stroke keeps the renderer's even-odd stroke rings; regression pixels, Undo/Redo, native save/reopen and instance opacity inheritance/overrides in `outline_stroke`. -- **Missing:** preserving filled-shape opacity, blend and effects on the composite. A follow-up must preserve component inheritance and references when deciding which node owns these properties. -- **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together, - sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG, - PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a - selected open path. Left: corner smoothing beyond rectangles and frames, the transform box around - points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. -- **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a - subtracted hole reaches what's behind; with the group or an operand selected its operands are - hit, so each can still be picked; "Select layer" lists the group only where its result is), live - boolean editing (operands selected by double-click or Cmd/Ctrl-click; - moving, resizing or editing an operand's points redraws the result during the drag, with every - operand outlined; per-operand operations from the boolean controls and `node.set`; a drag frame - recombines only the operands from the edited one up), Design panel Arc fields (Start, Sweep, - Ratio; Mixed across ellipses; bindable), the shape builder on strokes and open paths (strokes take - part as their outlined areas), one-sided variable widths (Alt-drag a width handle; canvas, PNG, - SVG, PDF and outline stroke). -- **Estimate:** compositing not yet re-estimated; vector QA with real files remains part of gap 3. - -### 14. Variables: extended collections on real libraries -- **Landed:** extended collections (`library.extendCollection`: a library collection's modes and - variables in a collection of this file, values overridden per mode with reset, following library - updates, variables of its own), variables import/export (`variable.export` / `variable.import`: - the REST local variables JSON, merged by name; CSS custom properties), gradient stops and variant - properties bound to variables. -- **Landed (2026-10-12):** variables a library adds to a collection this file extends are a library - update of their own (`library.updates` lists them by name, without an `id` until they have a - copy) and arrive inheriting when accepted; a library collection that is itself an extended - collection (a brand theme extending a core one) can be extended again, its values flowing down - the chain as each file accepts updates, every level's overrides kept. -- **Missing:** checked with synthetic files only; extending a collection of the same file isn't - offered (extending takes library collections). -- **Estimate:** 0–1 h. - -### 15. Prototyping: media triggers and actions, controllers off Windows -- **Landed (2026-10-12):** key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad - Home`, the standard layout's 17 buttons; a Controller menu beside the key field; REST files' - controller triggers import); the player reads controllers on Windows and in the browser (Gamepad - API) and takes `ui.input {gamepad}` on the control channel; widgets react to clicks in a - presented prototype, on the prototype's own copy; `proto.evaluate` computes expressions (mode - lookups included) where a layer's interaction would run (`crates/engine/src/proto_tail_tests.rs`, - `crates/ui-egui/src/present_tail_tests.rs`, `crates/format/src/rest/gamepad_tests.rs`). -- **Missing:** video triggers (on media hit/end) and play/pause/mute/seek actions (the REST - importer warns and skips them, `crates/format/src/rest/proto.rs`); controllers on macOS, Linux - and FreeBSD (the usual Rust controller library links libudev on Linux; a pure-Rust reader per - platform, or an owner decision on that dependency); PlayStation and Switch button labels. -- **Estimate:** 2–5 h, the media part after video fills (gap 12). - -### 16. Dev Mode: the tail after dev resources -- **Landed (2026-10-11):** code connect: a component or set links to its code component per - language (React, SwiftUI, Compose): an import path, the name and a template whose `{property}` - and `{property ? "a" : "b"}` placeholders take the instance's values, escaped per language and - never executed. `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet - instead of generated code (inside generated layer trees too); the panel's Connect to code edits - it with a live preview. Links are stored on the component (saved, synced, undoable) and travel - as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`, validated and capped) - so code owners can version them. Plugins whose manifest asks only to read run from the Inspect - tab. -- **Landed (2026-10-12):** dev resources: links for developers (a URL and a name) on any layer - (`devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced). An instance also - lists its main component's links (and its set's) read-only with where they come from; layers - inside an instance show their main layer's. URLs are checked as web links (http or https, a - host, no spaces, capped) and never fetched: a link added without a name is named after its URL, - and a link opens only when clicked. The Inspect tab lists, adds, edits and removes them; - `get_node` / `inspect_document` return them as `devResources`. -- **Missing:** import lines for connected instances inside a - generated layer tree (only the selected layer's own import is shown); publishing mappings from - the code repository with a CLI. An editor extension is out of scope: the MCP server already - serves design data, code and the connected snippets to agents and editors. -- **Estimate:** 0–1 h. - -### 17. Branching and merging -- **Landed (2026-10-11):** branches of a file (`branch.*`): File ▸ Create branch… (or the Branches - list in version history) copies the file into a branch tab that remembers the file as it was (its - base) and makes its ids in its own id space; edits on the branch are saved with the main file. - Review and merge (the banner on a branch tab) shows the main file and the branch side by side or - as an overlay, what changed on each side since the base (layers with their property changes as - Inspect shows them; styles, variables, comments) and the conflicts: the same property changed on - both sides, or a layer deleted on one side while the other edited it or what's inside it. Each - conflict is resolved for the branch or main; nothing conflicting merges until it is. Merging is - one undo step on the main file and archives the branch; Update from main brings main's changes - into the branch the same way; branches can be archived without merging. Three-way merge over the - collaboration change sets (`figcraft_collab::merge`), fuzz-tested with random real commands. -- **Landed (2026-10-12):** a branch's own version history: versions saved on a branch tab (by hand - or autosaved) go into the branch, not the main file, and are viewed, restored (one undo step on - the branch), renamed, deleted and duplicated there; they are saved in the file with the branch. - Merging adds a version to the main file's history (Merged "name"); the merged or archived - branch keeps its own history and opens with it. Fills, strokes and effects merge item by item: - different items changed on each side, or items added at the end on one side, merge without a - conflict; only the same item changed both ways, an item removed on one side, or different items - added on both sides conflict (as the whole list). -- **Missing:** branches of a file in a live session are refused; other lists (text runs, export - settings, guides…) still conflict as a whole; the branch history has no entries of its own for - its creation or updates from main (only saved versions, autosaves and the merge on main). -- **Estimate:** 1–2 h. - -### 18. Display P3 on screen (several windows landed) -- **Landed (2026-10-11):** several windows: File ▸ New window, a tab's Open in new window or a tab - dragged off the strip; each window has its own tabs, panels and canvas and draws only when it - needs to; closing one asks about unsaved changes; `window.new`, `window.list`, `window.focus` and - `window` on any control request. -- **Missing:** wide-gamut colour on the canvas (egui draws sRGB, so P3 colours outside sRGB clip on - screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.36 - gives only the main window one); window positions restored at the next start. -- **Estimate:** 4–8 h. - -### 19. AI features -- **Missing:** Figma AI (first draft, rename layers, remove background, image generation and - editing, visual search, Make). -- **Estimate:** 30–60 h with openly licensed local models. **Owner decision needed.** - -### 20. Mobile apps -- **Missing:** Figma's iOS and Android apps view and comment on files and mirror prototypes. - FigCraft's web build runs in mobile browsers but is not designed for touch. -- **Estimate:** 20–40 h for a touch-first viewer and mirror. - -### 21. Menu items without a command (measured): none left -- **Landed (2026-10-11):** `file.openRecent` (File ▸ Open recent: recent files kept in the - preferences, at most 20, greyed when the file is gone and dropped when picked, Clear recent), - `view.outlinesSelection` (outline mode for the selected layers only) and `view.maskOutlines`; - the plugin pair landed in #101. Every item in the menu catalog has a command - ([`parity-checklist.md`](parity-checklist.md)). -- **Estimate:** none left. - -### 22. Screen readers never run; browser has no accessibility tree -- **Evidence:** AccessKit names and keyboard navigation landed (#96), checked by tests only; - eframe's web backend doesn't send the tree yet. -- **Estimate:** 4–8 h plus human QA with VoiceOver, NVDA, Narrator and Orca. - -### 23. CMYK output intent for print PDFs -- **Evidence:** CMYK conversion is a documented naive formula; no CMYK ICC profile can ship in the - repository (licensing). -- **Estimate:** 2–4 h once an openly licensed profile is found. - -## Revision history - -| Date | Change | Summary | -|---|---|---| -| 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | -| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | -| 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | -| 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged | -| 2026-10-12 | minor | Gap 2: sharing with a join code (Share ▸ Online or Local network, one Start sharing button, a short code to give others, one join field for a code or a link; the server names links with codes and limits wrong ones) added to the landed list; usability, so the estimate and ranking are unchanged | -| 2026-10-12 | minor | Gap 3: first measured fidelity data. Text box sizes and auto layout results of our example documents were compared with the same documents re-saved by other software; three text rules (whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only) were found and applied, and all 230 self-sizing text boxes in 7 documents now match to the pixel (one font family). Real files are still unchecked: hours and the ranking unchanged | -| 2026-10-12 | minor | Gap 2: live sessions are per document (each open file has its own session or none, several run at once, the window and the `collab.*` commands follow the file in front, closing a file ends its session only) added to the landed list; a fix, so the estimate and ranking are unchanged | -| 2026-10-12 | minor | Gap 1: the experimental `.fig` export now opens elsewhere: written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Hours and the ranking unchanged | -| 2026-10-12 | minor | Gap 1: an experimental `.fig` export landed (the whole document, from the same public format notes as the import; checked only by round trips through our own importer); export hours 15–30 h → 5–15 h (checks against real files); gap 1's read tail (5–10 h) and the ranking unchanged | -| 2026-10-12 | minor | Gap 4: keyboard shortcut parity suite per platform (129 documented rows: 126 bound, 1 moved, 2 missing; 48 undocumented bindings pinned) added to the landed list; estimate and ranking unchanged | -| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Gap 5 31–54 h → 29–51 h, rank unchanged | -| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Gap 5 32–55 h → 31–54 h, rank unchanged | -| 2026-10-12 | minor | Gap 15: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes and `proto.evaluate` landed; gap 15 now media plus controllers off Windows, 3–6 h → 2–5 h; ranking unchanged | -| 2026-10-12 | minor | Plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version, operator moderation) added to gap 6's landed list; gap 6 10–30 h → 8–26 h; ranking unchanged | -| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Gap 5 40–65 h → 32–55 h, rank unchanged | -| 2026-10-12 | minor | Plugin widgets (API 3) and the panel tail (panels side by side, colour picker on plugin fields) added to gap 6's landed list; gap 6 now 10–30 h; ranking unchanged | -| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Gap 5 45–75 h → 40–65 h, rank unchanged | -| 2026-10-12 | minor | Cursor chat and spotlight in live sessions (gap 2): `/` opens a chat bubble at your pointer, a spotlight makes everyone follow your view; ephemeral presence the host validates and rate-limits. Gap 2 45–90 h → 42–84 h | -| 2026-10-12 | minor | Layers and Assets panels behaviour suite and fixes added to gap 4's landed list; ranking and hours unchanged | -| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Gap 9 2–5 h → 1–3 h (pen pressure on macOS and Linux tablets left, blocked on winit). Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | -| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | -| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | -| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | -| 2026-10-12 | minor | Comments and version history behaviour suite and fixes added to gap 4's landed list (gap 17 unchanged: a branch's own version history is still left); ranking and hours unchanged | -| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | -| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Gap 1 30–60 h → 8–15 h (vector networks, other layer kinds, real-file checks; export 15–30 h on top, as before) | -| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | -| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | -| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h | -| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | -| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | -| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | -| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | -| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | -| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | -| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | -| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | -| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | -| 2026-10-10 | minor | Marked beta blockers; no alpha blockers (the alpha gate passes) | -| 2026-10-10 | major | Created from the 2026-10-10 re-measure against Figma desktop 126.10.7 (replaces the gap list that lived in ROADMAP.md) | +# Where FigCraft falls short of Figma + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) + +Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from +doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The +numbers behind the ranking are in [`target-app-parity.md`](target-app-parity.md). Hours are one +Opus 5.5 agent working sequentially, tests included, calibrated as described there. "Estimated" +everywhere unless marked measured. + +## Ranked + +No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passes. Beta blockers are marked. + +| # | Gap | Kind | Hours | Doc | +|---|---|---|---|---| +| 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed, and an experimental export (round trips through our own importer; written files opened in other software, where layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, instances keep their size and variables with modes and colour styles come across); real-file checks and the small tail left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) | +| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) | +| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | +| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | +| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts | Localization | 29–51 | [localization-parity](localization-parity.md) | +| 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | +| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | +| 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | +| 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | +| 10 | Team libraries: no team-wide lists or accounts; analytics are counts | Feature | 4–8 | [components-parity](components-parity.md) | +| 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) | +| 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) | +| 13 | Vector tail: outline-stroke compositing (stroke rings preserved) | Feature | — | [target-app-parity](target-app-parity.md) | +| 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | +| 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | +| 16 | Dev Mode: import lines inside generated layer trees; the editor extension is left to the MCP server (code connect, Dev Mode plugins and dev resources landed) | Feature | 0–1 | [dev-mode-parity](dev-mode-parity.md) | +| 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | +| 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | +| 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | +| 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) | +| 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) | +| 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) | +| 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) | + +## Entries + +### 1. `.fig` files: the import's tail +- **Landed (2026-10-11):** `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors, auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning (`crates/format/src/fig`). Then (2026-10-11): vectors open as editable vector networks (per-point corner radius, handle mirroring, end caps; outlines when the network blob is missing or damaged), sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning, and set-variable, set-variable-mode and conditional prototype actions come across. `file.open`, + `file.import`, drag and drop, the CLI and MCP's `open_file` read `.fig`; the file is never saved + over. +- **Landed (2026-10-12):** an experimental export: `file.export {format: "fig"}`, File ▸ Export… ▸ + .fig (experimental), `figcraft-cli export --format fig` and MCP's `export` write the whole document + with a kiwi encoder mirroring the decoder and a schema of the fields written: the layer tree, + geometry, paints with image bytes, strokes, effects, text with styled runs, vector networks, auto + layout, constraints, components, sets, instances with their overrides and property values, and + prototype interactions; shared style links, variables, comments, Dev Mode data and the rest are + left out with a warning ([file-format-parity](file-format-parity.md#export-to-fig-experimental)). + The import gained ellipse arcs, boolean operation types and component set descriptions. Then + (2026-10-12): written files were checked by opening them in other software: layers, fills, + gradients, strokes, shadows, vectors, components and text draw as in FigCraft, after the export + learned to say that the document and its pages are visible and opaque, to number its schema as + the format does, to write outlines, text settings, standard font style names and line height + units. Then (2026-10-13): instances keep their own size (an override addressed by the main's id, + derived data for their layers, a scale factor), variables with modes and bindings, shared styles + and variable-width strokes are written and read back; checked there: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there), + like effects such as glass and noise, images and prototype interactions. +- **Missing:** connector labels, elbowed or curved connectors without a saved path (drawn straight), + table cells beyond their own layers, filled areas of a vector with their own fills, per-point stroke + joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks and video + fills aren't read; text lays out again with + local fonts. Nothing has been checked against real-world files yet (gap 3). The export is checked + only against our own importer: whether other apps open its files is unknown. +- **Evidence:** fixtures generated in code (`crates/format/src/fig/tests.rs` and `tail_tests.rs`: kiwi + round trips, every mapped feature, vector network blobs, truncation, bit flips and junk never panic); `crates/engine/src/fig_import_tests.rs`. + Export: 60 generated documents round trip through the importer layer by layer, written files + decode with the generic decoder and list parents first, the encoder agrees with the decoder, + junk documents never panic (`crates/format/src/fig/export_tests.rs`, + `crates/engine/src/fig_export_tests.rs`). +- **Impact:** existing work can be brought across, but only real files will show how much of it. +- **Estimate:** 5–10 h for the read tail (real-file fixes, connector labels, table cells); checking the export against real files a further 5–15 h (with gap 3). + +### 2. Multiplayer and sharing are a preview, without accounts +- **Missing:** accounts, teams and projects, per-person permissions, audio, + shared comments and version history. Comments and versions are local to the file. +- **Evidence:** the core landed in #103 (`crates/collab`: client-scoped ids, change sets, server + ordering and validation, client reconciliation, binary messages, randomized convergence tests over + real commands), and live sessions run over the network (`crates/collab-net`: hosting from the + app, invite links that open the app, document sync between apps, live cursors, selections, + text carets, avatars and follow, cursor chat and spotlight, and the browser build joins) with per-person undo, refused + edits rolled back with a reason, reconnects that resume and send what was made offline, and + styles, variables and comments that several people create at once all kept; sessions are per + document (each open file has its own or none, several at once, closing a file ends only its + own); files are shared with a short join code (online through the server in Preferences, or on + the local network with a code that carries the host's address) and joined by typing it; the app + remembers the files it put online and lists them on the home screen (open again as owner, stop + and start sharing, rename, remove, forget), per computer; a standalone + `figcraft-server` keeps many files (snapshot plus log, compacted; restarts lose nothing + confirmed), takes files published from the app and hands out share links that let people edit + or only view; the desktop app and the browser reach it over `wss://` through a TLS reverse + proxy, and releases ship it with a deployment guide (`docs/server.md`). But there are no + accounts (a link is the access) (`docs/collab-protocol.md`). +- **Impact:** Figma's defining feature; most teams use it every day. Solo designers can work + without it. +- **Estimate:** 42–84 h: accounts, teams and per-person permissions, shared comments and versions + (numbers from before the server landed; re-measured next). + +### 3. Nothing checked against real Figma files +- **Missing:** a corpus of real documents (design systems, app screens, prototypes) imported and + compared with Figma's own PNG/SVG/PDF exports. +- **Evidence:** every corpus in the repo is synthetic (`crates/svg/tests/corpus`, the synthetic + design system in `crates/engine/src/synthetic_ds.rs`). Scores were self-graded by the agents that + built the features. +- **Impact:** unknown fidelity in auto layout sizing, text metrics, effects and component + resolution on real files; the numbers in this repo can't be trusted beyond "estimated" until it + exists. +- **Measured so far:** text box sizes and auto layout results of our own example documents, + against the same documents re-saved by other software (which lays text out again). Three text + rules explained every difference and are applied (whole-pixel line heights, advances on a + 1024-per-em grid, letter spacing between glyphs only): all 230 self-sizing text boxes in 7 + documents now come out to the pixel (24 of 131 before, in the design-system example), and 208 of + its 231 other layers match in size and position (115 before). The other 23 hang on one grid + auto layout frame whose row positions came back as written while its cells got shorter. One font + family (Inter), left-aligned text; real files, other fonts, effects and component resolution are + still unmeasured (`crates/text/src/metrics_tests.rs`, and a local check over such documents in + `crates/engine/src/text_metrics_local_tests.rs`). +- **Estimate:** 15–30 h once `.fig` or REST import of real files is available; needs files whose + licence allows use (owner to supply or approve; never committed if not redistributable, see + craftrules `standards/test-corpora.md`). + +### 4. Interaction fidelity not compared tool by tool +- **Missing:** a side-by-side pass of every tool and panel: cursors, modifier keys while dragging, + hover states, popovers, menus, snapping thresholds. +- **Landed (2026-10-11):** behaviour suites written from the public documentation for auto layout, + selection and transform, text, and canvas navigation with rulers, guides, layout grids, outline + mode and measuring, then (2026-10-12) comments and version history, and the layers and Assets + panels (`crates/engine/src/*_behaviour_tests.rs`, `crates/ui-egui/src/layers_behaviour_tests.rs`), + each with its fixes; then keyboard shortcuts per platform (`shortcut_parity_tests.rs`: 129 + documented rows, 126 bound, 1 moved off a documented conflict, 2 missing, 48 undocumented + bindings pinned), with 16 rows newly bound, Control suspending snapping on macOS and shifted + symbols on other keyboard layouts; then the everyday partials of + [`ui-parity.md`](ui-parity.md): number fields, outline mode's options, Hide when publishing, a + cursor for every tool and modifier, panel widths, dark pages (`field_tests`, `cursor_tests`, + `cursors_tests`, `outline_options_tests`, `hide_publishing_tests`, `panel_width_tests`, + `theme_pages_tests`). +- **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). +- **Impact:** Figma users notice the feel within minutes; it decides whether they stay. +- **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). + +### 5. Localization: English, reviewed Russian, plus unreviewed drafts +- **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese, + Korean and Simplified Chinese drafts and the Latin American Spanish overlay, the 25 newest strings + in five drafts, Hindi, Arabic, Indonesian and Vietnamese, localized engine messages and number + formatting; CJK UI text on the web without the craft-fonts CJK build. +- **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in + `crates/ui-egui/locales/` (1,371 UI keys plus 524 command labels and 29 menu wordings), + Preferences ▸ Language, `pref.language`; `en.toml` is complete, and `es.toml`, `fr.toml`, + `de.toml`, `pt-BR.toml`, `ja.toml`, `ko.toml` and `zh-Hans.toml` (2026-10-12) are machine drafts + (all but `zh-Hans.toml` lack the 25 newest strings), with `es-419.toml` a Latin American overlay + listing only what differs from Spanish (Spanish and Russian are held to completeness by the check; the + others show English for strings added later). Russian adds all 2,165 current entries, + reviewed and approved by VanTan, with one/few/many integer plurals. + CJK UI text uses an installed CJK face the desktop app loads when something on screen needs it + (`crates/ui-egui/src/cjk.rs`). Engine error messages and Dev Mode values stay English. +- **Impact:** non-English designers get machine drafts nobody has reviewed yet, and English + errors. +- **Estimate:** 3–7 h per language plus native review, 4–6 h for engine messages and numbers; + 29–51 h for the key languages left and the drafts' follow-ups. Detail in + [`localization-parity.md`](localization-parity.md); how to add one in + [`translating.md`](translating.md). + +### 6. Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run +- **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in + the browser) acting through the command registry, one undo step per run, manifest permissions, + Plugins menu, Run last plugin, Manage plugins, `plugin.*` commands, a Rust SDK and two examples + ([`plugins.md`](plugins.md)). Plugin panels and settings (ABI v2, 2026-10-11): a plugin shows a + panel of fields and buttons drawn with FigCraft's widgets and is called back for each button or + changed value (one undo step per callback, same sandbox limits), driven headless with + `plugin.ui` / `plugin.event` / `plugin.close`; per-plugin settings kept with the preferences. + Widgets (plugin API 3, 2026-10-12): a plugin draws a layer of its own on the canvas (Plugins ▸ + Widgets, `widget.insert`), validated and built with locked layers inside, called back for clicks + on the parts it names (one undo step each, a fresh metered sandbox, the state kept in the layer + so it saves, copies, undoes, exports and syncs), `widget.click` / `widget.list` for agents, a + Counter example; panels of several plugins side by side; the colour picker on plugin colour + fields. A plugin directory on `figcraft-server` (2026-10-12): bundles published with the server + key and checked in full (`figcraft-cli plugin publish`), browsed, installed after a permissions + confirmation and updated from Plugins ▸ Manage plugins ▸ Browse, downloads checked against their + SHA-256, `plugin.directory.*` for scripts and MCP, moderation with `figcraft-server plugins`. +- **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings + and install counts, background update checks, the directory in the browser build; widgets take only clicks (no + text input, property menus, hover or timers) and draw only frames, text, shapes and images; + no images or sliders in panels. Figma's own plugins are JavaScript against Figma's plugin API and + don't run. +- **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks) + can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better + than Figma does. +- **Estimate:** 8–26 h for the missing pieces of FigCraft's own API. Running Figma's plugins + needs a JS engine and an API shim: **owner decision needed**. + +### 7. CPU canvas where Figma uses the GPU +- **Missing:** GPU rendering of the canvas. FigCraft renders with tiny-skia on the CPU (banded, + parallel, retained) and shows the image through wgpu. A new zoom can't reuse the retained + image; when renders from scratch take a frame or more, the whole view is previewed with a + sixteenth of the pixels first, then refined. +- **Evidence (measured, `cargo xtask bench`):** a 50k-layer page ~8 ms zoomed out with levels of + detail, single-layer edits at 10k layers ~0.2 ms; full 1440×900 renders 7–15 ms at 10k layers. + Heavy effects (glass, background blur, noise) and 4K/5K canvases at 2x are where CPU time grows. +- **Impact:** large files and effect-heavy designs; battery on laptops. +- **Estimate:** 30–60 h for a GPU path (vello or wgpu shaders) held to the CPU renderer as the + oracle. + +### 8. Platform QA: Linux and the browser left +- **Missing:** regular runs on Linux and the browser; real screen readers; a repeat of the Windows + pass on other machines (scale factors above 100%, pen tablets, file drops from Explorer). +- **Evidence:** CI tests on Linux only (`.github/workflows/ci.yml`); release builds exist for every + platform (v0.2.0) but no issue has been filed yet. Windows: QA pass done 2026-10-11, findings in + PR (Windows 11, the desktop app driven over the control channel, the same workflow through MCP): + eight bugs fixed, among them unsaved changes lost when the app was killed or crashed, + Ctrl+arrows and Ctrl+Backspace acting on the whole line in text, scrubbed fields snapping back + and skewed linear gradients. +- **Impact:** unknown bugs on the platforms where FigCraft is most attractive (Linux has no Figma + desktop app). +- **Estimate:** 10–20 h, partly human (hardware). + +### 9. Figma Draw: tablet pressure on macOS and Linux +- **Missing:** pen pressure on macOS and Linux tablets (winit reports none there). +- **Landed (2026-10-12):** multi-edit: several vector layers in edit mode at once (Enter or + double-click with them selected, `vector.edit.begin {ids}`); points selected, marqueed, dragged, + nudged, deleted, mirrored and rounded across layers in one undo step; the pen, bend, paint bucket, + cut and eraser work in the layer last pressed (`vector_multi_behaviour_tests`). +- **Landed:** text on a path (`text.onPath`, `text.pathOptions`, `text.removeFromPath`; the + Design panel's Typography section; canvas, PNG, SVG and PDF as outlines; SVG `` imports + as text on a path), pattern fills (`PaintKind::Pattern`, `fill.pattern`, the colour picker's + Pattern tab; SVG ``, PDF tiling patterns), stretch and scatter brushes (`stroke.brush`, + eight presets drawn in code; canvas, PNG, SVG, PDF and outline stroke) and pen pressure for the + Pencil (Windows pens and browser styluses; recorded as the stroke's width profile, which brushes + follow). +- **Evidence:** winit reports no pressure on macOS and Linux tablets. +- **Impact:** illustration-flavoured work moved into Figma Draw in 2025. +- **Estimate:** 1–3 h once winit (or a platform tablet API behind it) reports pressure there. + +### 10. Team libraries: no team-wide lists or accounts; analytics are counts +- **Missing:** libraries listed for a whole team or organization (each file enables a library by + its link), accounts and per-person library permissions, analytics beyond instance counts per + component and file (insertions and detaches over time), update notices pushed by the server + (apps ask every 30 seconds while the Assets panel shows). +- **Evidence:** a file's library is published to `figcraft-server` under the file and enabled in + other files by a share link, with per-asset versions, updates with overrides kept, usage counts + and swapping a whole library by names ([`server.md`](server.md#team-libraries), + [`components-parity.md`](components-parity.md)). +- **Impact:** design-system teams. +- **Estimate:** 4–8 h, the accounts part after gap 2. + +### 11. Spell check +- **Missing:** spelling underline and suggestions while editing text. +- **Evidence:** no spell-check code in the repo. +- **Estimate:** 4–8 h with a permissively licensed dictionary and checker (provenance per + `ATTRIBUTION.md`). + +### 12. Video fills; HEIC images +- **Landed (2026-10-11):** animated GIF fills (the file kept as is, the first frame on the canvas + and in exports, played in the presenter and the preview) and TIFF images. +- **Missing:** Figma takes JPG, PNG, HEIC, WebP, GIF and TIFF images and MP4, MOV and WebM video + (help center, checked 2026-10-10). FigCraft decodes PNG, JPEG, WebP, GIF, TIFF and BMP; HEIC and + video need decoders that are C libraries (libheif, FFmpeg) or each platform's media framework. +- **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock). + +### 13. Vector tail: outline-stroke compositing +- **Landed (2026-10-15):** Outline stroke keeps the renderer's even-odd stroke rings; regression pixels, Undo/Redo, native save/reopen and instance opacity inheritance/overrides in `outline_stroke`. +- **Missing:** preserving filled-shape opacity, blend and effects on the composite. A follow-up must preserve component inheritance and references when deciding which node owns these properties. +- **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together, + sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG, + PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a + selected open path. Left: corner smoothing beyond rectangles and frames, the transform box around + points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. +- **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a + subtracted hole reaches what's behind; with the group or an operand selected its operands are + hit, so each can still be picked; "Select layer" lists the group only where its result is), live + boolean editing (operands selected by double-click or Cmd/Ctrl-click; + moving, resizing or editing an operand's points redraws the result during the drag, with every + operand outlined; per-operand operations from the boolean controls and `node.set`; a drag frame + recombines only the operands from the edited one up), Design panel Arc fields (Start, Sweep, + Ratio; Mixed across ellipses; bindable), the shape builder on strokes and open paths (strokes take + part as their outlined areas), one-sided variable widths (Alt-drag a width handle; canvas, PNG, + SVG, PDF and outline stroke). +- **Estimate:** compositing not yet re-estimated; vector QA with real files remains part of gap 3. + +### 14. Variables: extended collections on real libraries +- **Landed:** extended collections (`library.extendCollection`: a library collection's modes and + variables in a collection of this file, values overridden per mode with reset, following library + updates, variables of its own), variables import/export (`variable.export` / `variable.import`: + the REST local variables JSON, merged by name; CSS custom properties), gradient stops and variant + properties bound to variables. +- **Landed (2026-10-12):** variables a library adds to a collection this file extends are a library + update of their own (`library.updates` lists them by name, without an `id` until they have a + copy) and arrive inheriting when accepted; a library collection that is itself an extended + collection (a brand theme extending a core one) can be extended again, its values flowing down + the chain as each file accepts updates, every level's overrides kept. +- **Missing:** checked with synthetic files only; extending a collection of the same file isn't + offered (extending takes library collections). +- **Estimate:** 0–1 h. + +### 15. Prototyping: media triggers and actions, controllers off Windows +- **Landed (2026-10-12):** key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad + Home`, the standard layout's 17 buttons; a Controller menu beside the key field; REST files' + controller triggers import); the player reads controllers on Windows and in the browser (Gamepad + API) and takes `ui.input {gamepad}` on the control channel; widgets react to clicks in a + presented prototype, on the prototype's own copy; `proto.evaluate` computes expressions (mode + lookups included) where a layer's interaction would run (`crates/engine/src/proto_tail_tests.rs`, + `crates/ui-egui/src/present_tail_tests.rs`, `crates/format/src/rest/gamepad_tests.rs`). +- **Missing:** video triggers (on media hit/end) and play/pause/mute/seek actions (the REST + importer warns and skips them, `crates/format/src/rest/proto.rs`); controllers on macOS, Linux + and FreeBSD (the usual Rust controller library links libudev on Linux; a pure-Rust reader per + platform, or an owner decision on that dependency); PlayStation and Switch button labels. +- **Estimate:** 2–5 h, the media part after video fills (gap 12). + +### 16. Dev Mode: the tail after dev resources +- **Landed (2026-10-11):** code connect: a component or set links to its code component per + language (React, SwiftUI, Compose): an import path, the name and a template whose `{property}` + and `{property ? "a" : "b"}` placeholders take the instance's values, escaped per language and + never executed. `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet + instead of generated code (inside generated layer trees too); the panel's Connect to code edits + it with a live preview. Links are stored on the component (saved, synced, undoable) and travel + as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`, validated and capped) + so code owners can version them. Plugins whose manifest asks only to read run from the Inspect + tab. +- **Landed (2026-10-12):** dev resources: links for developers (a URL and a name) on any layer + (`devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced). An instance also + lists its main component's links (and its set's) read-only with where they come from; layers + inside an instance show their main layer's. URLs are checked as web links (http or https, a + host, no spaces, capped) and never fetched: a link added without a name is named after its URL, + and a link opens only when clicked. The Inspect tab lists, adds, edits and removes them; + `get_node` / `inspect_document` return them as `devResources`. +- **Missing:** import lines for connected instances inside a + generated layer tree (only the selected layer's own import is shown); publishing mappings from + the code repository with a CLI. An editor extension is out of scope: the MCP server already + serves design data, code and the connected snippets to agents and editors. +- **Estimate:** 0–1 h. + +### 17. Branching and merging +- **Landed (2026-10-11):** branches of a file (`branch.*`): File ▸ Create branch… (or the Branches + list in version history) copies the file into a branch tab that remembers the file as it was (its + base) and makes its ids in its own id space; edits on the branch are saved with the main file. + Review and merge (the banner on a branch tab) shows the main file and the branch side by side or + as an overlay, what changed on each side since the base (layers with their property changes as + Inspect shows them; styles, variables, comments) and the conflicts: the same property changed on + both sides, or a layer deleted on one side while the other edited it or what's inside it. Each + conflict is resolved for the branch or main; nothing conflicting merges until it is. Merging is + one undo step on the main file and archives the branch; Update from main brings main's changes + into the branch the same way; branches can be archived without merging. Three-way merge over the + collaboration change sets (`figcraft_collab::merge`), fuzz-tested with random real commands. +- **Landed (2026-10-12):** a branch's own version history: versions saved on a branch tab (by hand + or autosaved) go into the branch, not the main file, and are viewed, restored (one undo step on + the branch), renamed, deleted and duplicated there; they are saved in the file with the branch. + Merging adds a version to the main file's history (Merged "name"); the merged or archived + branch keeps its own history and opens with it. Fills, strokes and effects merge item by item: + different items changed on each side, or items added at the end on one side, merge without a + conflict; only the same item changed both ways, an item removed on one side, or different items + added on both sides conflict (as the whole list). +- **Missing:** branches of a file in a live session are refused; other lists (text runs, export + settings, guides…) still conflict as a whole; the branch history has no entries of its own for + its creation or updates from main (only saved versions, autosaves and the merge on main). +- **Estimate:** 1–2 h. + +### 18. Display P3 on screen (several windows landed) +- **Landed (2026-10-11):** several windows: File ▸ New window, a tab's Open in new window or a tab + dragged off the strip; each window has its own tabs, panels and canvas and draws only when it + needs to; closing one asks about unsaved changes; `window.new`, `window.list`, `window.focus` and + `window` on any control request. +- **Missing:** wide-gamut colour on the canvas (egui draws sRGB, so P3 colours outside sRGB clip on + screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.36 + gives only the main window one); window positions restored at the next start. +- **Estimate:** 4–8 h. + +### 19. AI features +- **Missing:** Figma AI (first draft, rename layers, remove background, image generation and + editing, visual search, Make). +- **Estimate:** 30–60 h with openly licensed local models. **Owner decision needed.** + +### 20. Mobile apps +- **Missing:** Figma's iOS and Android apps view and comment on files and mirror prototypes. + FigCraft's web build runs in mobile browsers but is not designed for touch. +- **Estimate:** 20–40 h for a touch-first viewer and mirror. + +### 21. Menu items without a command (measured): none left +- **Landed (2026-10-11):** `file.openRecent` (File ▸ Open recent: recent files kept in the + preferences, at most 20, greyed when the file is gone and dropped when picked, Clear recent), + `view.outlinesSelection` (outline mode for the selected layers only) and `view.maskOutlines`; + the plugin pair landed in #101. Every item in the menu catalog has a command + ([`parity-checklist.md`](parity-checklist.md)). +- **Estimate:** none left. + +### 22. Screen readers never run; browser has no accessibility tree +- **Evidence:** AccessKit names and keyboard navigation landed (#96), checked by tests only; + eframe's web backend doesn't send the tree yet. +- **Estimate:** 4–8 h plus human QA with VoiceOver, NVDA, Narrator and Orca. + +### 23. CMYK output intent for print PDFs +- **Evidence:** CMYK conversion is a documented naive formula; no CMYK ICC profile can ship in the + repository (licensing). +- **Estimate:** 2–4 h once an openly licensed profile is found. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | +| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | +| 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | +| 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged | +| 2026-10-12 | minor | Gap 2: sharing with a join code (Share ▸ Online or Local network, one Start sharing button, a short code to give others, one join field for a code or a link; the server names links with codes and limits wrong ones) added to the landed list; usability, so the estimate and ranking are unchanged | +| 2026-10-12 | minor | Gap 3: first measured fidelity data. Text box sizes and auto layout results of our example documents were compared with the same documents re-saved by other software; three text rules (whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only) were found and applied, and all 230 self-sizing text boxes in 7 documents now match to the pixel (one font family). Real files are still unchecked: hours and the ranking unchanged | +| 2026-10-12 | minor | Gap 2: live sessions are per document (each open file has its own session or none, several run at once, the window and the `collab.*` commands follow the file in front, closing a file ends its session only) added to the landed list; a fix, so the estimate and ranking are unchanged | +| 2026-10-12 | minor | Gap 1: the experimental `.fig` export now opens elsewhere: written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Hours and the ranking unchanged | +| 2026-10-12 | minor | Gap 1: an experimental `.fig` export landed (the whole document, from the same public format notes as the import; checked only by round trips through our own importer); export hours 15–30 h → 5–15 h (checks against real files); gap 1's read tail (5–10 h) and the ranking unchanged | +| 2026-10-12 | minor | Gap 4: keyboard shortcut parity suite per platform (129 documented rows: 126 bound, 1 moved, 2 missing; 48 undocumented bindings pinned) added to the landed list; estimate and ranking unchanged | +| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Gap 5 31–54 h → 29–51 h, rank unchanged | +| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Gap 5 32–55 h → 31–54 h, rank unchanged | +| 2026-10-12 | minor | Gap 15: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes and `proto.evaluate` landed; gap 15 now media plus controllers off Windows, 3–6 h → 2–5 h; ranking unchanged | +| 2026-10-12 | minor | Plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version, operator moderation) added to gap 6's landed list; gap 6 10–30 h → 8–26 h; ranking unchanged | +| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Gap 5 40–65 h → 32–55 h, rank unchanged | +| 2026-10-12 | minor | Plugin widgets (API 3) and the panel tail (panels side by side, colour picker on plugin fields) added to gap 6's landed list; gap 6 now 10–30 h; ranking unchanged | +| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Gap 5 45–75 h → 40–65 h, rank unchanged | +| 2026-10-12 | minor | Cursor chat and spotlight in live sessions (gap 2): `/` opens a chat bubble at your pointer, a spotlight makes everyone follow your view; ephemeral presence the host validates and rate-limits. Gap 2 45–90 h → 42–84 h | +| 2026-10-12 | minor | Layers and Assets panels behaviour suite and fixes added to gap 4's landed list; ranking and hours unchanged | +| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Gap 9 2–5 h → 1–3 h (pen pressure on macOS and Linux tablets left, blocked on winit). Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | +| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | +| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | +| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | +| 2026-10-12 | minor | Comments and version history behaviour suite and fixes added to gap 4's landed list (gap 17 unchanged: a branch's own version history is still left); ranking and hours unchanged | +| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | +| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Gap 1 30–60 h → 8–15 h (vector networks, other layer kinds, real-file checks; export 15–30 h on top, as before) | +| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | +| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | +| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h | +| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | +| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | +| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | +| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | +| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | +| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | +| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | +| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | +| 2026-10-10 | minor | Marked beta blockers; no alpha blockers (the alpha gate passes) | +| 2026-10-10 | major | Created from the 2026-10-10 re-measure against Figma desktop 126.10.7 (replaces the gap list that lived in ROADMAP.md) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 336eb1ad..2ac93002 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,125 +1,125 @@ -# FigCraft roadmap: milestones and what's next - -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) - -Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and -[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours -are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint crates. There is no -`cargo xtask scorecard` in this repo; the measured number is `cargo xtask parity`. - -## Current focus - -1. **`.fig` import's tail**: editable vector networks, the skipped layer kinds that matter and - fixes from real files (the best-effort import landed 2026-10-11): 8–15 h. -2. **Real-file corpus and fidelity measurement**: import real files, compare against Figma's own - exports, turn estimated percentages into measured ones: 15–30 h. -3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)): - 40–60 h, parallel by tool. -4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja, - ko and zh-Hans drafts and the es-419 overlay (people), then the other key languages: 3–7 h per language - plus native review. -5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h. - -## Alpha gate - -The core workflows a Figma Design user runs every day, checked end to end on macOS (the main -platform) with work saved to `.figcraft` and reopened. Evidence is the repo's tests and the -builders' side-by-side checks with synthetic files; nothing has been run on real Figma files yet. - -| Workflow | Works end to end? | Evidence | Hours to pass | -|---|---|---|---| -| Design screens: frames, shapes, vectors, text, auto layout, styles; save, reopen, export assets | yes | tools and panels in `crates/engine` and `crates/ui-egui`; `.figcraft` round trips (`crates/format/src/large_tests.rs`); PNG/JPG/SVG/PDF export tests | — | -| Build and use a design system: components, variants, properties, variables and modes, libraries | yes | `components_scale_tests.rs` (300 components, 2,000 instances), `library_tests.rs`, `library_variables_tests.rs`; libraries are local files, not shared | — | -| Prototype and present: interactions, flows, overlays, smart animate, variables | yes | `proto_*_tests.rs`, `present_*_tests.rs` | — | -| Hand off to developers: Dev Mode inspect, code, measurements, asset export, ready for dev | yes | `devinspect_tests.rs`, `component_codegen_tests.rs`, `devcompare_tests.rs`, `annotations_tests.rs` | — | -| Review with others: comments, versions, sharing the work | partial (not blocking) | comments with threads, mentions and resolve plus version history work inside a file (`comments_tests.rs`, `version_tests.rs`); the file is shared by passing it along; live sessions (preview: hosted from the desktop app or a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, undo per person); no accounts or teams ([collaboration-parity](collaboration-parity.md)) | beta gap: 45–90 | -| Continue existing Figma work (open `.fig`) | partial (beta gap) | best-effort `.fig` import (`crates/format/src/fig`, tested on generated fixtures, not yet on real files); REST JSON import works for files fetched through Figma's API ([file-format-parity](file-format-parity.md)) | beta gap: 8–15 | - -**Passes, so FigCraft stays alpha:** every daily design workflow works end to end on FigCraft's -own files, which save and reopen, and ready for real work (~47%) is above the ~40% bar. The two -gaps are real but don't break a workflow done in FigCraft: review works asynchronously through -comments in a shared file, and opening `.fig` is the step that moves work across from Figma, which -the standard requires for beta, not alpha. Both stay at the top of [`gaps.md`](gaps.md) as beta -blockers. - -## Milestones - -| Milestone | Status | Left | Hours | -|---|---|---|---| -| M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — | -| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — | -| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass | -| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | -| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | -| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11; vector edit mode regions 2026-10-13; stroke holes preserved 2026-10-15) | outline-stroke compositing, spell check, Figma Draw multi-edit | 7–18 | -| M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 | -| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | -| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | -| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | -| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | -| M10 Localization (new) | not started | layer + 11 languages | 60–100 | -| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 | -| M12 AI features (new, owner decision) | not started | local models | 30–60 | - -## Next, after the current focus - -- Team libraries for a whole team (lists, accounts and permissions) and deeper library analytics (4–8 h, after - accounts), branching (8–15 h). -- GPU canvas held to the CPU renderer (30–60 h). -- Figma Draw multi-edit and pen pressure on macOS/Linux tablets (2–5 h). -- Mobile viewer and prototype mirror (20–40 h). - -## Revision history - -| Date | Change | Summary | -|---|---|---| -| 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | -| 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | -| 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | -| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | -| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | -| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged | -| 2026-10-14 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen and leaves it again; FigCraft's own item. Numbers unchanged | -| 2026-10-14 | minor | Colour picker: wrapped rows of page and recent swatches stay inset on both sides instead of running past the picker's right edge. Numbers unchanged | -| 2026-10-14 | minor | Server snapshots (#188): figcraft-server never replaces a readable snapshot with one too large to be read back, and reads snapshots up to twice the file cap (recent batches kept for reconnecting clients may take a file over it). Numbers unchanged | -| 2026-10-14 | minor | Preferences owner-only on Unix (#200): `state.json`, which holds online owner tokens, is written with mode 0600 from the first byte, and an older world-readable file is narrowed. Numbers unchanged | -| 2026-10-14 | minor | ruzstd 0.9.1 (#182): `.fig` import requires the release that fixes RUSTSEC-2026-0344 (a zstd decompression bomb). Numbers unchanged | -| 2026-10-14 | minor | Layer cycles (#205): `validate` reports children listed twice, pages with a parent and layers not reachable from a page (cycles, detached subtrees); `repair` drops them in one linear pass. Numbers unchanged | -| 2026-10-14 | minor | AppImage CLI: `figcraft.AppImage cli ` runs the bundled `figcraft-cli` (MCP server included) on Linux without a second download. Numbers unchanged | -| 2026-10-14 | minor | Boolean outline depth (#193): nested boolean groups share one outline depth budget instead of each level getting its own. Numbers unchanged | -| 2026-10-14 | minor | Command batches roll back as one: a failed `command.batch` (MCP `batch`) restores every open document and its state, and puts the tab in front back. Numbers unchanged | -| 2026-10-14 | minor | Desktop builds no longer rebuild the text crate on every build when the craft-fonts manifest is missing. Numbers unchanged | -| 2026-10-14 | minor | Direct3D 12 on Windows: the desktop app asks wgpu for Direct3D 12 there instead of Vulkan, so it starts without Vulkan loader errors on machines with stray overlay layers. Numbers unchanged | -| 2026-10-14 | minor | Plugin metering (#192): every host call and every byte copied between a plugin and the host costs fuel, so a plugin can't stall the app through cheap-looking host calls. Numbers unchanged | -| 2026-10-13 | minor | M4: Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, octave-folded spacing fixed to the shape), and a click, Shift-click or drag selects and moves areas with their points (`vector_regions_tests`, `regions_tests`). Estimates unchanged. | -| 2026-10-13 | minor | M1, #220: The Layout panel's constrain-proportions link keeps the ratio on a W or H edit; the property edit dropped it, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep (transform behaviour suite). Estimates unchanged. | -| 2026-10-13 | minor | M1, #181: Repeat last command keeps the underlying command and parameters instead of recording itself; a stored self-reference is refused. Three cumulative repeats and their individual undo steps are covered by regression tests. Estimates unchanged. | -| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Focus item 4 adds the zh-Hans draft's review | -| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Focus item 4 no longer lists Latin American Spanish | -| 2026-10-12 | minor | M5: prototyping tail: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, `proto.evaluate`; M5's tail 3–6 h → 2–5 h | -| 2026-10-12 | minor | M11: a plugin directory on `figcraft-server` (bundles published with the server key and checked in full, browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, hash-checked downloads, updates by version, `plugin.directory.*`, `figcraft-cli plugin publish`); 10–30 h → 8–26 h | -| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Current focus 4 now lists the drafts' native reviews | -| 2026-10-12 | minor | M11: plugin widgets (API 3: plugin-drawn layers on the canvas, redrawn on clicks from state kept in the document), panels of several plugins side by side and the colour picker on plugin colour fields; 15–40 h → 10–30 h | -| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Current focus 4 now lists Japanese and Korean | -| 2026-10-11 | minor | `.fig` import, best effort: current focus 1 and the alpha gate's `.fig` row move to the import's tail (30–60 h → 8–15 h); M6 ~55% → ~65%, 37–75 h → 15–30 h | -| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | -| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | -| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | -| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | -| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | -| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | -| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | -| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | -| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | -| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | -| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | -| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | -| 2026-10-10 | minor | Added the alpha gate (core-workflow table): passes, stage stays alpha | -| 2026-10-10 | major | Created: milestone table moved from ROADMAP.md with statuses re-checked; added M10–M12; current focus re-planned | +# FigCraft roadmap: milestones and what's next + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) + +Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and +[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours +are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint crates. There is no +`cargo xtask scorecard` in this repo; the measured number is `cargo xtask parity`. + +## Current focus + +1. **`.fig` import's tail**: editable vector networks, the skipped layer kinds that matter and + fixes from real files (the best-effort import landed 2026-10-11): 8–15 h. +2. **Real-file corpus and fidelity measurement**: import real files, compare against Figma's own + exports, turn estimated percentages into measured ones: 15–30 h. +3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)): + 40–60 h, parallel by tool. +4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja, + ko and zh-Hans drafts and the es-419 overlay (people), then the other key languages: 3–7 h per language + plus native review. +5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h. + +## Alpha gate + +The core workflows a Figma Design user runs every day, checked end to end on macOS (the main +platform) with work saved to `.figcraft` and reopened. Evidence is the repo's tests and the +builders' side-by-side checks with synthetic files; nothing has been run on real Figma files yet. + +| Workflow | Works end to end? | Evidence | Hours to pass | +|---|---|---|---| +| Design screens: frames, shapes, vectors, text, auto layout, styles; save, reopen, export assets | yes | tools and panels in `crates/engine` and `crates/ui-egui`; `.figcraft` round trips (`crates/format/src/large_tests.rs`); PNG/JPG/SVG/PDF export tests | — | +| Build and use a design system: components, variants, properties, variables and modes, libraries | yes | `components_scale_tests.rs` (300 components, 2,000 instances), `library_tests.rs`, `library_variables_tests.rs`; libraries are local files, not shared | — | +| Prototype and present: interactions, flows, overlays, smart animate, variables | yes | `proto_*_tests.rs`, `present_*_tests.rs` | — | +| Hand off to developers: Dev Mode inspect, code, measurements, asset export, ready for dev | yes | `devinspect_tests.rs`, `component_codegen_tests.rs`, `devcompare_tests.rs`, `annotations_tests.rs` | — | +| Review with others: comments, versions, sharing the work | partial (not blocking) | comments with threads, mentions and resolve plus version history work inside a file (`comments_tests.rs`, `version_tests.rs`); the file is shared by passing it along; live sessions (preview: hosted from the desktop app or a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, undo per person); no accounts or teams ([collaboration-parity](collaboration-parity.md)) | beta gap: 45–90 | +| Continue existing Figma work (open `.fig`) | partial (beta gap) | best-effort `.fig` import (`crates/format/src/fig`, tested on generated fixtures, not yet on real files); REST JSON import works for files fetched through Figma's API ([file-format-parity](file-format-parity.md)) | beta gap: 8–15 | + +**Passes, so FigCraft stays alpha:** every daily design workflow works end to end on FigCraft's +own files, which save and reopen, and ready for real work (~47%) is above the ~40% bar. The two +gaps are real but don't break a workflow done in FigCraft: review works asynchronously through +comments in a shared file, and opening `.fig` is the step that moves work across from Figma, which +the standard requires for beta, not alpha. Both stay at the top of [`gaps.md`](gaps.md) as beta +blockers. + +## Milestones + +| Milestone | Status | Left | Hours | +|---|---|---|---| +| M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — | +| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — | +| M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass | +| M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | +| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | +| M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11; vector edit mode regions 2026-10-13; stroke holes preserved 2026-10-15) | outline-stroke compositing, spell check, Figma Draw multi-edit | 7–18 | +| M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 | +| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | +| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | +| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | +| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | +| M10 Localization (new) | not started | layer + 11 languages | 60–100 | +| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 | +| M12 AI features (new, owner decision) | not started | local models | 30–60 | + +## Next, after the current focus + +- Team libraries for a whole team (lists, accounts and permissions) and deeper library analytics (4–8 h, after + accounts), branching (8–15 h). +- GPU canvas held to the CPU renderer (30–60 h). +- Figma Draw multi-edit and pen pressure on macOS/Linux tablets (2–5 h). +- Mobile viewer and prototype mirror (20–40 h). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | +| 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | +| 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | +| 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | +| 2026-10-14 | minor | SVG import budgets (#190): gzipped SVG is unpacked by FigCraft within 64 MiB before parsing (it was unpacked without a limit), so the nesting check sees the real document; SVG documents embedded in `` data URIs share one 32 MiB budget and at most 4 levels, and one past a budget is left out. Linked files stay unread. Numbers unchanged | +| 2026-10-14 | minor | Leptos code (#223): `inspect.code`, `inspect.componentCode` and `codeConnect.set` take `language: leptos`: a `#[component]` with `#[prop]` defaults, variant enums and `view!` markup, instances as calls, Code Connect `use` lines; the Dev Mode language list and rem units include it. Numbers unchanged | +| 2026-10-14 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen and leaves it again; FigCraft's own item. Numbers unchanged | +| 2026-10-14 | minor | Colour picker: wrapped rows of page and recent swatches stay inset on both sides instead of running past the picker's right edge. Numbers unchanged | +| 2026-10-14 | minor | Server snapshots (#188): figcraft-server never replaces a readable snapshot with one too large to be read back, and reads snapshots up to twice the file cap (recent batches kept for reconnecting clients may take a file over it). Numbers unchanged | +| 2026-10-14 | minor | Preferences owner-only on Unix (#200): `state.json`, which holds online owner tokens, is written with mode 0600 from the first byte, and an older world-readable file is narrowed. Numbers unchanged | +| 2026-10-14 | minor | ruzstd 0.9.1 (#182): `.fig` import requires the release that fixes RUSTSEC-2026-0344 (a zstd decompression bomb). Numbers unchanged | +| 2026-10-14 | minor | Layer cycles (#205): `validate` reports children listed twice, pages with a parent and layers not reachable from a page (cycles, detached subtrees); `repair` drops them in one linear pass. Numbers unchanged | +| 2026-10-14 | minor | AppImage CLI: `figcraft.AppImage cli ` runs the bundled `figcraft-cli` (MCP server included) on Linux without a second download. Numbers unchanged | +| 2026-10-14 | minor | Boolean outline depth (#193): nested boolean groups share one outline depth budget instead of each level getting its own. Numbers unchanged | +| 2026-10-14 | minor | Command batches roll back as one: a failed `command.batch` (MCP `batch`) restores every open document and its state, and puts the tab in front back. Numbers unchanged | +| 2026-10-14 | minor | Desktop builds no longer rebuild the text crate on every build when the craft-fonts manifest is missing. Numbers unchanged | +| 2026-10-14 | minor | Direct3D 12 on Windows: the desktop app asks wgpu for Direct3D 12 there instead of Vulkan, so it starts without Vulkan loader errors on machines with stray overlay layers. Numbers unchanged | +| 2026-10-14 | minor | Plugin metering (#192): every host call and every byte copied between a plugin and the host costs fuel, so a plugin can't stall the app through cheap-looking host calls. Numbers unchanged | +| 2026-10-13 | minor | M4: Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, octave-folded spacing fixed to the shape), and a click, Shift-click or drag selects and moves areas with their points (`vector_regions_tests`, `regions_tests`). Estimates unchanged. | +| 2026-10-13 | minor | M1, #220: The Layout panel's constrain-proportions link keeps the ratio on a W or H edit; the property edit dropped it, so the link only worked while dragging resize handles. Both sides in one edit stay as typed, the `w`/`h` aliases work the same, each layer of a multi-selection keeps its own ratio, and a zero side has no ratio to keep (transform behaviour suite). Estimates unchanged. | +| 2026-10-13 | minor | M1, #181: Repeat last command keeps the underlying command and parameters instead of recording itself; a stored self-reference is refused. Three cumulative repeats and their individual undo steps are covered by regression tests. Estimates unchanged. | +| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Focus item 4 adds the zh-Hans draft's review | +| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Focus item 4 no longer lists Latin American Spanish | +| 2026-10-12 | minor | M5: prototyping tail: controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, `proto.evaluate`; M5's tail 3–6 h → 2–5 h | +| 2026-10-12 | minor | M11: a plugin directory on `figcraft-server` (bundles published with the server key and checked in full, browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, hash-checked downloads, updates by version, `plugin.directory.*`, `figcraft-cli plugin publish`); 10–30 h → 8–26 h | +| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Current focus 4 now lists the drafts' native reviews | +| 2026-10-12 | minor | M11: plugin widgets (API 3: plugin-drawn layers on the canvas, redrawn on clicks from state kept in the document), panels of several plugins side by side and the colour picker on plugin colour fields; 15–40 h → 10–30 h | +| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Current focus 4 now lists Japanese and Korean | +| 2026-10-11 | minor | `.fig` import, best effort: current focus 1 and the alpha gate's `.fig` row move to the import's tail (30–60 h → 8–15 h); M6 ~55% → ~65%, 37–75 h → 15–30 h | +| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | +| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | +| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | +| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | +| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | +| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | +| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | +| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | +| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | +| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | +| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | +| 2026-10-10 | minor | Added the alpha gate (core-workflow table): passes, stage stays alpha | +| 2026-10-10 | major | Created: milestone table moved from ROADMAP.md with statuses re-checked; added M10–M12; current focus re-planned | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1f17a8c0..3289a570 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,703 +1,703 @@ -# FigCraft parity with Figma - -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) - -The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) -summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is -[`parity-checklist.md`](parity-checklist.md). Deeper checklists: -[`ui-parity.md`](ui-parity.md), [`file-format-parity.md`](file-format-parity.md), -[`hardware-parity.md`](hardware-parity.md), [`localization-parity.md`](localization-parity.md), -[`auto-layout-parity.md`](auto-layout-parity.md), [`components-parity.md`](components-parity.md), -[`prototyping-parity.md`](prototyping-parity.md), [`dev-mode-parity.md`](dev-mode-parity.md), -[`collaboration-parity.md`](collaboration-parity.md). - -## Headline - -| Number | Value | Kind | -|---|---|---| -| Menu/command breadth | **100%** (211 / 211 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-12) | -| Feature breadth (does each Figma Design feature exist, weighted) | **~85%** (core editor areas 1–16: ~94%) | estimated from the area table below | -| Ready for real work | **~53%** | estimated (dimension table below: written weights × written per-dimension scores) | -| Essentials user | **~59%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.94 × 0.88) | -| Mainstream practitioner | **~50%** | estimated ([below](#mainstream-practitioner): weekly areas 72.7% × discounts 0.9 × 0.9 × 0.85) | -| Remaining to beta | **~155–280 h** one Opus 5.5 agent (~32–64 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated | -| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | - -The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were -self-graded against a 16-area rubric that left out, by design, multiplayer, plugins, cloud -libraries and AI features, and that counted no file-format blocker. Figma users rely on all of -those every day, so this re-measure weights them in. The core-editor depth figure drops from ~79% -to ~67% because nothing has yet been checked against real Figma files or side by side tool by -tool; presence was being graded as behaviour in places. - -## Readiness by audience - -| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | -|---|---|---|---| -| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | -| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the experimental `.fig` export opens elsewhere (5–15) | -| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | - -Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour -per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see -[how the hours are calibrated](#how-the-hours-are-calibrated)). Each audience's work is a subset of -the next. About 70–80% parallelizes across 4–6 agents on disjoint crates (roughly ÷4 in -wall-clock); the `.fig` reader, the multiplayer core and a plugin runtime each need one owner, and -native-speaker review, real-file licensing and screen-reader QA need people. - -The full number is an additive weighted sum over the dimension table; mainstream and essentials use -multiplicative discounts, as the standard says. - -## What was measured, and how - -- **Target and version.** Figma desktop for macOS, `CFBundleShortVersionString` 126.10.7, - installed at `/Applications/Figma.app`. FigCraft is measured as of `origin/main` d452da3 - (2026-10-10, PR #99). -- **The installed app, names and public listings only.** FigCraft is clean-room - ([`AGENTS.md`](../AGENTS.md)): nothing inside the bundle's code or web assets was read. From - `Info.plist`: one document type, `com.figma.document`, extensions `fig`, `jam`, `deck` - (MIME `application/x-figma`), and the `figma://` URL scheme. The `.lproj` folders are - Electron/Chromium locale packs (60+), not Figma's UI languages; the UI languages come from - Figma's help center (see [`localization-parity.md`](localization-parity.md)). The bundle ships - GPU, Plugin and Renderer helper processes (GPU rendering; a sandboxed plugin runtime). -- **The menu catalog.** `crates/engine/src/catalog.rs` holds Figma's main menu tree (202 items in - File, Edit, View, Object, Text, Arrange, Vector, Plugins, Preferences, Libraries, Help), written - from public documentation and black-box observation (local notes in the gitignored - `plan/figma/03-menu-tree.md`). It leaves out the Widgets menu and dynamic plugin lists, so the - 100% overstates breadth against the full menu a little. -- **From source** (no build; 2026-10-10): 449 command ids registered with `spec!`, 963 `#[test]` - functions in 177 files, ~108,800 lines of Rust in 13 crates + 3 apps + xtask (before #101–#103, which added the `plugin` and `collab` crates and ~7k lines), 140 key bindings - and 13 gesture rows in `figcraft_engine::keymap`, 21 files in the SVG import corpus, 0 - translated UI strings (no localization layer exists). -- **Figma's feature set**: help center, release notes and Config 2025 announcements (Figma Draw, - grid auto layout, slots, glass/noise/texture effects, progressive blur), known to the assessor up - to mid-2026, plus web checks on 2026-10-10 (supported image/video types, UI languages, pen - pressure in Figma Draw). -- **Side by side.** Look & feel was compared with Figma UI3 on the owner's Mac (screenshots kept - local, never committed). No real Figma file has been opened in FigCraft (no `.fig` reader), and - no rendered frame has been compared pixel for pixel against Figma's own export. - -## Weights - -Weights are the share of a typical product designer's working time in Figma Design, written down -so they can be argued with. Areas 1–16 keep the original rubric's weights (they sum to 94, not the 100 the rubric claimed); -areas 17–21 are added (sum 19) because Figma users depend on them. Percentages below divide by the -total, 113. -Out of scope as separate products: FigJam, Figma Slides, Sites, Make, Buzz, Weave (the `.jam` and -`.deck` document kinds belong to them). - -## By feature area - -Breadth: does it exist. Ready: depth, correctness and fidelity for real projects. Hours: one Opus -5.5 agent, sequential, tests included. - -| # | Area | Weight | Breadth | Ready | Remaining | Hours | -|---|---|---|---|---|---|---| -| 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | -| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | -| 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | -| 4 | Shapes & vector | 8 | 99% | 73% | Outline-stroke compositing; corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (outline stroke rings covered by `outline_stroke`; behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | -| 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | -| 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | -| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | -| 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | -| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | -| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | -| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | -| 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | -| 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | -| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | -| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | -| 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | -| 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | -| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | -| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | -| 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | -| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| | **Weighted (÷113)** | 113 | **~85%** | **~63%** | | **117–255** | -| | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 32–74 | - -## By dimension (ready for real work) - -| Dimension | Weight | Ready | Hours | Evidence | Doc | -|---|---|---|---|---|---| -| Features (depth) | 35 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file | -| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | -| File formats | 12 | 60% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | -| Collaboration | 10 | 13% | 52–104 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | -| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | -| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | -| Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | -| Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese (none reviewed by a native speaker; every draft covers all current strings), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | -| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | -| Ecosystem/plugins | 3 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | -| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~53%** | **~262–519** | | | - -Dimension hours add up to the full-parity total without double counting: the feature-area rows -for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are -counted under their dimensions. - -## Essentials user - -Someone who uses only the core of Figma: a casual or beginner user making a few screens, a social -graphic or a simple mockup. Same method, with the core features a casual user touches. - -**1. Core features, weights and depth** (depth from the area table and checklists above): - -| Feature | Weight | Depth | Notes | -|---|---|---|---| -| Create, open, save and reopen a file; pages | 10 | 85% | home screen, `.figcraft` round trips, Open recent | -| Pan and zoom | 10 | 80% | wheel, pinch, Space-drag, zoom shortcuts | -| Select, move, resize, rotate, align, group, duplicate | 15 | 75% | not yet compared tool by tool | -| Frames with device presets | 8 | 80% | | -| Rectangle, ellipse, line, arrow, corner radius | 8 | 80% | | -| Text: type, font, size, weight, colour, alignment | 12 | 70% | no spell check | -| Colour picker, fills and strokes; place and crop images | 10 | 72% | no HEIC images | -| Layers panel: rename, hide, lock, reorder | 7 | 80% | | -| Basic auto layout (add, gap, padding) | 6 | 70% | | -| Undo / redo | 5 | 90% | unlimited, one step per gesture | -| Export PNG, JPG, SVG, PDF | 6 | 85% | | -| Copy and paste, incl. images from other apps | 3 | 75% | | -| **Weighted** | 100 | **77.8%** | | - -Left out: components and variables beyond using one, prototyping, Dev Mode, advanced vector and -type, and everything the mainstream number leaves out. - -**2. Discounts a casual user actually hits:** - -| Discount | Factor | Evidence | -|---|---|---| -| Launch and stability | ×0.92 | signed and notarized macOS build, installers for every platform (v0.2.0), never-crash rules and recovery copies; no field record yet | -| Discoverability and UI clarity | ×0.94 | the UI3 layout Figma users know, Actions palette, keyboard shortcuts panel; a first run opens on the home screen with a Getting started card (six tips with their shortcuts, closable, back from the Help menu) and eight starter templates to open (also File ▸ New from template…); no Community files, no guided tour, and nobody new to the app has been watched using it | -| Opening files people send them | ×0.88 | what people send a Figma user is a Figma link or a `.fig`; a `.fig` opens (best effort, not yet checked on real files), a link doesn't. Images and SVG do | - -77.8% × 0.92 × 0.94 × 0.88 = **~59%** (estimated). User evidence: none yet (see above). - -## Mainstream practitioner - -The same question for the typical professional Figma Design user: a product designer who designs -screens, maintains or uses a design system, prototypes and hands off to developers every week. -Computed per craftrules `standards/progress-docs.md`. - -**1. Areas used weekly, with weights.** Feature areas 1–16 of the table above, with their weights -(sum 94). Left out as the standard says: plugins and widgets (18), AI (21), team and organization -libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a product designer), -localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration -and is left out as an area, but its effect on working with Figma users is counted in discount 3. - -**2. Average depth** ("Ready" column, weighted): **72.7%**. - -**3. Discounts for what still stops real work:** - -| Discount | Factor | Evidence | -|---|---|---| -| Interaction fidelity | ×0.90 | UI3 look compared side by side (~65% by the builders); no tool-by-tool comparison of cursors, modifier keys while dragging, snapping thresholds or popovers yet; Ctrl to suspend snapping on macOS missing ([ui-parity](ui-parity.md)) | -| Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way | -| File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) and writes an experimental one (written files open and draw in other software; effects, images and prototypes unchecked there); incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) | - -72.7% × 0.90 × 0.90 × 0.85 = **~50%** (estimated; 50.03%, rounded down). - -**4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments -from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is -private, 1 star, PRs only from maintainers). No outside evidence either way; nothing raises the -number. - -**Why it is lower than ready for real work (~53%), unusually.** The full number averages -dimensions additively, so the missing `.fig` exchange costs only the file-format dimension's 12% -weight, and FigCraft earns points the mainstream number doesn't count (platforms 75%: Linux, -FreeBSD and the web, where Figma has no desktop app). The mainstream number applies file exchange -as a multiplier on everything, because a Figma practitioner's whole working week is shared through -Figma files. Both are computed from written weights; neither was moved to match the other. The -mainstream number rises fastest with `.fig` import and the experimental `.fig` export checked on real files (the ×0.85 toward ×0.95: ~53%) and the -interaction pass. - -## How the hours are calibrated - -From this repository's own history (`git log`, `gh pr list`): - -- The whole app was built in ~25 wall-clock hours: first commit 2026-10-09 15:38 UTC, PR #99 - merged 2026-10-10 16:53 UTC; ~49k lines on day 1 before PRs, then 99 PRs adding ~81k lines. The - ROADMAP of that period records 4–6 agents in parallel, so ~110–150 agent-hours in all: about - **1 agent-hour per ~1,000 added lines**, and a typical feature PR (1,000–2,500 lines, e.g. - #84 arc handles, #92 PDF print options, #96 accessibility) took **1–2.5 agent-hours**. -- Between the ROADMAP revisions of 2026-10-09 22:19 UTC and 2026-10-10 16:53 UTC (~18.5 h wall, - 78 PRs, ~75–110 agent-hours) the self-graded depth went 58% → 79%: **~4–5 agent-hours per - self-graded point**. This re-measure grades about 0.6 points for each of those, which puts an - honest point nearer 7 agent-hours in the polish tail; new subsystems (a `.fig` reader, - multiplayer, a plugin runtime) are estimated bottom-up instead, since nothing comparable has been - built here yet. -- Parallelism: most of the work splits across 4–6 agents on disjoint crates (the history shows it - working); multiplayer, `.fig` import and the plugin runtime each need one owner for their core. - Needs a human: native-speaker review for every language, the plugin-runtime and AI-model - decisions, real-world Figma files to test with (licensing), Windows/Linux/screen-reader QA. - -## Area detail: what has landed and what is left - -Carried over from the ROADMAP's "Where we're lacking" list (2026-10-10, before this re-measure). -Hours in this narrative are the builders' estimates; the tables above supersede them. - -1. **Vector edit mode**: *landed:* edit-mode toolbar (move, pen, bend, paint bucket, lasso, cut), - pen that extends, branches and closes paths (vector networks), paint bucket fill/unfill of any - closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and - length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and - merge, delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder - (⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept), - variable width strokes (width profile presets with previews in the stroke settings, the Variable - width tool ⇧W: drag the path to add a width point, slide points, drag their handles, Delete; - smooth interpolation per subpath, dashes, caps and joins, canvas/PNG/SVG/PDF and outline stroke), - a transform box around several selected points (corners and edges scale them, Shift keeps - proportions, Alt from the centre; just outside a corner rotates, Shift in 15° steps; drag inside - to move; the Design panel's X/Y/W/H and flip/rotate act on the points), point snapping while - dragging points or handles and with both pens (to the points and segment midpoints of this and - other vectors, the usual layer targets and the pixel grid, with guides and a marker on the point - landed on; Ctrl/Cmd turns it off), arc handles on the canvas (sweep, start and ratio with a - live readout, Shift snapping, closing at the start, the other part from the gap; rotated and - flipped ellipses, any zoom), Design panel Arc fields (Start, Sweep, Ratio; bindable), live - boolean editing (operands selected by double-click or Cmd/Ctrl-click; moving, resizing or - editing an operand's points redraws the result during the drag with every operand outlined; - per-operand operations from the boolean controls; only the operands from the edited one up are - recombined; clicks hit a boolean group where its result is), the shape builder on strokes and open paths (strokes as their outlined areas), and - one-sided variable widths (Alt-drag a width handle; canvas, exports and outline stroke), and - filled regions with the Move tool (the area under the pointer hatched, clear of points and - segments, its spacing by octave folding fitted to measurements at several zoom levels and fixed - to the shape; click, Shift-click and drag to select and move areas with their points). - *Left:* vector QA with real files. 1–2 h. -2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case, - truncation/max lines, font picker with previews and recents, word/line selection, IME commit, - inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the - document only gets the committed text), variable fonts (axes and named instances read from the - font, named instances as styles, Type settings ▸ Variable sliders, per-range axis values applied - to shaping, outlines and PDF), range-aware Typography panel with "Mixed". Range fills in the Fill - section. Right-to-left and mixed-direction text (Unicode bidi: Arabic shaping, mirrored brackets, - caret, clicks and selection follow the runs; arrow keys move visually). Right-to-left and - mixed-direction names in the panels (layers, pages, Assets, tabs, the file header, canvas frame - titles: visual order, joined Arabic, the ellipsis at a right-to-left name's left end; rename fields - whose caret, clicks, drags and arrow keys follow the shown text; system fonts for scripts the UI - fonts lack). The text style browser in the Typography section (list and grid views in each - style's 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, "Mixed" when the - chars use several styles); text styles link chars, not just layers (editing what a style sets - unlinks the chars edited). Live previews of a hovered named instance or font style and of a - dragged axis slider (one undo step on release, Escape cancels). Visual QA: list markers keep the - paragraph's font, renames start with the name selected and centred, Max lines shows while - truncating. Right-to-left paragraphs mirror lists and indents: bullets and numbers (".1") on - the right, wrapped lines hanging from the right, the paragraph indent on the right, trailing - spaces hanging outside the line (canvas, PNG, PDF and SVG). A behaviour suite from the public - documentation (`crates/text/src/behaviour_tests.rs`, `crates/engine/src/text_behaviour_tests.rs`): - Auto / % / px line heights with the leading split above and below every line, letter spacing in - % and px, paragraph spacing and indent (start-aligned text only), resizing and truncation, - decorations from the font's metrics; CJK line breaking (between ideographs, kana and Hangul, no - break inside Latin words, kinsoku for closing and opening punctuation, small kana and the - prolonged sound mark); a style's metrics from its own font, not a fallback; carets, Delete and - Backspace by grapheme cluster; double-click and word moves by script run; IME composition with - CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`, - `crates/engine/src/text_tail_tests.rs`): lists per paragraph, nested up to 8 levels with Tab and - Shift+Tab (1. a. i. and • ◦ ▪ per level, counts restarting after plain paragraphs), Enter on an - empty item and Backspace at an item's start stepping out, `- `/`* `/`1. ` starting a list; list - spacing between items; hanging lists and hanging punctuation; decoration style (solid, dotted, - wavy), thickness, underline offset, skip ink and colour, with inspect, CSS and the REST import. - *Left:* visual QA; hanging punctuation in right-to-left paragraphs and centred text, list - numbering that continues across a plain paragraph and a list's own start number (documentation - silent); decoration colour in SVG exports (drawn with the text's fill, like run fills); Korean - keeps breaking by syllable and Han runs aren't split by a dictionary (documentation silent). 0.5 h. -3. **Auto layout and sections depth**: *landed:* on-canvas padding/gap handles (also wrapped-row - and grid column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, - hugging text wraps at its max), ignore auto layout (absolute position) with constraints, text - baseline alignment, grid tracks (fixed/hug/`fr`), grid cells and spans, wrap row gap UI, - wrap-aware drag-reorder (by row) and dragging into a grid cell, nested hug/fill settling in one - pass (wrapping fill text, three levels deep, grid rows), min/max redistribution, coherent - Fill/Hug rules, drag across nested auto layout frames (deepest one wins, insertion line, out to - the canvas or a plain frame). Arrow keys move auto layout children one place (along the flow - past hidden and absolute siblings, Up/Down between wrapped rows, to the neighbouring grid cell - (automatically placed children swap, anchored ones take or swap cells), and at the end of a - flow or across a row's axis into the neighbouring nested auto layout frame when the frame - around flows that way; one undo step, none when nothing can move). Visual QA of nested - hug/fill/fixed/min/max/wrap/grid samples against the documented rules (Fill turns a hugging - parent Fixed, min/max redistribution, wrap inside fill, absolute children following - constraints as hug frames grow). Sections: hide contents (name, outline and "Contents hidden" - on the canvas; contents skipped by rendering, hit testing and labels; dimmed in the layers - panel; Design panel checkbox and context menu), Wrap in new section, frames in sections get - canvas labels and are top-level frames (flows start there, navigation, Export frames to PDF), - "Navigate to" a section shows its frame shown last, else its first; ready for dev turns - "Changed" when the contents are edited, resolved with a reason; Compare changes shows what a - changed frame or section looked like when it was marked next to how it is now, with the - changed layers and their property changes. *Left:* whether `fr` rows of a fixed-height grid - should make room for fixed-size children (now `minmax(0, 1fr)`-like). 1–2 h. -4. **Components depth**: *landed:* component properties (edit/delete/bind) with the properties - panel, add variant, instance swap UI, per-layer overrides with reset, restore main, instance - structure protection; Assets panel with live thumbnails, search and drag-to-canvas (drops into - the frame or auto layout slot under the pointer); exposed nested instances (their properties - show on the main's instances); component descriptions (edited on the main, shown on - instances); local libraries (publish a file's components, sets and styles with per-asset - versions, enable library files, place library components and apply library styles as - read-only linked copies, "Library updates available" with overrides kept on update); slots - (a frame of a main made a slot property with preferred components; instances fill it with any - content of their own, kept across main updates, variant switches, copy/paste and detach, with - the main's default content until edited; reset slot; slot badges in the layers panel and on the - canvas; fill menu in the properties panel; drops from the Assets panel and the canvas land in - slots); nested overrides kept when the main moves or swaps a nested instance, and on nested - instances copied out of an instance (re-linked to their own main); canvas drags can no longer - pull layers out of an instance; library variables (publishing includes the file's variable - collections with their modes, aliases and scopes, versioned per variable and collection, without - variables hidden from publishing; files using the library see its collections read-only under - the library's name in the Variables editor and the variable pickers; binding, aliasing or setting - a library collection's mode on a frame copies the variables in as read-only linked copies, so the - file renders without the library; library components keep their variable bindings and modes; - "Library updates available" covers changed variables and bound layers follow). Explicit variable - modes in the Design panel: a frame's mode per collection, Auto (inherited) or its own, with - enabled libraries' collections listed under the library's name (picking a mode copies the - collection in). Instance boolean and text properties bound to boolean and string variables - (following modes, renames, reset and detach); layout grid settings in the Design panel whose - count, size, margin/offset and gutter bind to number variables; fills, strokes, effects and - layout grids reordered by dragging their rows, their variable bindings following. Components at - scale, QA'd with a synthetic design system (about 300 components: buttons, inputs, cards, navs - and badges as sets of size × state × type × theme variants, boolean/text/instance swap/slot - properties, instances nested four deep, text and paint styles, light/dark variables bound across - it) used 2,000 times with overrides at every level: main edits keep every override and match a - full rebuild exactly; values from variables (a theme switch, a bound corner radius) are no longer - recorded as overrides; detaching a variable on an instance layer is an override that survives main - edits; exposed nested instances stay exposed and swapped nested instances keep their layer names - through main edits; layers a swapped nested instance's main adds keep their ids and overrides. - Instance swap properties have preferred values (offered first). Alt-drop from the Assets panel - onto an instance swaps it (same-set instance preferred, highlighted while dragging); instances - pasted into other files follow their mains (library copies shared, published components used - from their library, other mains left behind as a restorable missing main). The Assets panel lays - out only the tiles in view and renders thumbnails as they show (cached, re-rendered only for - changed components). A behaviour suite from the public documentation checks overrides (what an - instance takes, what it refuses: geometry, constraints and resizing of its layers, its auto - layout direction), variant switches (overrides stay where both variants agreed; text layers keep - theirs), swaps (by layer name; from the Assets panel only text overrides), the top-left default - variant, detach, reset and push. *Left:* QA with real-world (not synthetic) libraries. 1 h. -5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor, - flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter. - Custom bezier and custom spring easings with editor fields; device frames (phone, tablet, - laptop, desktop bodies drawn in code) around the presented frame; overlays animate out with - their opening transition reversed; several selected layers edit their interactions together - (or replace mixed ones); scrolling inside device frames (the frame sized to the device's screen, - clipped or scrolling by wheel and drag, fixed layers pinned to the screen by their constraints, - nested "Scroll to", preserve scroll position); a resizable preview pane over the canvas that - plays the selected frame and follows edits live; device rotation (phones and tablets turn to - landscape: saved in the file, imported from REST files, drawn turned in the presenter and the - pane); presentation options (fit, fit width, fill, 100%, with Z to cycle them and R to - restart, the device frame shown or hidden, kept between presentations; what overflows the - window pans); scroll momentum (drags fling and slow down the same at any frame rate, the wheel - coasts, a drag past the ends stretches and springs back); responsive presentation (the presented - frame resized to the device's screen, or the window without a device, its constraints and auto - layout responding live; in the Options menu and the Prototype panel, saved in the file); device - colours (each model's colour variants, bodies drawn in code) and custom device sizes (a bare - screen with width and height fields, imported from REST files); flow descriptions in the - presenter's flow picker and under each flow; conditional actions (if / else if / else over - variable expressions) and set-variable-mode actions run in the presenter (from REST files or - commands). Conditionals are built in the interaction editor: an interaction's actions are a list - (add, reorder by dragging a row's grip, also inside conditional blocks, or from its menu, remove, - open one to edit it; one undo step per move), "Add action ▸ Conditional" adds an if whose - condition comes from a builder (a variable from the variable picker, an operator, a value or - another variable) or is typed as an expression (`count >= 3 and not done`, checked when entered, - what's wrong shown under the field), each block with its own actions (the same editors, - nested), plus "Else if" and "Else"; set variable values can be expressions (`count + 1`, - `"Hi " + name`); expressions read a variable's value in a given mode of its collection - (`accent[Dark]`, also from REST files' variable mode lookups) and the mode a collection is in - where the interaction runs (`mode(Theme) == "Dark"`, following set variable mode actions); every - edit is an undo step (`proto.action.*`, `proto.conditional.*`). Hotspot hints: a click where - nothing reacts flashes the screen's hotspots (an Options menu toggle, on by default); the - presenter's Inspect panel shows the hovered layer's properties. Overlays opened through commands - close on a click outside unless told not to, as in the editor. A behaviour suite written from - the public documentation covers triggers, actions, overlays, animations, easing, scrolling and - interactive components ([prototyping-parity](prototyping-parity.md)); its fixes: drag-driven - animations go their way and follow the pointer, key combinations, overlay timers from when they - open, instances play their variant's interactions, physical springs, smart animate overshoot, - fixed layers on top; scroll to takes an offset. *Left:* QA against larger real-world - prototypes. 1–2 h. -6. **Interop**: *landed:* image crop handles; REST API file JSON import (`file.open` / `file.import` - / CLI: pages, layer tree, geometry, paints, strokes, effects, text runs, vectors from geometry - paths, auto layout, constraints, grids, components, sets, instances with overrides, component - properties, styles; image fills from an `images` folder or placeholders; warnings for the rest), - now with prototyping (interactions with their triggers, actions, transitions and easing, flows, - scrolling, the prototype device; conditional actions (if / else if / else over expressions), - set variable with variable references and expressions, set variable mode, all run by the - presenter) and variables (the local variables JSON passed along, next to the file or embedded: - collections, modes, aliases, scopes, layer bindings and explicit modes). - Export panel: a row per export setting (typed or preset sizes like `2x` / `512w` / `512h`, PNG, - JPG, WebP, SVG and PDF, suffix, advanced options: contents only, include bounding box, and for - SVG outline text, id attributes and simplified strokes), a preview, and "Export N layers" (`file.exportLayers`) in the - panel and the Export dialog. Paste from other apps: images, SVG and text (the newest clipboard - content wins over our own copy; pasted SVG lands centred in the view). SVG import is editable and - checked against a corpus of synthetic files (expected pixels worked out by hand, layer structure, - export and re-import): groups named from their ids with transforms baked into the layers; - rectangles (corner radius), ellipses and lines kept as those shapes; gradients with exact - handles, `reflect`/`repeat` and focal points; patterns as image fills; clip paths and masks as - vector, luminance and alpha masks; shadow and blur filter chains as drop shadows, inner shadows - and layer blurs; blend modes; text as text layers with runs, anchors and stacked lines; images - and nested SVG images; import warnings for what's left out; linked files never read; hostile - input fuzzed. SVG export keeps alpha and luminance masks. PDF export: one page per frame in points (Export frames to - PDF, the Export panel, `figcraft-cli export -o x.pdf`), vectors, gradients and strokes as - vectors, real selectable text in subset fonts with clickable hyperlinks, images embedded once, - opacity and blend modes, clip content, alpha/luminance/vector masks, shadows and layer and - background blurs as images; checked by rendering the PDFs back against PNG exports. PDF print - options in the Export panel (per PDF export setting), the Export dialog, `file.export`, - `file.exportLayers`, `file.exportFramesPdf` and `figcraft-cli export`: a bleed showing what lies - past the frame (its fills reach out, its children are clipped at the bleed), crop and bleed - marks outside it, MediaBox / BleedBox / TrimBox, and CMYK colour (a documented, deterministic - naive conversion; images and rasterised effects too). - *Left:* .fig import (best effort), a CMYK output intent (no CMYK ICC profile can ship in the - repository), SVG filters other than shadows and blurs (left out with a warning). 8–16 h. -7. **Look & feel and interaction-fidelity pass**: *landed:* keyboard shortcuts audited against the - public shortcut reference: one keymap (`figcraft_engine::keymap`) drives the menus, the canvas, - the control channel and MCP, with macOS and Windows/Linux keys where they differ (mask, bring to - front/back, distribute, tidy up, layout grids, pixel preview), Text-only and canvas-only bindings - (Cmd/Ctrl+K: link while typing, Actions on the canvas), Cmd/Ctrl+/ for Actions, `+`/`-` zoom, - Shift+0/1/2 from any layout, Cmd/Ctrl+0, hold Z for the zoom tool (Alt zooms out, drag an area), - two quick digits for an exact opacity, boolean keys (Alt+Shift+U/S/I/E), remove fill/stroke - (Alt+/, Shift+/), outlines on Shift+Cmd+O, pixel grid on Shift+' (and Cmd+'), Shift+E / Shift+D and the other panel keys, Ctrl+Tab between files, Shift+B (and B) for the paint bucket in vector edit mode; the Keyboard - shortcuts panel docks at the bottom with a tab per group and search, keycaps per platform. - A shortcut parity suite (`shortcut_parity_tests.rs`) checks every documented shortcut on both - platforms (129 rows: 128 bound, including moving the rotation origin with Alt+R and placing an - object with Enter from the toolbar; 1 moved off a documented conflict) and pins the 48 undocumented - bindings; others' cursors (`Alt+Cmd+\`), list levels while editing (Cmd/Ctrl+] and [), the - keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, - Control to suspend snapping on macOS and Space/N/C/F while presenting landed with it - ([ui-parity](ui-parity.md#keyboard-shortcuts)). - *Left:* tool by tool, panel by panel against the incumbent (cursors, modifier keys while - dragging, hover states, menus, popovers); per-keyboard-layout shortcut tables. 40–60 h. -8. **Collaboration (local):** comments landed: pins pinned to frames and layers that move with them - (to other pages too) and stay where they were when the layer is deleted, your pins dragged onto - another frame, crowded pins clustered when zoomed out (a click zooms them apart), Shift+C to show - or hide them; threads where you edit and delete your own messages, emoji reactions, @mentions - with a picker of the names used in the file (your name from Preferences), read and unread state, - resolve, and "Copy link" (`figcraft://comment/`: pasting or `comment.open` goes to the page, - zooms to the frame and opens the thread); the Comments panel with search (text, names, frame), - filters (open, resolved, yours, unread), sorting by date or page, each thread's frame and page, - and Up/Down to step through threads; comments on the presented frame (Comments button or - Shift+C: read and reply). All of it through `comment.*` commands. Version history - landed (named versions saved in the `.figcraft` file; restore, rename, duplicate, delete; panel; - view a version on the canvas read-only before restoring it; autosave checkpoints every 30 - minutes, folded in the panel). A behaviour suite written from the public documentation covers - both: restoring keeps the comments and first keeps the replaced design as an autosave, a titled - autosave becomes a named version, merging a branch records the main file before and after, and - threads made in a live session can be replied to and resolved. Real-time collaboration is designed (`docs/collab-protocol.md`: - WebSocket sessions the app can host itself, server-ordered per-property changes, live cursors) - and its core landed (`crates/collab`, no networking yet): client-scoped node ids, every saved - layer field and the document-level data (styles, variables, comments, images…) as synced - properties, diffs after a command that cost the size of the edit, fractional sibling positions, - a session server that orders and validates batches (cycles, deleted layers, id spaces, sizes) - and a client that shows its own edits at once and replays them over others'; derived data - (auto layout, instances, bindings) is repaired by the author after concurrent edits; binary - messages (a moved layer is 43 bytes, a cursor 23) with a JSON form; a randomized multi-client - test over real commands converges. Live sessions run over the network (hosting from the app, - invite links, document sync, live cursors, selections, avatars, follow). Undo is per person in - a session (`edit.undo` takes back only your own steps, leaving what others changed since; - creations and deletions invert; a gesture or command group stays one step); refused edits roll - back with a toast; a dropped connection keeps editing and resumes as the same client with what - it missed (or a snapshot), then sends what was made offline. Randomized sessions with undo, - redo and dropped connections converge, and a checker confirms no undo reverts someone else's - change. - Plugins landed: sandboxed WebAssembly plugins (a metered wasmi - interpreter with a memory cap and no WASI, also in the browser) whose API is the command - registry; each run is one undo step and is rolled back when it fails (error, trap, endless loop, - memory); a `figcraft-plugin.json` manifest declares menu commands with parameters and read or - write access with an optional command allowlist, and files, other documents, the undo history, - dialogs and other plugins are always off limits. Plugins ▸ Development ▸ Import plugin from - manifest… (kept in the preferences, the module re-read on every run), the installed plugins in - the Plugins menu with a parameters form drawn from the manifest, Run last plugin (⌥⌘P), Manage - plugins, and `plugin.*` commands for scripts, the CLI and MCP; a Rust SDK and two example - plugins (Tidy names, Swatch grid) in `plugins/`, built by `cargo xtask plugins`. Then plugin - panels (ABI v2: fields and buttons drawn with FigCraft's widgets, the plugin called back for - each button or changed value as one undo step, `plugin.ui` / `plugin.event` / `plugin.close` - for scripts and MCP) and per-plugin settings kept with the preferences. Then widgets (API 3: - a plugin describes a layer of its own, validated and built as a frame with locked layers; clicks - on its named parts call the plugin back in a fresh sandbox with the state kept in the layer, one - undo step each; `widget.insert` / `widget.click` / `widget.list`; a Counter example), panels of - several plugins side by side and the colour picker on plugin colour fields. Then a plugin - directory on `figcraft-server`: bundles published with the server key and checked in full, - browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, downloads - checked against their SHA-256, updates by version. *Left:* a public catalogue, publisher - accounts and signatures; richer widget events; the directory in the browser. 8–26 h. -9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer - pages: engine, render and headless UI-frame timings). The canvas keeps its last image and - re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders - in ~0.2 ms instead of ~8 ms (0.8 vs 24 ms at 50k zoomed out), a 40 px pan in ~0.7 ms instead of - ~7.5 ms. The layers panel lays out only the rows in view (every screen expanded at 50k: 39 → - 0.6 ms a frame); text layouts are shared instead of re-formatted per lookup; overflowing text is - culled by its glyphs. At 10k: full 1440×900 renders 7–15 ms, single-layer edits ~10 µs, hit - tests ~26 µs. Zoomed out (50% and below) the canvas draws levels of detail: layers under 2 px - land as their averaged colour, text under 4 px is greeked into bars, small rectangles and clip - layers skip the path pipeline (exports and closer zooms stay exact): a whole 50k-layer page - renders in ~8 ms instead of ~50 ms. `.figcraft` at 50k layers saves in ~0.09 s instead of ~1 s - and loads in ~0.16 s instead of ~0.86 s (JSON streamed to and from the document without a - value tree, layer fields at their defaults left out: 63 → 22 MB of JSON, old files still - open). The retained canvas is held to a full render: a seeded random walk (edits, undo/redo, - pans and zooms over nested, rotated and clipping frames, groups, masks, booleans, auto layout, - effects, text, instances, variables and styles) checks every step, and found clip edges on a - pixel boundary letting a column through in some renders. Drags show live on the canvas (every - finished render is shown, not only the newest one asked for), and a button held still no - longer rebuilds the preview and re-renders every frame. - Components at scale (`cargo xtask bench`, synthetic design system of about 300 - components used by 2,000 instances): edits of mains reach their copies directly instead of - rebuilding every instance (value, size and layer added/removed edits; other edits rebuild each - affected instance once, inner mains first), bound variables no longer touch unchanged layers on - every edit, auto layout re-flows only where sizes or places changed: recolouring or resizing a - main's layer used ~900 times 188/226 → 4/18 ms, adding a layer to it 430 → 21 ms, a label style - in all 72 button variants (1,800 copies) 450 → 57 ms, switching a variant, reset, detach, push - and paste ~450 → under 3 ms, accepting a library update 460 → 64 ms; the Assets panel with 2,000 - assets 3.4 → 1.3 ms a frame. Accessibility: the custom-painted - controls name themselves for screen readers (AccessKit on desktop: role, name, value and state - for tools, layer rows with their type, level, selection and hidden/locked state, fields, - dropdowns, tabs, menu items, and the canvas, which describes the selection); keyboard navigation: - F6 / Shift+F6 move between the toolbar, layers, canvas and properties, Tab / Shift+Tab move - within a region with a focus ring, Enter or Space activate, the arrows go along the toolbar and - through the layer rows, menus and tool flyouts opened from the keyboard take it and give it back, - Escape returns to the canvas (where Tab selects the next layer again instead of wandering into - the tab strip); reduced motion follows the system's setting or a preference; secondary and - accent text meet WCAG AA in both themes. - An idle window draws no frames: it redrew ~240 times a second - (the window title was re-sent every frame, and each viewport command asks for another), ~33% of - a core in a debug build, and now sits at 0 frames and ~0% CPU; the toast, the autosave - checkpoint and the presenter's "after delay" timers wake it at their deadline, the interaction - editor's curve preview plays when it shows, changes or is hovered, and a screenshot keeps frames - coming only until it arrives (`ui.inspect` `framesDrawn` and `repaintCauses` show what asked for - frames; headless tests hold it). *Left:* a real-world file corpus, Windows/Linux/browser - QA, runs with real screen readers (NVDA, Narrator, VoiceOver, Orca), the AccessKit tree in the - browser (eframe's web backend doesn't send it yet). Landed since: F2 rename from the keyboard, - Shift+F10 context menus at the focused layer row or the canvas selection, keyboard box selection - (F8). 27–44 h. - -## Inventory: what exists today - -Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count there was stale: 963 `#[test]` functions as of this re-measure. - -- **Architecture:** 15 crates with enforced layering (`cargo xtask layers`): geom, color, doc, - layout, text, pathops, plugin, collab, render, format, svg, engine, ui-egui (+ mcp, pdf). Every action - is a command (449 command ids); undo is unlimited via structural sharing (`Arc` node snapshots); gestures are - one undo step; `command.batch` runs several commands as one step. -- **Document model:** pages, frames, groups, sections, rectangles, ellipses (arcs/rings), polygons, - stars, lines/arrows, vector networks, text with styled runs, boolean groups, components, component - sets (variants), instances with per-layer overrides, slices; fills (solid, linear/radial/angular/ - diamond gradients, images with fill/fit/crop/tile + adjustments), multiple strokes with - inside/center/outside alignment, per-side weights, dashes, caps/arrowheads, joins; effects (drop - and inner shadow with spread that offsets the outline exactly: ellipses, stars, vectors, text; - inner shadows over the fills and under the children and strokes; drop shadows hidden behind what - the layer covers, translucent fills included, unless shown behind transparent areas; layer blur; background blur - that sees the whole backdrop, inside clipping frames, faded groups and masks too; both blurs - uniform or progressive (the radius ramps between two points, dragged on the canvas); noise in - mono, duo and multi colour with grain size, density and blend mode; texture with clip to shape; - glass with light angle and intensity, refraction, depth, dispersion, frost and splay; the same in - PNG, PDF and SVG exports, SVG embedding renders of noise grains, texture, progressive and - background blur and glass); corner radii with - smoothing; constraints; auto layout (horizontal, vertical, wrap, grid; hug/fill/fixed; min/max; - absolute children); layout grids; export settings; prototype interactions, flows and transitions; - paint/text/effect/grid styles; variables with collections, modes and aliases. -- **Renderer:** CPU (tiny-skia) scene renderer used for canvas, export and the web: isolated layers - for opacity/blend/effects, clip content, masks, booleans (linesweeper), text (harfrust shaping, - skrifa outlines, font fallback), aligned strokes with caching. -- **Engine:** pointer tools (move/marquee/deep select/resize/rotate/Alt-duplicate with smart-guide - snapping, frame/section/slice/shapes/line/arrow with Shift/Alt, text, pen, pencil, hand, - eyedropper), Cmd/Ctrl-click and Cmd/Ctrl-marquee into nested layers, Shift-marquee toggling, - Select layer (every layer under the click), measuring (Alt-hover distances from the selection to - layers, parents and ruler guides, Cmd+Alt for nested layers, from a guide being dragged to the layers of its frame, extension lines, one box inside the - other, overlaps, live distances to the neighbours while Alt-dragging, Shift+Alt for auto layout - padding and gaps; labels that never overlap at any zoom), text editing (caret, selection, words, - lines), auto layout reorder by dragging, drop into frames, align/distribute/tidy, z-order, - flip/rotate, the scale tool (panel, and canvas handles that scale everything about its anchor - point or from the centre with Alt, with a live factor, one undo step per drag), booleans, flatten, - outline stroke, components (create, instances, sync with overrides, reset, detach, push changes, - variants, properties, slots), styles and variables, prototype commands, find/replace, batch rename, - copy/paste (internal + SVG/text), file new/open/save/import/export (native `.figcraft`, SVG - in/out, PNG/JPG/WebP), version snapshots. -- **UI (egui, swappable):** the incumbent's 2026 layout: tab strip that is also the window's title - bar (frameless on Windows/Linux with drawn window buttons, drag, double-click maximise and edge - resizing; macOS window buttons inset into it), icon rail, file header, pages and - layers tree (rename, visibility/lock, drag to reorder/reparent), Assets panel, floating bottom - toolbar with flyouts and mode switcher, Design / Prototype / Inspect panels (Dev Mode: properties with their styles and variables, annotations - with pinned properties and categories drawn as coloured callouts on the canvas (filtered by category), - a ready-for-dev list, a focus view on one frame with previous / next, Compare changes since a frame - was marked ready (before / after side by side or overlaid, property-level diffs), CSS/HTML/React/SwiftUI/Compose - code where components take their properties as parameters and instances are calls, components - connected to their code components showing the real snippet, read-only plugins), colour picker - (HSB square, hue/alpha, hex, gradient stops, image fills), main menu generated from the catalog, - context menu, Actions palette (⌘K), keyboard shortcuts from one keymap per platform (a tabbed, - searchable Keyboard shortcuts panel), keyboard navigation (F6 between regions, Tab within them, a - focus ring) and screen-reader names for the custom controls (AccessKit), presentation player with - transitions, light and dark themes, home screen, About/Preferences/shortcuts/variables dialogs. -- **Automation:** desktop control channel (`--control `: execute commands, inspect document/UI, - synthetic pointer/keys/typing, menu invocation, screenshots, renders). -- **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions. - -## Revision history - -| Date | Change | Summary | -|---|---|---| -| 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | -| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | -| 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | -| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | -| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | -| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | -| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | -| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. | -| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. | -| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | -| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | -| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | -| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials: discoverability ×0.92 → ×0.94 (templates and first-run tips exist; no Community files, no tour, no user evidence), 77.8% × 0.92 × 0.94 × 0.88 = 59.2%, ~57% → ~59%; essentials hours ~60–110 h → ~55–100 h (the onboarding and templates item is done); menu breadth stays 211/211 (the two new menu items are FigCraft's own and not counted); every other number unchanged | -| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains | -| 2026-10-12 | minor | Text metrics, first measured fidelity data (gap 3): our example documents, exported and re-saved by other software, were read back and every text layer's stored box, line heights, baselines and glyph positions compared with ours. Three rules explained every row and were applied: a line's height is rounded to whole pixels (halves up; Auto, % and px alike; the baseline keeps its fraction inside the rounded line); glyph advances and kerning are each rounded to 1/1024 em before scaling by the font size; letter spacing is not added after a line's last visible glyph. Before: 24 of 131 text boxes of the design-system example matched (118 in width, 24 in height); after: 131 of 131, and all 230 self-sizing text boxes across the 7 documents (Inter only, 11–120 px, letter spacing −6% to 8%, one wrapped paragraph). Glyph positions within 0.001 px: 223 of 1,832 → 1,781; the rest are kerning pairs at weights where the installed font files differ slightly, at most 0.05 px. Frames and other layers matching in size and position: 115 of 231 → 208; the other 23 hang on one grid frame whose row positions came back as written. Not measured: other font families, fractional px line heights, centred or right-aligned text with letter spacing, lists, mixed sizes on a line. Wording only; no number moves | -| 2026-10-12 | minor | `.fig` export (gap 1): written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Wording only; no number moves | -| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | -| 2026-10-12 | minor | Experimental `.fig` export (gap 1): the whole document written from the same public format notes as the import, verified only by round trips through our own importer. Import/export breadth 84% → 86%, ready 57% → 58%; file formats 59% → 60%; file exchange with Figma users stays ×0.85 (nothing shows another app opens the files); mainstream hours ~130–244 h → ~120–229 h (`.fig` export 15–30 h → checking it elsewhere 5–15 h); feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta and full parity hours unchanged at this rounding | -| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding | -| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h | -| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h | -| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h | -| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h | -| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35% (drafts, not reviewed: graded low), 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1 by the dimension table); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h (Japanese and Korean leave the beta list; their native reviews need people) | -| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h | -| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%; 52.6 → 52.9); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h (Figma's languages left on the way to beta: Japanese and Korean, 7–12 h) | -| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page, latecomers included, until they move their own. Both are ephemeral presence the host validates and rate-limits, passed only to clients that read them. Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; the Collaboration dimension's hours 55–110 → 52–104 h (the dimension hours still add up to the total); feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h | -| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes: several layers dropped together in the layers panel land together (layers from apart split around the drop point); Collapse layers keeps the selection's rows open (it closed everything); the eye and padlock stay under the pointer (they vanished as it reached them, so clicks could miss) and dragging across them sets several layers in one undo step; several layers selected open a Rename layers dialog (match as text or a regular expression, Rename to, current name and number buttons, a start value, preview) instead of renaming the top one; `object.batchRename` counts `$n` up and `$N` down (they were swapped), pads with `$nnn`, takes `start`, regular expressions with `$1`…`$9`, `` $` `` / `$'` and codes in the replacement; pages are dragged to reorder, a blank page named with a hyphen is a divider (never opened, holds no layers), duplicates are named "Copy of …", the last page that isn't a divider can't be deleted; the Assets panel files components by page, frame and slash names in folders that fold away (`component.list` gives each `folder`); a leading `.` or `_` now keeps a component out of the library even after it was published, and removing it brings the component back. Pinned where the documentation is silent: Alt-click on an arrow, numbering in stacking order, hidden layers and locked frames taking drops, locking deselects. Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | -| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | -| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | -| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | -| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | -| 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) | -| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h | -| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, find, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h; mainstream weekly areas 71.0% → 71.1%; percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | -| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h; feature breadth (~80%), ready for real work (~49%), core editor (~68%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~320–615 h → ~319–614 h | -| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53%; Dev Mode 90%/64% → 93%/70%; file formats 55% → 56%; mainstream weekly areas 68.1% → 68.4%; feature breadth (~80%), ready for real work (~49%), essentials (~57%), mainstream (~46%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | -| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation (10 shape tests, 6 vector tests and 3 handle tests: about 70 table rows plus stepwise checks) and its fixes: rounded vector points are drawn and exported (the radius was stored only; a vector's corner radius now rounds every point); a boolean group looks like its top layer, a subtraction like the bottom one (always the bottom); Alt draws a line from its centre; stars keep to 3–60 points; delete and heal fits the curve through the deleted point; Shift-clicking a selected point at a corner of the points' box takes it out (it scaled); outline stroke turns the stroke's colour style into the fill's and keeps opacity and effects; the miter field shows the miter angle (it showed the ratio under that name); individual corners only for rectangles and frames; Option/Alt+Shift+F flattens; edit-mode keys reach the edit tools through MCP `key` (Delete removed the layer). Added: corner radius and star ratio handles on the canvas, Count and Ratio fields for polygons and stars, cut tool drags that divide a shape into layers, paint bucket drags across areas. Shapes & vector 97%/68% → 98%/72%, 1–2 h → 2–4 h (the suite found gaps the estimate didn't count: corner smoothing beyond rectangles, a join or cap per point). Totals recomputed by the doc's method, which earlier rows had left stale: the area table's weighted figures are breadth ~82% (core ~93%) and ready ~61% (core ~69%), not the ~80%/~58% (~91%/~68%) carried since #130; Features (depth) follows the weighted ready, 57% → 61%; ready for real work ~49% → ~51%; mainstream ~46% → ~48% (weekly areas 67.8% → 69.4%); the selection suite's area 2 hours (−3/−5) hadn't reached the totals, and neither it nor the text suite had reached the Features (depth) row: Features 60–115 h → 56–109 h, full parity ~321–618 h → ~319–615 h (−3/−5 for area 2, +1/+2 for area 4) | -| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation (14 paint and 9 effects tests with table rows for fills, opacity, every blend mode by its formula, pass-through and Normal containers, gradient handles and stops, image scale modes and adjustments, colour models, the colour profile, shadows, blurs, effect order and exports; 3 command tests) and its fixes: groups and frames set to Normal blend their children among themselves first, and clipping frames no longer isolate their children (blend modes inside a clipping frame met only the frame's children, not its fill or what is behind it) — canvas, SVG (`isolation`; clips put on what blends, since a clipped group isolates) and PDF (isolated groups); plus darker is drawn (it was multiply) on the canvas and in PNG exports; drop shadows hide behind what the layer covers, translucent fills included, unless shown behind transparent areas, in SVG too (it never cut them out) and the SVG filter region reaches as far as the effects; Fill images cover the box to its edges (upscaled ones faded out at the edge); "Normal" names Normal, not pass-through, in commands and the blend menu (the menu's Normal set pass-through). Added: a blend menu for each paint in the colour picker. Paint 66% → 70%, 4–7 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h | -| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h | -| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h | -| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h | -| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) | -| 2026-10-11 | minor | Embedded image colour profiles: placed and opened images are converted once, when decoded, from their own colour space into the file's (sRGB or Display P3): ICC profiles in PNG (`iCCP`), JPG (APP2), WebP and TIFF, matrix/curve and table profiles in RGB or grey (moxcms, already built for the `image` crate), and a PNG's `sRGB`, `gAMA` and `cHRM` chunks; images without a profile, or with one that can't be used (CMYK, damaged, over 4 MB), are taken as sRGB as before. The original file is kept (PDF exports still embed it); pixel exports carry the converted pixels with the file's profile. The image fill's details show the source profile (Unknown — treated as sRGB when there's none) and `image.place` reports it as `imageProfile`. Paint breadth 87% → 90%, ready 66% → 67%, 4–7 h → 3–6 h; feature breadth (~80%), ready for real work (~49%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h | -| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | -| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | -| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Import/export breadth 65% → 80%, ready 35% → 50%, 30–60 h → 8–15 h; file formats 40% → 55%, 30–60 h → 8–15 h; feature breadth ~79% → ~80% (core ~90% → ~91%), areas ready ~57% → ~58% (core ~67% → ~68%); ready for real work ~47% → ~49%; essentials ~53% → ~57% (opening files people send ×0.80 → ×0.88); mainstream ~43% → ~46% (weekly areas 66.8% → 67.8%, file exchange ×0.80 → ×0.85); beta ~195–355 h → ~173–310 h; full parity ~345–666 h → ~322–620 h | -| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | -| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | -| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | -| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | -| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | -| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | -| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~358–687 h | -| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | -| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | -| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | -| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | -| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | -| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | -| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | -| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | -| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for full (~400–750 h), mainstream (~180–330 h) and essentials (~85–160 h). Full number confirmed as an additive weighted sum; unchanged | -| 2026-10-10 | minor | Added the essentials user number (~53%: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send) | -| 2026-10-10 | minor | Added the mainstream practitioner number (~43%: weekly areas 66.5% × 0.9 interaction × 0.9 stability × 0.8 file exchange) and user-evidence count (none). Checked that ready for real work (~46%) is built from the written dimension weights and scores; unchanged | -| 2026-10-10 | minor | Stage checked against the alpha gate: stays alpha | -| 2026-10-10 | major | Created from the ROADMAP's parity rubric, honest assessment and area narrative. Full re-measure against Figma desktop 126.10.7: added multiplayer, plugins, team libraries, Figma Draw extras and AI as weighted areas; split breadth from ready-for-real-work; core depth re-graded ~79% → ~67%; hours calibrated from git history; full parity 115–195 h → 420–790 h | +# FigCraft parity with Figma + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Outline stroke preserves stroke holes; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) + +The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) +summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is +[`parity-checklist.md`](parity-checklist.md). Deeper checklists: +[`ui-parity.md`](ui-parity.md), [`file-format-parity.md`](file-format-parity.md), +[`hardware-parity.md`](hardware-parity.md), [`localization-parity.md`](localization-parity.md), +[`auto-layout-parity.md`](auto-layout-parity.md), [`components-parity.md`](components-parity.md), +[`prototyping-parity.md`](prototyping-parity.md), [`dev-mode-parity.md`](dev-mode-parity.md), +[`collaboration-parity.md`](collaboration-parity.md). + +## Headline + +| Number | Value | Kind | +|---|---|---| +| Menu/command breadth | **100%** (211 / 211 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-12) | +| Feature breadth (does each Figma Design feature exist, weighted) | **~85%** (core editor areas 1–16: ~94%) | estimated from the area table below | +| Ready for real work | **~53%** | estimated (dimension table below: written weights × written per-dimension scores) | +| Essentials user | **~59%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.94 × 0.88) | +| Mainstream practitioner | **~50%** | estimated ([below](#mainstream-practitioner): weekly areas 72.7% × discounts 0.9 × 0.9 × 0.85) | +| Remaining to beta | **~155–280 h** one Opus 5.5 agent (~32–64 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated | +| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | + +The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were +self-graded against a 16-area rubric that left out, by design, multiplayer, plugins, cloud +libraries and AI features, and that counted no file-format blocker. Figma users rely on all of +those every day, so this re-measure weights them in. The core-editor depth figure drops from ~79% +to ~67% because nothing has yet been checked against real Figma files or side by side tool by +tool; presence was being graded as behaviour in places. + +## Readiness by audience + +| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | +|---|---|---|---| +| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the experimental `.fig` export opens elsewhere (5–15) | +| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | + +Hours are one agent working sequentially, calibrated from this repo's history (about 1 agent-hour +per 1,000 added lines, 1–2.5 h per feature PR, ~7 h per honest depth point in the polish tail; see +[how the hours are calibrated](#how-the-hours-are-calibrated)). Each audience's work is a subset of +the next. About 70–80% parallelizes across 4–6 agents on disjoint crates (roughly ÷4 in +wall-clock); the `.fig` reader, the multiplayer core and a plugin runtime each need one owner, and +native-speaker review, real-file licensing and screen-reader QA need people. + +The full number is an additive weighted sum over the dimension table; mainstream and essentials use +multiplicative discounts, as the standard says. + +## What was measured, and how + +- **Target and version.** Figma desktop for macOS, `CFBundleShortVersionString` 126.10.7, + installed at `/Applications/Figma.app`. FigCraft is measured as of `origin/main` d452da3 + (2026-10-10, PR #99). +- **The installed app, names and public listings only.** FigCraft is clean-room + ([`AGENTS.md`](../AGENTS.md)): nothing inside the bundle's code or web assets was read. From + `Info.plist`: one document type, `com.figma.document`, extensions `fig`, `jam`, `deck` + (MIME `application/x-figma`), and the `figma://` URL scheme. The `.lproj` folders are + Electron/Chromium locale packs (60+), not Figma's UI languages; the UI languages come from + Figma's help center (see [`localization-parity.md`](localization-parity.md)). The bundle ships + GPU, Plugin and Renderer helper processes (GPU rendering; a sandboxed plugin runtime). +- **The menu catalog.** `crates/engine/src/catalog.rs` holds Figma's main menu tree (202 items in + File, Edit, View, Object, Text, Arrange, Vector, Plugins, Preferences, Libraries, Help), written + from public documentation and black-box observation (local notes in the gitignored + `plan/figma/03-menu-tree.md`). It leaves out the Widgets menu and dynamic plugin lists, so the + 100% overstates breadth against the full menu a little. +- **From source** (no build; 2026-10-10): 449 command ids registered with `spec!`, 963 `#[test]` + functions in 177 files, ~108,800 lines of Rust in 13 crates + 3 apps + xtask (before #101–#103, which added the `plugin` and `collab` crates and ~7k lines), 140 key bindings + and 13 gesture rows in `figcraft_engine::keymap`, 21 files in the SVG import corpus, 0 + translated UI strings (no localization layer exists). +- **Figma's feature set**: help center, release notes and Config 2025 announcements (Figma Draw, + grid auto layout, slots, glass/noise/texture effects, progressive blur), known to the assessor up + to mid-2026, plus web checks on 2026-10-10 (supported image/video types, UI languages, pen + pressure in Figma Draw). +- **Side by side.** Look & feel was compared with Figma UI3 on the owner's Mac (screenshots kept + local, never committed). No real Figma file has been opened in FigCraft (no `.fig` reader), and + no rendered frame has been compared pixel for pixel against Figma's own export. + +## Weights + +Weights are the share of a typical product designer's working time in Figma Design, written down +so they can be argued with. Areas 1–16 keep the original rubric's weights (they sum to 94, not the 100 the rubric claimed); +areas 17–21 are added (sum 19) because Figma users depend on them. Percentages below divide by the +total, 113. +Out of scope as separate products: FigJam, Figma Slides, Sites, Make, Buzz, Weave (the `.jam` and +`.deck` document kinds belong to them). + +## By feature area + +Breadth: does it exist. Ready: depth, correctness and fidelity for real projects. Hours: one Opus +5.5 agent, sequential, tests included. + +| # | Area | Weight | Breadth | Ready | Remaining | Hours | +|---|---|---|---|---|---|---| +| 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | +| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | +| 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | +| 4 | Shapes & vector | 8 | 99% | 73% | Outline-stroke compositing; corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (outline stroke rings covered by `outline_stroke`; behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | +| 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | +| 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | +| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | +| 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | +| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | +| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | +| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | +| 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | +| 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | +| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | +| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | +| 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | +| 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | +| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | +| 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | +| 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | +| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | +| | **Weighted (÷113)** | 113 | **~85%** | **~63%** | | **117–255** | +| | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 32–74 | + +## By dimension (ready for real work) + +| Dimension | Weight | Ready | Hours | Evidence | Doc | +|---|---|---|---|---|---| +| Features (depth) | 35 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file | +| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | +| File formats | 12 | 60% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | +| Collaboration | 10 | 13% | 52–104 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | +| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | +| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | +| Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | +| Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese (none reviewed by a native speaker; every draft covers all current strings), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | +| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | +| Ecosystem/plugins | 3 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | +| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | +| **Ready for real work** | 100 | **~53%** | **~262–519** | | | + +Dimension hours add up to the full-parity total without double counting: the feature-area rows +for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are +counted under their dimensions. + +## Essentials user + +Someone who uses only the core of Figma: a casual or beginner user making a few screens, a social +graphic or a simple mockup. Same method, with the core features a casual user touches. + +**1. Core features, weights and depth** (depth from the area table and checklists above): + +| Feature | Weight | Depth | Notes | +|---|---|---|---| +| Create, open, save and reopen a file; pages | 10 | 85% | home screen, `.figcraft` round trips, Open recent | +| Pan and zoom | 10 | 80% | wheel, pinch, Space-drag, zoom shortcuts | +| Select, move, resize, rotate, align, group, duplicate | 15 | 75% | not yet compared tool by tool | +| Frames with device presets | 8 | 80% | | +| Rectangle, ellipse, line, arrow, corner radius | 8 | 80% | | +| Text: type, font, size, weight, colour, alignment | 12 | 70% | no spell check | +| Colour picker, fills and strokes; place and crop images | 10 | 72% | no HEIC images | +| Layers panel: rename, hide, lock, reorder | 7 | 80% | | +| Basic auto layout (add, gap, padding) | 6 | 70% | | +| Undo / redo | 5 | 90% | unlimited, one step per gesture | +| Export PNG, JPG, SVG, PDF | 6 | 85% | | +| Copy and paste, incl. images from other apps | 3 | 75% | | +| **Weighted** | 100 | **77.8%** | | + +Left out: components and variables beyond using one, prototyping, Dev Mode, advanced vector and +type, and everything the mainstream number leaves out. + +**2. Discounts a casual user actually hits:** + +| Discount | Factor | Evidence | +|---|---|---| +| Launch and stability | ×0.92 | signed and notarized macOS build, installers for every platform (v0.2.0), never-crash rules and recovery copies; no field record yet | +| Discoverability and UI clarity | ×0.94 | the UI3 layout Figma users know, Actions palette, keyboard shortcuts panel; a first run opens on the home screen with a Getting started card (six tips with their shortcuts, closable, back from the Help menu) and eight starter templates to open (also File ▸ New from template…); no Community files, no guided tour, and nobody new to the app has been watched using it | +| Opening files people send them | ×0.88 | what people send a Figma user is a Figma link or a `.fig`; a `.fig` opens (best effort, not yet checked on real files), a link doesn't. Images and SVG do | + +77.8% × 0.92 × 0.94 × 0.88 = **~59%** (estimated). User evidence: none yet (see above). + +## Mainstream practitioner + +The same question for the typical professional Figma Design user: a product designer who designs +screens, maintains or uses a design system, prototypes and hands off to developers every week. +Computed per craftrules `standards/progress-docs.md`. + +**1. Areas used weekly, with weights.** Feature areas 1–16 of the table above, with their weights +(sum 94). Left out as the standard says: plugins and widgets (18), AI (21), team and organization +libraries (19, cloud/team admin), Figma Draw extras (20, not weekly for a product designer), +localization beyond English, specialist hardware. Real-time multiplayer (17) is cloud collaboration +and is left out as an area, but its effect on working with Figma users is counted in discount 3. + +**2. Average depth** ("Ready" column, weighted): **72.7%**. + +**3. Discounts for what still stops real work:** + +| Discount | Factor | Evidence | +|---|---|---| +| Interaction fidelity | ×0.90 | UI3 look compared side by side (~65% by the builders); no tool-by-tool comparison of cursors, modifier keys while dragging, snapping thresholds or popovers yet; Ctrl to suspend snapping on macOS missing ([ui-parity](ui-parity.md)) | +| Stability on real machines | ×0.90 | Never-crash lints, command guard with rollback, recovery copies and 963 tests, but no field record at all: the repository is private, v0.2.0 has had no outside users or issue reports, CI tests run on Linux only and hands-on QA is on macOS. Harsher than VectorCraft's ×0.92 because there is no outside evidence either way | +| File exchange with Figma users | ×0.85 | A Figma designer's work lives in shared Figma files. FigCraft opens a `.fig` (best effort, not yet checked on real files) and writes an experimental one (written files open and draw in other software; effects, images and prototypes unchecked there); incoming work can come through the REST API JSON (one way, needs an access token), outgoing only as SVG, PDF, PNG or code. Teammates, reviewers and developers would all need FigCraft ([file-format-parity](file-format-parity.md), [collaboration-parity](collaboration-parity.md)) | + +72.7% × 0.90 × 0.90 × 0.85 = **~50%** (estimated; 50.03%, rounded down). + +**4. User evidence (2026-10-10):** 0 issues, 0 praise or "switched from Figma" reports, 0 comments +from anyone but the maintainers (`gh issue list`, `gh api .../issues/comments`; the repository is +private, 1 star, PRs only from maintainers). No outside evidence either way; nothing raises the +number. + +**Why it is lower than ready for real work (~53%), unusually.** The full number averages +dimensions additively, so the missing `.fig` exchange costs only the file-format dimension's 12% +weight, and FigCraft earns points the mainstream number doesn't count (platforms 75%: Linux, +FreeBSD and the web, where Figma has no desktop app). The mainstream number applies file exchange +as a multiplier on everything, because a Figma practitioner's whole working week is shared through +Figma files. Both are computed from written weights; neither was moved to match the other. The +mainstream number rises fastest with `.fig` import and the experimental `.fig` export checked on real files (the ×0.85 toward ×0.95: ~53%) and the +interaction pass. + +## How the hours are calibrated + +From this repository's own history (`git log`, `gh pr list`): + +- The whole app was built in ~25 wall-clock hours: first commit 2026-10-09 15:38 UTC, PR #99 + merged 2026-10-10 16:53 UTC; ~49k lines on day 1 before PRs, then 99 PRs adding ~81k lines. The + ROADMAP of that period records 4–6 agents in parallel, so ~110–150 agent-hours in all: about + **1 agent-hour per ~1,000 added lines**, and a typical feature PR (1,000–2,500 lines, e.g. + #84 arc handles, #92 PDF print options, #96 accessibility) took **1–2.5 agent-hours**. +- Between the ROADMAP revisions of 2026-10-09 22:19 UTC and 2026-10-10 16:53 UTC (~18.5 h wall, + 78 PRs, ~75–110 agent-hours) the self-graded depth went 58% → 79%: **~4–5 agent-hours per + self-graded point**. This re-measure grades about 0.6 points for each of those, which puts an + honest point nearer 7 agent-hours in the polish tail; new subsystems (a `.fig` reader, + multiplayer, a plugin runtime) are estimated bottom-up instead, since nothing comparable has been + built here yet. +- Parallelism: most of the work splits across 4–6 agents on disjoint crates (the history shows it + working); multiplayer, `.fig` import and the plugin runtime each need one owner for their core. + Needs a human: native-speaker review for every language, the plugin-runtime and AI-model + decisions, real-world Figma files to test with (licensing), Windows/Linux/screen-reader QA. + +## Area detail: what has landed and what is left + +Carried over from the ROADMAP's "Where we're lacking" list (2026-10-10, before this re-measure). +Hours in this narrative are the builders' estimates; the tables above supersede them. + +1. **Vector edit mode**: *landed:* edit-mode toolbar (move, pen, bend, paint bucket, lasso, cut), + pen that extends, branches and closes paths (vector networks), paint bucket fill/unfill of any + closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and + length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and + merge, delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder + (⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept), + variable width strokes (width profile presets with previews in the stroke settings, the Variable + width tool ⇧W: drag the path to add a width point, slide points, drag their handles, Delete; + smooth interpolation per subpath, dashes, caps and joins, canvas/PNG/SVG/PDF and outline stroke), + a transform box around several selected points (corners and edges scale them, Shift keeps + proportions, Alt from the centre; just outside a corner rotates, Shift in 15° steps; drag inside + to move; the Design panel's X/Y/W/H and flip/rotate act on the points), point snapping while + dragging points or handles and with both pens (to the points and segment midpoints of this and + other vectors, the usual layer targets and the pixel grid, with guides and a marker on the point + landed on; Ctrl/Cmd turns it off), arc handles on the canvas (sweep, start and ratio with a + live readout, Shift snapping, closing at the start, the other part from the gap; rotated and + flipped ellipses, any zoom), Design panel Arc fields (Start, Sweep, Ratio; bindable), live + boolean editing (operands selected by double-click or Cmd/Ctrl-click; moving, resizing or + editing an operand's points redraws the result during the drag with every operand outlined; + per-operand operations from the boolean controls; only the operands from the edited one up are + recombined; clicks hit a boolean group where its result is), the shape builder on strokes and open paths (strokes as their outlined areas), and + one-sided variable widths (Alt-drag a width handle; canvas, exports and outline stroke), and + filled regions with the Move tool (the area under the pointer hatched, clear of points and + segments, its spacing by octave folding fitted to measurements at several zoom levels and fixed + to the shape; click, Shift-click and drag to select and move areas with their points). + *Left:* vector QA with real files. 1–2 h. +2. **Text editing depth**: *landed:* OpenType features panel, small caps, lists, title case, + truncation/max lines, font picker with previews and recents, word/line selection, IME commit, + inline IME composition (underlined at the caret, reflowing, candidate window at the caret; the + document only gets the committed text), variable fonts (axes and named instances read from the + font, named instances as styles, Type settings ▸ Variable sliders, per-range axis values applied + to shaping, outlines and PDF), range-aware Typography panel with "Mixed". Range fills in the Fill + section. Right-to-left and mixed-direction text (Unicode bidi: Arabic shaping, mirrored brackets, + caret, clicks and selection follow the runs; arrow keys move visually). Right-to-left and + mixed-direction names in the panels (layers, pages, Assets, tabs, the file header, canvas frame + titles: visual order, joined Arabic, the ellipsis at a right-to-left name's left end; rename fields + whose caret, clicks, drags and arrow keys follow the shown text; system fonts for scripts the UI + fonts lack). The text style browser in the Typography section (list and grid views in each + style's 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, "Mixed" when the + chars use several styles); text styles link chars, not just layers (editing what a style sets + unlinks the chars edited). Live previews of a hovered named instance or font style and of a + dragged axis slider (one undo step on release, Escape cancels). Visual QA: list markers keep the + paragraph's font, renames start with the name selected and centred, Max lines shows while + truncating. Right-to-left paragraphs mirror lists and indents: bullets and numbers (".1") on + the right, wrapped lines hanging from the right, the paragraph indent on the right, trailing + spaces hanging outside the line (canvas, PNG, PDF and SVG). A behaviour suite from the public + documentation (`crates/text/src/behaviour_tests.rs`, `crates/engine/src/text_behaviour_tests.rs`): + Auto / % / px line heights with the leading split above and below every line, letter spacing in + % and px, paragraph spacing and indent (start-aligned text only), resizing and truncation, + decorations from the font's metrics; CJK line breaking (between ideographs, kana and Hangul, no + break inside Latin words, kinsoku for closing and opening punctuation, small kana and the + prolonged sound mark); a style's metrics from its own font, not a fallback; carets, Delete and + Backspace by grapheme cluster; double-click and word moves by script run; IME composition with + CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`, + `crates/engine/src/text_tail_tests.rs`): lists per paragraph, nested up to 8 levels with Tab and + Shift+Tab (1. a. i. and • ◦ ▪ per level, counts restarting after plain paragraphs), Enter on an + empty item and Backspace at an item's start stepping out, `- `/`* `/`1. ` starting a list; list + spacing between items; hanging lists and hanging punctuation; decoration style (solid, dotted, + wavy), thickness, underline offset, skip ink and colour, with inspect, CSS and the REST import. + *Left:* visual QA; hanging punctuation in right-to-left paragraphs and centred text, list + numbering that continues across a plain paragraph and a list's own start number (documentation + silent); decoration colour in SVG exports (drawn with the text's fill, like run fills); Korean + keeps breaking by syllable and Han runs aren't split by a dictionary (documentation silent). 0.5 h. +3. **Auto layout and sections depth**: *landed:* on-canvas padding/gap handles (also wrapped-row + and grid column/row gaps, grid tracks outlined), min/max width/height (UI, Hug/Fill/Fixed, + hugging text wraps at its max), ignore auto layout (absolute position) with constraints, text + baseline alignment, grid tracks (fixed/hug/`fr`), grid cells and spans, wrap row gap UI, + wrap-aware drag-reorder (by row) and dragging into a grid cell, nested hug/fill settling in one + pass (wrapping fill text, three levels deep, grid rows), min/max redistribution, coherent + Fill/Hug rules, drag across nested auto layout frames (deepest one wins, insertion line, out to + the canvas or a plain frame). Arrow keys move auto layout children one place (along the flow + past hidden and absolute siblings, Up/Down between wrapped rows, to the neighbouring grid cell + (automatically placed children swap, anchored ones take or swap cells), and at the end of a + flow or across a row's axis into the neighbouring nested auto layout frame when the frame + around flows that way; one undo step, none when nothing can move). Visual QA of nested + hug/fill/fixed/min/max/wrap/grid samples against the documented rules (Fill turns a hugging + parent Fixed, min/max redistribution, wrap inside fill, absolute children following + constraints as hug frames grow). Sections: hide contents (name, outline and "Contents hidden" + on the canvas; contents skipped by rendering, hit testing and labels; dimmed in the layers + panel; Design panel checkbox and context menu), Wrap in new section, frames in sections get + canvas labels and are top-level frames (flows start there, navigation, Export frames to PDF), + "Navigate to" a section shows its frame shown last, else its first; ready for dev turns + "Changed" when the contents are edited, resolved with a reason; Compare changes shows what a + changed frame or section looked like when it was marked next to how it is now, with the + changed layers and their property changes. *Left:* whether `fr` rows of a fixed-height grid + should make room for fixed-size children (now `minmax(0, 1fr)`-like). 1–2 h. +4. **Components depth**: *landed:* component properties (edit/delete/bind) with the properties + panel, add variant, instance swap UI, per-layer overrides with reset, restore main, instance + structure protection; Assets panel with live thumbnails, search and drag-to-canvas (drops into + the frame or auto layout slot under the pointer); exposed nested instances (their properties + show on the main's instances); component descriptions (edited on the main, shown on + instances); local libraries (publish a file's components, sets and styles with per-asset + versions, enable library files, place library components and apply library styles as + read-only linked copies, "Library updates available" with overrides kept on update); slots + (a frame of a main made a slot property with preferred components; instances fill it with any + content of their own, kept across main updates, variant switches, copy/paste and detach, with + the main's default content until edited; reset slot; slot badges in the layers panel and on the + canvas; fill menu in the properties panel; drops from the Assets panel and the canvas land in + slots); nested overrides kept when the main moves or swaps a nested instance, and on nested + instances copied out of an instance (re-linked to their own main); canvas drags can no longer + pull layers out of an instance; library variables (publishing includes the file's variable + collections with their modes, aliases and scopes, versioned per variable and collection, without + variables hidden from publishing; files using the library see its collections read-only under + the library's name in the Variables editor and the variable pickers; binding, aliasing or setting + a library collection's mode on a frame copies the variables in as read-only linked copies, so the + file renders without the library; library components keep their variable bindings and modes; + "Library updates available" covers changed variables and bound layers follow). Explicit variable + modes in the Design panel: a frame's mode per collection, Auto (inherited) or its own, with + enabled libraries' collections listed under the library's name (picking a mode copies the + collection in). Instance boolean and text properties bound to boolean and string variables + (following modes, renames, reset and detach); layout grid settings in the Design panel whose + count, size, margin/offset and gutter bind to number variables; fills, strokes, effects and + layout grids reordered by dragging their rows, their variable bindings following. Components at + scale, QA'd with a synthetic design system (about 300 components: buttons, inputs, cards, navs + and badges as sets of size × state × type × theme variants, boolean/text/instance swap/slot + properties, instances nested four deep, text and paint styles, light/dark variables bound across + it) used 2,000 times with overrides at every level: main edits keep every override and match a + full rebuild exactly; values from variables (a theme switch, a bound corner radius) are no longer + recorded as overrides; detaching a variable on an instance layer is an override that survives main + edits; exposed nested instances stay exposed and swapped nested instances keep their layer names + through main edits; layers a swapped nested instance's main adds keep their ids and overrides. + Instance swap properties have preferred values (offered first). Alt-drop from the Assets panel + onto an instance swaps it (same-set instance preferred, highlighted while dragging); instances + pasted into other files follow their mains (library copies shared, published components used + from their library, other mains left behind as a restorable missing main). The Assets panel lays + out only the tiles in view and renders thumbnails as they show (cached, re-rendered only for + changed components). A behaviour suite from the public documentation checks overrides (what an + instance takes, what it refuses: geometry, constraints and resizing of its layers, its auto + layout direction), variant switches (overrides stay where both variants agreed; text layers keep + theirs), swaps (by layer name; from the Assets panel only text overrides), the top-left default + variant, detach, reset and push. *Left:* QA with real-world (not synthetic) libraries. 1 h. +5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor, + flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter. + Custom bezier and custom spring easings with editor fields; device frames (phone, tablet, + laptop, desktop bodies drawn in code) around the presented frame; overlays animate out with + their opening transition reversed; several selected layers edit their interactions together + (or replace mixed ones); scrolling inside device frames (the frame sized to the device's screen, + clipped or scrolling by wheel and drag, fixed layers pinned to the screen by their constraints, + nested "Scroll to", preserve scroll position); a resizable preview pane over the canvas that + plays the selected frame and follows edits live; device rotation (phones and tablets turn to + landscape: saved in the file, imported from REST files, drawn turned in the presenter and the + pane); presentation options (fit, fit width, fill, 100%, with Z to cycle them and R to + restart, the device frame shown or hidden, kept between presentations; what overflows the + window pans); scroll momentum (drags fling and slow down the same at any frame rate, the wheel + coasts, a drag past the ends stretches and springs back); responsive presentation (the presented + frame resized to the device's screen, or the window without a device, its constraints and auto + layout responding live; in the Options menu and the Prototype panel, saved in the file); device + colours (each model's colour variants, bodies drawn in code) and custom device sizes (a bare + screen with width and height fields, imported from REST files); flow descriptions in the + presenter's flow picker and under each flow; conditional actions (if / else if / else over + variable expressions) and set-variable-mode actions run in the presenter (from REST files or + commands). Conditionals are built in the interaction editor: an interaction's actions are a list + (add, reorder by dragging a row's grip, also inside conditional blocks, or from its menu, remove, + open one to edit it; one undo step per move), "Add action ▸ Conditional" adds an if whose + condition comes from a builder (a variable from the variable picker, an operator, a value or + another variable) or is typed as an expression (`count >= 3 and not done`, checked when entered, + what's wrong shown under the field), each block with its own actions (the same editors, + nested), plus "Else if" and "Else"; set variable values can be expressions (`count + 1`, + `"Hi " + name`); expressions read a variable's value in a given mode of its collection + (`accent[Dark]`, also from REST files' variable mode lookups) and the mode a collection is in + where the interaction runs (`mode(Theme) == "Dark"`, following set variable mode actions); every + edit is an undo step (`proto.action.*`, `proto.conditional.*`). Hotspot hints: a click where + nothing reacts flashes the screen's hotspots (an Options menu toggle, on by default); the + presenter's Inspect panel shows the hovered layer's properties. Overlays opened through commands + close on a click outside unless told not to, as in the editor. A behaviour suite written from + the public documentation covers triggers, actions, overlays, animations, easing, scrolling and + interactive components ([prototyping-parity](prototyping-parity.md)); its fixes: drag-driven + animations go their way and follow the pointer, key combinations, overlay timers from when they + open, instances play their variant's interactions, physical springs, smart animate overshoot, + fixed layers on top; scroll to takes an offset. *Left:* QA against larger real-world + prototypes. 1–2 h. +6. **Interop**: *landed:* image crop handles; REST API file JSON import (`file.open` / `file.import` + / CLI: pages, layer tree, geometry, paints, strokes, effects, text runs, vectors from geometry + paths, auto layout, constraints, grids, components, sets, instances with overrides, component + properties, styles; image fills from an `images` folder or placeholders; warnings for the rest), + now with prototyping (interactions with their triggers, actions, transitions and easing, flows, + scrolling, the prototype device; conditional actions (if / else if / else over expressions), + set variable with variable references and expressions, set variable mode, all run by the + presenter) and variables (the local variables JSON passed along, next to the file or embedded: + collections, modes, aliases, scopes, layer bindings and explicit modes). + Export panel: a row per export setting (typed or preset sizes like `2x` / `512w` / `512h`, PNG, + JPG, WebP, SVG and PDF, suffix, advanced options: contents only, include bounding box, and for + SVG outline text, id attributes and simplified strokes), a preview, and "Export N layers" (`file.exportLayers`) in the + panel and the Export dialog. Paste from other apps: images, SVG and text (the newest clipboard + content wins over our own copy; pasted SVG lands centred in the view). SVG import is editable and + checked against a corpus of synthetic files (expected pixels worked out by hand, layer structure, + export and re-import): groups named from their ids with transforms baked into the layers; + rectangles (corner radius), ellipses and lines kept as those shapes; gradients with exact + handles, `reflect`/`repeat` and focal points; patterns as image fills; clip paths and masks as + vector, luminance and alpha masks; shadow and blur filter chains as drop shadows, inner shadows + and layer blurs; blend modes; text as text layers with runs, anchors and stacked lines; images + and nested SVG images; import warnings for what's left out; linked files never read; hostile + input fuzzed. SVG export keeps alpha and luminance masks. PDF export: one page per frame in points (Export frames to + PDF, the Export panel, `figcraft-cli export -o x.pdf`), vectors, gradients and strokes as + vectors, real selectable text in subset fonts with clickable hyperlinks, images embedded once, + opacity and blend modes, clip content, alpha/luminance/vector masks, shadows and layer and + background blurs as images; checked by rendering the PDFs back against PNG exports. PDF print + options in the Export panel (per PDF export setting), the Export dialog, `file.export`, + `file.exportLayers`, `file.exportFramesPdf` and `figcraft-cli export`: a bleed showing what lies + past the frame (its fills reach out, its children are clipped at the bleed), crop and bleed + marks outside it, MediaBox / BleedBox / TrimBox, and CMYK colour (a documented, deterministic + naive conversion; images and rasterised effects too). + *Left:* .fig import (best effort), a CMYK output intent (no CMYK ICC profile can ship in the + repository), SVG filters other than shadows and blurs (left out with a warning). 8–16 h. +7. **Look & feel and interaction-fidelity pass**: *landed:* keyboard shortcuts audited against the + public shortcut reference: one keymap (`figcraft_engine::keymap`) drives the menus, the canvas, + the control channel and MCP, with macOS and Windows/Linux keys where they differ (mask, bring to + front/back, distribute, tidy up, layout grids, pixel preview), Text-only and canvas-only bindings + (Cmd/Ctrl+K: link while typing, Actions on the canvas), Cmd/Ctrl+/ for Actions, `+`/`-` zoom, + Shift+0/1/2 from any layout, Cmd/Ctrl+0, hold Z for the zoom tool (Alt zooms out, drag an area), + two quick digits for an exact opacity, boolean keys (Alt+Shift+U/S/I/E), remove fill/stroke + (Alt+/, Shift+/), outlines on Shift+Cmd+O, pixel grid on Shift+' (and Cmd+'), Shift+E / Shift+D and the other panel keys, Ctrl+Tab between files, Shift+B (and B) for the paint bucket in vector edit mode; the Keyboard + shortcuts panel docks at the bottom with a tab per group and search, keycaps per platform. + A shortcut parity suite (`shortcut_parity_tests.rs`) checks every documented shortcut on both + platforms (129 rows: 128 bound, including moving the rotation origin with Alt+R and placing an + object with Enter from the toolbar; 1 moved off a documented conflict) and pins the 48 undocumented + bindings; others' cursors (`Alt+Cmd+\`), list levels while editing (Cmd/Ctrl+] and [), the + keyboard box selection on Alt+Space / Ctrl+Space, arrows that pan with nothing selected, + Control to suspend snapping on macOS and Space/N/C/F while presenting landed with it + ([ui-parity](ui-parity.md#keyboard-shortcuts)). + *Left:* tool by tool, panel by panel against the incumbent (cursors, modifier keys while + dragging, hover states, menus, popovers); per-keyboard-layout shortcut tables. 40–60 h. +8. **Collaboration (local):** comments landed: pins pinned to frames and layers that move with them + (to other pages too) and stay where they were when the layer is deleted, your pins dragged onto + another frame, crowded pins clustered when zoomed out (a click zooms them apart), Shift+C to show + or hide them; threads where you edit and delete your own messages, emoji reactions, @mentions + with a picker of the names used in the file (your name from Preferences), read and unread state, + resolve, and "Copy link" (`figcraft://comment/`: pasting or `comment.open` goes to the page, + zooms to the frame and opens the thread); the Comments panel with search (text, names, frame), + filters (open, resolved, yours, unread), sorting by date or page, each thread's frame and page, + and Up/Down to step through threads; comments on the presented frame (Comments button or + Shift+C: read and reply). All of it through `comment.*` commands. Version history + landed (named versions saved in the `.figcraft` file; restore, rename, duplicate, delete; panel; + view a version on the canvas read-only before restoring it; autosave checkpoints every 30 + minutes, folded in the panel). A behaviour suite written from the public documentation covers + both: restoring keeps the comments and first keeps the replaced design as an autosave, a titled + autosave becomes a named version, merging a branch records the main file before and after, and + threads made in a live session can be replied to and resolved. Real-time collaboration is designed (`docs/collab-protocol.md`: + WebSocket sessions the app can host itself, server-ordered per-property changes, live cursors) + and its core landed (`crates/collab`, no networking yet): client-scoped node ids, every saved + layer field and the document-level data (styles, variables, comments, images…) as synced + properties, diffs after a command that cost the size of the edit, fractional sibling positions, + a session server that orders and validates batches (cycles, deleted layers, id spaces, sizes) + and a client that shows its own edits at once and replays them over others'; derived data + (auto layout, instances, bindings) is repaired by the author after concurrent edits; binary + messages (a moved layer is 43 bytes, a cursor 23) with a JSON form; a randomized multi-client + test over real commands converges. Live sessions run over the network (hosting from the app, + invite links, document sync, live cursors, selections, avatars, follow). Undo is per person in + a session (`edit.undo` takes back only your own steps, leaving what others changed since; + creations and deletions invert; a gesture or command group stays one step); refused edits roll + back with a toast; a dropped connection keeps editing and resumes as the same client with what + it missed (or a snapshot), then sends what was made offline. Randomized sessions with undo, + redo and dropped connections converge, and a checker confirms no undo reverts someone else's + change. + Plugins landed: sandboxed WebAssembly plugins (a metered wasmi + interpreter with a memory cap and no WASI, also in the browser) whose API is the command + registry; each run is one undo step and is rolled back when it fails (error, trap, endless loop, + memory); a `figcraft-plugin.json` manifest declares menu commands with parameters and read or + write access with an optional command allowlist, and files, other documents, the undo history, + dialogs and other plugins are always off limits. Plugins ▸ Development ▸ Import plugin from + manifest… (kept in the preferences, the module re-read on every run), the installed plugins in + the Plugins menu with a parameters form drawn from the manifest, Run last plugin (⌥⌘P), Manage + plugins, and `plugin.*` commands for scripts, the CLI and MCP; a Rust SDK and two example + plugins (Tidy names, Swatch grid) in `plugins/`, built by `cargo xtask plugins`. Then plugin + panels (ABI v2: fields and buttons drawn with FigCraft's widgets, the plugin called back for + each button or changed value as one undo step, `plugin.ui` / `plugin.event` / `plugin.close` + for scripts and MCP) and per-plugin settings kept with the preferences. Then widgets (API 3: + a plugin describes a layer of its own, validated and built as a frame with locked layers; clicks + on its named parts call the plugin back in a fresh sandbox with the state kept in the layer, one + undo step each; `widget.insert` / `widget.click` / `widget.list`; a Counter example), panels of + several plugins side by side and the colour picker on plugin colour fields. Then a plugin + directory on `figcraft-server`: bundles published with the server key and checked in full, + browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, downloads + checked against their SHA-256, updates by version. *Left:* a public catalogue, publisher + accounts and signatures; richer widget events; the directory in the browser. 8–26 h. +9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer + pages: engine, render and headless UI-frame timings). The canvas keeps its last image and + re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders + in ~0.2 ms instead of ~8 ms (0.8 vs 24 ms at 50k zoomed out), a 40 px pan in ~0.7 ms instead of + ~7.5 ms. The layers panel lays out only the rows in view (every screen expanded at 50k: 39 → + 0.6 ms a frame); text layouts are shared instead of re-formatted per lookup; overflowing text is + culled by its glyphs. At 10k: full 1440×900 renders 7–15 ms, single-layer edits ~10 µs, hit + tests ~26 µs. Zoomed out (50% and below) the canvas draws levels of detail: layers under 2 px + land as their averaged colour, text under 4 px is greeked into bars, small rectangles and clip + layers skip the path pipeline (exports and closer zooms stay exact): a whole 50k-layer page + renders in ~8 ms instead of ~50 ms. `.figcraft` at 50k layers saves in ~0.09 s instead of ~1 s + and loads in ~0.16 s instead of ~0.86 s (JSON streamed to and from the document without a + value tree, layer fields at their defaults left out: 63 → 22 MB of JSON, old files still + open). The retained canvas is held to a full render: a seeded random walk (edits, undo/redo, + pans and zooms over nested, rotated and clipping frames, groups, masks, booleans, auto layout, + effects, text, instances, variables and styles) checks every step, and found clip edges on a + pixel boundary letting a column through in some renders. Drags show live on the canvas (every + finished render is shown, not only the newest one asked for), and a button held still no + longer rebuilds the preview and re-renders every frame. + Components at scale (`cargo xtask bench`, synthetic design system of about 300 + components used by 2,000 instances): edits of mains reach their copies directly instead of + rebuilding every instance (value, size and layer added/removed edits; other edits rebuild each + affected instance once, inner mains first), bound variables no longer touch unchanged layers on + every edit, auto layout re-flows only where sizes or places changed: recolouring or resizing a + main's layer used ~900 times 188/226 → 4/18 ms, adding a layer to it 430 → 21 ms, a label style + in all 72 button variants (1,800 copies) 450 → 57 ms, switching a variant, reset, detach, push + and paste ~450 → under 3 ms, accepting a library update 460 → 64 ms; the Assets panel with 2,000 + assets 3.4 → 1.3 ms a frame. Accessibility: the custom-painted + controls name themselves for screen readers (AccessKit on desktop: role, name, value and state + for tools, layer rows with their type, level, selection and hidden/locked state, fields, + dropdowns, tabs, menu items, and the canvas, which describes the selection); keyboard navigation: + F6 / Shift+F6 move between the toolbar, layers, canvas and properties, Tab / Shift+Tab move + within a region with a focus ring, Enter or Space activate, the arrows go along the toolbar and + through the layer rows, menus and tool flyouts opened from the keyboard take it and give it back, + Escape returns to the canvas (where Tab selects the next layer again instead of wandering into + the tab strip); reduced motion follows the system's setting or a preference; secondary and + accent text meet WCAG AA in both themes. + An idle window draws no frames: it redrew ~240 times a second + (the window title was re-sent every frame, and each viewport command asks for another), ~33% of + a core in a debug build, and now sits at 0 frames and ~0% CPU; the toast, the autosave + checkpoint and the presenter's "after delay" timers wake it at their deadline, the interaction + editor's curve preview plays when it shows, changes or is hovered, and a screenshot keeps frames + coming only until it arrives (`ui.inspect` `framesDrawn` and `repaintCauses` show what asked for + frames; headless tests hold it). *Left:* a real-world file corpus, Windows/Linux/browser + QA, runs with real screen readers (NVDA, Narrator, VoiceOver, Orca), the AccessKit tree in the + browser (eframe's web backend doesn't send it yet). Landed since: F2 rename from the keyboard, + Shift+F10 context menus at the focused layer row or the canvas selection, keyboard box selection + (F8). 27–44 h. + +## Inventory: what exists today + +Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count there was stale: 963 `#[test]` functions as of this re-measure. + +- **Architecture:** 15 crates with enforced layering (`cargo xtask layers`): geom, color, doc, + layout, text, pathops, plugin, collab, render, format, svg, engine, ui-egui (+ mcp, pdf). Every action + is a command (449 command ids); undo is unlimited via structural sharing (`Arc` node snapshots); gestures are + one undo step; `command.batch` runs several commands as one step. +- **Document model:** pages, frames, groups, sections, rectangles, ellipses (arcs/rings), polygons, + stars, lines/arrows, vector networks, text with styled runs, boolean groups, components, component + sets (variants), instances with per-layer overrides, slices; fills (solid, linear/radial/angular/ + diamond gradients, images with fill/fit/crop/tile + adjustments), multiple strokes with + inside/center/outside alignment, per-side weights, dashes, caps/arrowheads, joins; effects (drop + and inner shadow with spread that offsets the outline exactly: ellipses, stars, vectors, text; + inner shadows over the fills and under the children and strokes; drop shadows hidden behind what + the layer covers, translucent fills included, unless shown behind transparent areas; layer blur; background blur + that sees the whole backdrop, inside clipping frames, faded groups and masks too; both blurs + uniform or progressive (the radius ramps between two points, dragged on the canvas); noise in + mono, duo and multi colour with grain size, density and blend mode; texture with clip to shape; + glass with light angle and intensity, refraction, depth, dispersion, frost and splay; the same in + PNG, PDF and SVG exports, SVG embedding renders of noise grains, texture, progressive and + background blur and glass); corner radii with + smoothing; constraints; auto layout (horizontal, vertical, wrap, grid; hug/fill/fixed; min/max; + absolute children); layout grids; export settings; prototype interactions, flows and transitions; + paint/text/effect/grid styles; variables with collections, modes and aliases. +- **Renderer:** CPU (tiny-skia) scene renderer used for canvas, export and the web: isolated layers + for opacity/blend/effects, clip content, masks, booleans (linesweeper), text (harfrust shaping, + skrifa outlines, font fallback), aligned strokes with caching. +- **Engine:** pointer tools (move/marquee/deep select/resize/rotate/Alt-duplicate with smart-guide + snapping, frame/section/slice/shapes/line/arrow with Shift/Alt, text, pen, pencil, hand, + eyedropper), Cmd/Ctrl-click and Cmd/Ctrl-marquee into nested layers, Shift-marquee toggling, + Select layer (every layer under the click), measuring (Alt-hover distances from the selection to + layers, parents and ruler guides, Cmd+Alt for nested layers, from a guide being dragged to the layers of its frame, extension lines, one box inside the + other, overlaps, live distances to the neighbours while Alt-dragging, Shift+Alt for auto layout + padding and gaps; labels that never overlap at any zoom), text editing (caret, selection, words, + lines), auto layout reorder by dragging, drop into frames, align/distribute/tidy, z-order, + flip/rotate, the scale tool (panel, and canvas handles that scale everything about its anchor + point or from the centre with Alt, with a live factor, one undo step per drag), booleans, flatten, + outline stroke, components (create, instances, sync with overrides, reset, detach, push changes, + variants, properties, slots), styles and variables, prototype commands, find/replace, batch rename, + copy/paste (internal + SVG/text), file new/open/save/import/export (native `.figcraft`, SVG + in/out, PNG/JPG/WebP), version snapshots. +- **UI (egui, swappable):** the incumbent's 2026 layout: tab strip that is also the window's title + bar (frameless on Windows/Linux with drawn window buttons, drag, double-click maximise and edge + resizing; macOS window buttons inset into it), icon rail, file header, pages and + layers tree (rename, visibility/lock, drag to reorder/reparent), Assets panel, floating bottom + toolbar with flyouts and mode switcher, Design / Prototype / Inspect panels (Dev Mode: properties with their styles and variables, annotations + with pinned properties and categories drawn as coloured callouts on the canvas (filtered by category), + a ready-for-dev list, a focus view on one frame with previous / next, Compare changes since a frame + was marked ready (before / after side by side or overlaid, property-level diffs), CSS/HTML/React/SwiftUI/Compose + code where components take their properties as parameters and instances are calls, components + connected to their code components showing the real snippet, read-only plugins), colour picker + (HSB square, hue/alpha, hex, gradient stops, image fills), main menu generated from the catalog, + context menu, Actions palette (⌘K), keyboard shortcuts from one keymap per platform (a tabbed, + searchable Keyboard shortcuts panel), keyboard navigation (F6 between regions, Tab within them, a + focus ring) and screen-reader names for the custom controls (AccessKit), presentation player with + transitions, light and dark themes, home screen, About/Preferences/shortcuts/variables dialogs. +- **Automation:** desktop control channel (`--control `: execute commands, inspect document/UI, + synthetic pointer/keys/typing, menu invocation, screenshots, renders). +- **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-15 | minor | M4: Outline stroke preserves even-odd stroke holes. Pixel comparisons cover alignment, dashes, individual side weights, vector paths and stroke-only opacity; native save/reopen, Undo/Redo and instance opacity inheritance/overrides are covered by `outline_stroke`. Existing estimates unchanged; filled-shape compositing needs separate work. | +| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | +| 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | +| 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | +| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | +| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | +| 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | +| 2026-10-13 | minor | Effects on groups, inner shadows and background blur (M3): a group is no outline of its own, so its inner shadows are cast inwards from around what its layers draw together (their pixels, over them: no shade at a seam between two layers) and its background blur and glass show the backdrop through the outlines of its layers (under them). Canvas and PNG; SVG embeds a group with an inner shadow as a render and its background blur as an image under the layers; PDF places both as images, the blur matching the PNG. Where the group holds one shape the pixels match that shape, or a frame filled like it, carrying the effect (`group_effects_tests` in render, svg and pdf). Offsets and spreads of a group's inner shadow go by whole pixels. Numbers unchanged. | +| 2026-10-13 | minor | Text made by commands on a dark page (M1): in the app, `text.create` and `node.create {type: text}` that name no fill give white text on a dark page, by the same rule as the Text tool (the page's colour decides, also inside a frame); a named `fill`, `fills` or `fillStyle` is kept. Sessions without an interface (CLI, MCP, exports) still make black text everywhere. Numbers unchanged. | +| 2026-10-13 | minor | Frames and groups, a one-step convert (M1): `object.convertToGroup` and `object.convertToFrame` (the Design panel's type menu on a frame or a group) change the layer in place, its layers staying where they are on the page. A frame turned into a group loses what only a frame has (auto layout, with the positions it gave kept and fill children fixed; fills, strokes, corner radius, clipping, layout grids, guides, scrolling) and takes its layers' box; a group turned into a frame keeps its box, with no fill and no clipping. Instances, main components, layers inside instances and empty frames are refused with the reason, all or nothing; one undo step. Numbers unchanged. | +| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | +| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | +| 2026-10-13 | minor | Starter templates and a first run that explains itself: eight templates built in code through the command layer (mobile app, landing page, dashboard, design system starter, wireframe kit, social post sizes, presentation slides, icon set starter; original and synthetic, deterministic, about 10–190 ms each to build), `template.list` and `file.newFromTemplate` for the app, CLI and MCP, File ▸ New from template…, a Templates tab on the home screen (cards with thumbnails rendered from the documents, search, kind filter, keyboard and screen-reader names) in place of the Community placeholder, a row of templates on Recents while there are no recent files, a Getting started card that can be closed and comes back from Help ▸ Getting started (`help.gettingStarted`), and a first run that opens on the home screen. The Drafts placeholder is gone (the app has no drafts folder). Essentials: discoverability ×0.92 → ×0.94 (templates and first-run tips exist; no Community files, no tour, no user evidence), 77.8% × 0.92 × 0.94 × 0.88 = 59.2%, ~57% → ~59%; essentials hours ~60–110 h → ~55–100 h (the onboarding and templates item is done); menu breadth stays 211/211 (the two new menu items are FigCraft's own and not counted); every other number unchanged | +| 2026-10-12 | minor | Everyday editing partials closed (gap 4): number fields scrub a step a point (×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it), step with the arrow keys by the nudge preferences, take brackets, units and relative edits (`+10`, `*2`, `50%`), commit on Tab and nothing when left untouched, and on Mixed apply relative edits to each layer in one undo step; outline mode's Include hidden layers and Include object bounds (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`), drawn and clickable; Hide when publishing from the Assets panel (`component.hideWhenPublishing`); a cursor for every tool, modifier and hover target (rotate turned to its corner, pen states, eyedropper, comment, copy, not-allowed), with guides and handles keeping theirs; panel widths with limits, double-click reset and the preferences (`view.panelWidths`); a file or page made in the dark theme has a #1E1E1E page (sessions without an interface keep #F5F5F5) and text drawn on a dark page is white. Menu breadth 211/211. UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding: the side-by-side comparison is what remains | +| 2026-10-12 | minor | Text metrics, first measured fidelity data (gap 3): our example documents, exported and re-saved by other software, were read back and every text layer's stored box, line heights, baselines and glyph positions compared with ours. Three rules explained every row and were applied: a line's height is rounded to whole pixels (halves up; Auto, % and px alike; the baseline keeps its fraction inside the rounded line); glyph advances and kerning are each rounded to 1/1024 em before scaling by the font size; letter spacing is not added after a line's last visible glyph. Before: 24 of 131 text boxes of the design-system example matched (118 in width, 24 in height); after: 131 of 131, and all 230 self-sizing text boxes across the 7 documents (Inter only, 11–120 px, letter spacing −6% to 8%, one wrapped paragraph). Glyph positions within 0.001 px: 223 of 1,832 → 1,781; the rest are kerning pairs at weights where the installed font files differ slightly, at most 0.05 px. Frames and other layers matching in size and position: 115 of 231 → 208; the other 23 hang on one grid frame whose row positions came back as written. Not measured: other font families, fractional px line heights, centred or right-aligned text with letter spacing, lists, mixed sizes on a line. Wording only; no number moves | +| 2026-10-12 | minor | `.fig` export (gap 1): written files were checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; effects such as glass and noise, images and prototype interactions are still unchecked there, and variables are not written. Wording only; no number moves | +| 2026-10-12 | minor | A public `figcraft-server` (open hosting): with `open_publish = true` anyone publishes a file without the server key and gets its edit and view links plus an owner token (128 bits, shown once, stored hashed) that lists, makes and revokes its links, renames it or removes it (`collab.manageFile`); limits per address (connections at once and a minute, messages a second, publishes an hour, files and MiB kept; IPv6 by /64; `X-Forwarded-For` believed only from `trusted_proxy`; tables capped at 10,000 addresses) and for everyone (`max_total_mb`), each refusal saying why; files published without the key expire after `idle_days` unopened (the date shown to everyone in the file); moderation (`files list --sort size|age|idle`, `block-ip`, `reports`, a Report button and `collab.report`); the operator's contact and terms shown in the Share dialog; `GET /healthz` on the same port; `packaging/server/install.sh` for a Debian/Ubuntu machine behind Caddy and a nightly backup timer. Protocol additions within version 2, gated like signals (older apps get clear refusals). Hosting infrastructure rather than a Figma feature: percentages and hours unchanged | +| 2026-10-12 | minor | Experimental `.fig` export (gap 1): the whole document written from the same public format notes as the import, verified only by round trips through our own importer. Import/export breadth 84% → 86%, ready 57% → 58%; file formats 59% → 60%; file exchange with Figma users stays ×0.85 (nothing shows another app opens the files); mainstream hours ~130–244 h → ~120–229 h (`.fig` export 15–30 h → checking it elsewhere 5–15 h); feature breadth (~84%), ready for real work (~53%), essentials (~57%), mainstream (~49%), beta and full parity hours unchanged at this rounding | +| 2026-10-12 | minor | Keyboard shortcut parity suite and bindings (gap 4): a table test with one row per shortcut in the public documentation, macOS and Windows/Linux keys each (129 rows: 126 bound, 110 before; 1 moved off a documented conflict; 2 missing; 2 kept by a platform), plus 48 pinned undocumented bindings so every default binding is in the table; newly bound: A (Frame), Shift+' (pixel grid), Alt+Cmd+\ (others' cursors, new `view.multiplayerCursors`), Ctrl+F6, Shift+Cmd+U (link), Cmd/Ctrl+] and [ (list levels while editing, new `text.listIndent` / `text.listOutdent`), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection, Cmd/Ctrl+arrows draw it out), suggest auto layout, go to main component, arrows that pan with nothing selected (new `view.pan`), Control suspending snapping on macOS, Space/N/C/F while presenting; symbols another layout types with Shift count without it; no default binding uses a system-reserved chord (tested). Menu breadth 207/207 (View ▸ Multiplayer cursors). UI/UX fidelity 58% and the interaction pass 40–60 h unchanged at this rounding | +| 2026-10-12 | minor | Simplified Chinese draft catalog (gap 5): `zh-Hans.toml` translates every UI string (1,371 keys; plurals give only `other`, and `plural_one` never picks "one"), command label (524) and menu wording (29), machine-drafted in a concise UI style with full-width punctuation and marked as a draft awaiting native review; Preferences ▸ Language lists 简体中文, System maps zh, zh-CN, zh-SG and zh-Hans to it, and Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and stays English (or the next OS language), never Simplified. A Chinese UI draws Han in a Chinese face first (Microsoft YaHei, PingFang SC, Noto Sans CJK SC), ahead of the craft-fonts build's Japanese BIZ UDPGothic, and changing the UI language lays the CJK faces out again (switching from Japanese to Chinese no longer keeps the Japanese face in front). String counts re-measured: 1,371 UI keys, 524 command labels and 29 menu wordings (1,924 entries); the French, German, Brazilian Portuguese, Japanese and Korean drafts have 1,899 of them (the 25 newer strings read English). Localization 36% → 39%, 31–54 h → 29–51 h; ready for real work unchanged (~53%); full parity ~267–527 h → ~265–524 h | +| 2026-10-12 | minor | Latin American Spanish overlay (gap 5): `es-419.toml` lists only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American usage (agregar for añadir, presionar for pulsar, configuración for settings, Enter for the Intro key, control for a gamepad, resorte for a spring) and reads `es.toml`, then English, for the rest (a regional catalog overlays its language's); same tú register as Spanish; a draft awaiting native review. Preferences ▸ Language lists Español (Latinoamérica); System maps es-419, es-MX, es-AR, es-CO and the other Latin American and US Spanish tags to it, es-ES and es to Spanish. Localization 35% → 36%, 32–55 h → 31–54 h; ready for real work unchanged (~53%); full parity ~268–528 h → ~267–527 h | +| 2026-10-12 | minor | Prototyping tail (gap 15, the part that doesn't need video): key/gamepad triggers take controller buttons (`Gamepad A` … `Gamepad Home`, the standard layout's 17 buttons, from a Controller menu beside the key field; the REST import maps controller triggers instead of skipping them); the player reads controllers on Windows (Windows.Gaming.Input through the `windows` crate the app already used; no new dependency) and in the browser (the Gamepad API), polling only while the prototype has such a trigger and the window has the focus, and the control channel presses buttons (`ui.input {gamepad}`); macOS, Linux and FreeBSD read no controllers yet. Widgets react to clicks in a presented prototype (the canvas's callback and sandbox limits, on the prototype's copy: the file and its history don't change, restarting starts over). `proto.evaluate` computes an expression where a layer's interaction would run (MCP); the mode lookup and set-variable-mode rules are cases in a behaviour suite (they already held). Prototyping breadth 90% → 92%, ready unchanged at 74%, 3–6 h → 2–5 h; area hours 120–260 → 119–259 (core 33–75 → 32–74), Features (depth) 29–68 h → 28–67 h; percentages unchanged at this rounding; full parity ~276–538 h → ~275–537 h | +| 2026-10-12 | minor | Plugin directory on `figcraft-server` (gap 6): the server keeps plugin bundles (manifest, module, up to 32 assets; an uncompressed length-prefixed format named by its SHA-256) published with the server key (`figcraft-cli plugin publish`, `plugin.directory.publish`), checked in full before they are kept (size cap, manifest and plugin API version, a version newer than the one kept, the module compiled but never run, plain asset names) and capped (`max_plugins`, `max_plugin_mb`, `max_plugins_mb`); anyone who reaches the server lists, searches and fetches them (hash-checked again on read, a shared download budget); the operator moderates with `figcraft-server plugins [remove]`. The app's Plugins ▸ Manage plugins gains a Browse tab: a server's plugins with Install / Update / Remove, an install confirmation that shows the permissions asked for, the source and the SHA-256, the download checked against the listed hash before anything is read, plugins kept per user in hash-named folders; `plugin.directory.list` / `install` (needs `confirm: true`) / `publish` / `unpublish` for scripts and MCP. Not in the browser build yet; no public catalogue, publisher accounts, signatures, ratings or background update checks. Plugins & widgets 55%/32% → 60%/35%, 10–30 h → 8–26 h; Ecosystem dimension 34% → 38%; weighted totals unchanged at this rounding; full parity ~281–548 h → ~279–544 h | +| 2026-10-12 | minor | Japanese and Korean draft catalogs and CJK UI fonts (gap 5): `ja.toml` and `ko.toml` translate every UI string (1,351 keys; plurals give only `other`, and `plural_one` never picks "one" in either), command label (519) and menu wording (29), machine-drafted and marked as drafts awaiting native review; Preferences ▸ Language lists 日本語 and 한국어, System picks ja* and ko*. CJK UI text renders: once the UI language, the open file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han, the desktop app appends one installed face per script to egui's fallbacks (Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux Noto Sans CJK; the craft-fonts build's BIZ UDPGothic first), reading only that face's file and sharing its bytes with egui (English users load none: startup and memory unchanged); the web shows Japanese and Korean only when built with craft-fonts' Noto Sans CJK (`FIGCRAFT_EMBED_CJK`), else it keeps them out of the picker and System. Localization 30% → 35% (drafts, not reviewed: graded low), 40–65 h → 32–55 h; ready for real work ~52% → ~53% (52.9 → 53.1 by the dimension table); full parity ~284–554 h → ~276–544 h; beta ~162–292 h → ~155–280 h (Japanese and Korean leave the beta list; their native reviews need people) | +| 2026-10-12 | minor | Plugin widgets and the panel tail (gap 6, plugin API 3; v1 and v2 plugins unchanged): a plugin marked as a widget puts a layer of its own on the canvas from Plugins ▸ Widgets or `widget.insert`: it describes the layer (frames with auto layout, text, rectangles, ellipses, images it carries) and FigCraft validates the description (size, layer count, depth, numbers, colours, text and image sizes) and builds it as a frame with locked layers inside; a click on a part the plugin named (on the canvas with the Move tool, a pointing hand over it, or `widget.click`) calls the plugin back in a fresh, metered sandbox with the widget's state, as one undo step, and the widget is redrawn; the state lives in the widget's own layer data, so widgets survive save and reload, copying, undo, export and live sessions; no plugin can write that data or call `widget.*`. `widget.list` lists them. A Counter example widget. Plugin panels of different plugins open side by side, and a colour field's swatch opens the colour picker (the plugin hears of the colour when a drag ends). Plugins & widgets 45%/28% → 55%/32%, 15–40 h → 10–30 h; Ecosystem dimension 28% → 34%; weighted totals unchanged at this rounding; full parity ~286–558 h → ~281–548 h | +| 2026-10-12 | minor | French, German and Brazilian Portuguese draft catalogs (gap 5): every UI string, command label and menu wording (1,899 entries) machine-drafted in each and marked as drafts awaiting native review, falling back to English per key (new strings need only English and Spanish); 0 counts as singular in French and Brazilian Portuguese; System picks the first OS language with a catalog (pt-PT and pt read pt-BR); long translations elide with a tooltip in dropdowns, two-column labels and the home cards. Localization 20% → 30%, 45–75 h → 40–65 h; ready for real work unchanged (~52%; 52.6 → 52.9); full parity ~289–564 h → ~284–554 h; beta ~170–305 h → ~162–292 h (Figma's languages left on the way to beta: Japanese and Korean, 7–12 h) | +| 2026-10-12 | minor | Cursor chat and spotlight in live sessions: `/` opens a chat bubble at your pointer that everyone sees as you type and that fades a few seconds after the last change; a spotlight (your avatar's menu, `collab.spotlight`) makes everyone follow your view and page, latecomers included, until they move their own. Both are ephemeral presence the host validates and rate-limits, passed only to clients that read them. Multiplayer & sharing breadth 15% → 17%, ready unchanged (5%), 45–90 h → 42–84 h; the Collaboration dimension's hours 55–110 → 52–104 h (the dimension hours still add up to the total); feature breadth (~84%) and ready for real work (~51%) unchanged at this rounding; full parity ~301–587 h → ~298–581 h | +| 2026-10-12 | minor | Layers and Assets panels behaviour suite written from the public documentation (6 engine tests with about 50 table rows and 5 panel tests driven through the UI; about 30 rows that the code before the fixes contradicts) and its fixes: several layers dropped together in the layers panel land together (layers from apart split around the drop point); Collapse layers keeps the selection's rows open (it closed everything); the eye and padlock stay under the pointer (they vanished as it reached them, so clicks could miss) and dragging across them sets several layers in one undo step; several layers selected open a Rename layers dialog (match as text or a regular expression, Rename to, current name and number buttons, a start value, preview) instead of renaming the top one; `object.batchRename` counts `$n` up and `$N` down (they were swapped), pads with `$nnn`, takes `start`, regular expressions with `$1`…`$9`, `` $` `` / `$'` and codes in the replacement; pages are dragged to reorder, a blank page named with a hyphen is a divider (never opened, holds no layers), duplicates are named "Copy of …", the last page that isn't a divider can't be deleted; the Assets panel files components by page, frame and slash names in folders that fold away (`component.list` gives each `folder`); a leading `.` or `_` now keeps a component out of the library even after it was published, and removing it brings the component back. Pinned where the documentation is silent: Alt-click on an arrow, numbering in stacking order, hidden layers and locked frames taking drops, locking deselects. Layers panel 98%/82% → 99%/85%; percentages and full parity hours unchanged at this rounding | +| 2026-10-12 | minor | Vector multi-edit and the vector edit tail, with a behaviour suite written from the public documentation (5 tests, about 30 table rows and steps, and a junk-parameter sweep of every vector command in multi-edit mode): several vector layers are edited at once (Enter, double-click or `vector.edit.begin {ids}`; a press on another layer's point, handle or segment makes it the active layer; Shift-click, marquee and lasso select across layers; dragging, the arrows, Delete, delete and heal, mirroring, smoothing, corner radius and point caps and joins reach every layer's selected points in one undo step; the pen, bend, paint bucket, cut and eraser work in the active layer); several Bezier handles are selected with Shift-click and dragged together (`vector.edit.moveHandles`); a sharp point next to a curved segment rounds (the curve is cut where the arc meets it and keeps its shape); a point can have its own stroke cap and join (`vector.edit.strokeCap`, `vector.edit.strokeJoin`, the Point section of the Design panel; canvas, PNG, SVG and PDF, outline stroke); the Pen outside edit mode connects to one of its own points and ends the path, and continues a selected open path from an end point. Left: corner smoothing beyond rectangles, the transform box around points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. Shapes & vector 98%/72% → 99%/73%, 2–4 h → 1–3 h; Figma Draw extras 80%/55% → 88%/60%, 2–5 h → 1–3 h (gap 9: pen pressure on macOS and Linux tablets left). Totals recomputed by the doc's method, which rows since the last recompute had left stale: the area table's weighted breadth is 83.7% (~83%; core 94.1%, ~94%) and ready 63.1% (~63%; core 72.1%, ~72%), hours 133–284 (core 38–83); Features (depth) 61% → 63%, 46–94 h → 34–76 h; ready for real work 52.6% (~52%); mainstream weekly areas 71.0% → 72.1% (× 0.9 × 0.9 × 0.85 = 49.7%, ~49%, rounded down); essentials unchanged (~57%); full parity ~306–595 h → ~294–572 h (on top of the rows merged since: ~301–587 h → ~289–564 h; area-table totals recomputed on the merged table and rounded down: breadth ~84%, ready ~63%, core ~94%/~72%, mainstream weekly areas 72.4%, ~49%) | +| 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | +| 2026-10-12 | minor | Extended collections' tail (gap 14): variables a library adds to a collection this file extends now count as a library update of their own (listed by name without a copy yet) and arrive inheriting when accepted, instead of waiting for the library's next other update; a library collection that is itself an extended collection can be extended again, values flowing down the chain as each file accepts updates with every level's overrides kept (checked end to end). Styles & variables breadth 94% → 95%, ready unchanged (76%), 3–5 h → 2–4 h; gap 14 1–2 h → 0–1 h (real-library checks left); feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~305–594 h → ~304–593 h | +| 2026-10-12 | minor | A branch's own version history and finer list merges (gap 17): versions saved or autosaved on a branch tab go into the branch (viewed, restored, renamed, deleted and duplicated there, saved in the file with it); merging adds a version to the main file's history and the merged or archived branch keeps its own; fills, strokes and effects merge item by item, conflicting only when the same item changed both ways, an item was removed on one side or both sides added different items. Comments, versions, find, misc breadth 85% → 87%, ready 60% → 61%, 2–5 h → 1–4 h; gap 17 1–3 h → 1–2 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~306–595 h → ~305–594 h | +| 2026-10-12 | minor | Headline percentages recomputed from the area table by its own method (weights × written scores): feature breadth ~82% → ~84% (core editor ~93% → ~94%), weighted ready ~61% → ~63%, core ready ~70% → ~72%, mainstream weekly areas 71.3% → 72.3% and mainstream ~49% → ~50%; several earlier rows had moved the area scores without carrying them into the totals. Hours are unchanged (left for the next full re-measure) | +| 2026-10-12 | minor | Text lists, hanging punctuation and decoration styles, with a behaviour suite written from the public documentation (7 layout tests and 7 command tests, about 60 table rows): lists are set per paragraph (the list buttons, shortcuts and Type settings act on the paragraphs the selection touches, the whole layer when it isn't being edited), nest up to 8 levels (Tab and Shift+Tab; numbers go 1. a. i., bullets • ◦ ▪, counted per level), Enter on an empty item and Backspace at an item's start step out a level and then out of the list, typing `- `, `* ` or `1. ` at a paragraph's start starts one; list spacing (between items, in place of paragraph spacing), hanging lists (markers outside the box) and hanging punctuation (opening punctuation outside a start-aligned edge, closing punctuation outside an end-aligned one, taking no room when lines wrap); underline and strikethrough style (solid, dotted, wavy), thickness, underline offset, skip ink and colour. Canvas, PNG, SVG and PDF; `node.set` keys (`indentation`, `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`/`Thickness`/`Offset`/`SkipInk`/`Color`), inspect (`lineTypes`, `lineIndentations`), CSS (`text-decoration-style`/`-thickness`/`-color`, `text-underline-offset`, `text-decoration-skip-ink`) and the REST import (`lineTypes` with `lineIndentations` per paragraph, which collapsed to one list style before). Text 87%/68% → 90%/70%, 5–10 h → 3–7 h; mainstream weekly areas 71.0% → 71.2%; other percentages unchanged at this rounding; full parity ~306–595 h → ~304–592 h | +| 2026-10-12 | minor | Comments and version history behaviour suite written from the public documentation (15 tests, about 80 table rows plus stepwise checks; 4 tests failed before the fixes) and its fixes: in a live session, threads made in the session couldn't be replied to, edited, reacted to, resolved, moved, deleted or opened (their ids, from their creator's id space, were refused as out of range); restoring a version brought back that version's comments, losing newer threads and replies (comments now stay, those on layers the version lacks staying where they were); restoring now keeps the replaced design in the history as an autosave first, unless a version of it was just saved or restored; giving an autosave a title makes it a named version (it could be dropped to make room); merging a branch adds the main file as it was and then a `Merged “”` version to its history. Pinned where the documentation is silent: pins go to the innermost frame under them, copies, duplicates and duplicated pages come without comments, replying reopens a resolved thread, versions stay with whoever saved them in a live session (a shared history needs accounts). Left: a branch's own version history, comparing two versions. Comments, versions, find, misc 85%/60% → 85%/62%, 2–5 h → 2–4 h; mainstream weekly areas 71.0% → 71.1%; percentages unchanged at this rounding; full parity ~306–595 h → ~306–594 h | +| 2026-10-11 | minor | Canvas navigation, rulers, guides, layout grids, outline mode and measuring behaviour suite written from the public documentation (18 tests, about 120 table rows and checks) and its fixes: the pixel grid shows from 400% (it waited for 800%); guides belong to the page or a top-level frame (a guide dropped over a nested frame goes to the frame holding it, and a frame moved into another keeps its guides but they stop showing, snapping and measuring); a guide can be selected, deleted with Delete, nudged with the arrows and removed from the context menu (`guide.select`, `guide.remove` without an index); Alt-dragging a guide drops a copy and holding Alt while dragging one measures to the nearest edges in the frame it crosses; columns and rows count Auto (as many fixed-size ones as fit); layout grids don't show or snap on a rotated frame; in outline mode shapes are clicked on their outlines, reaching layers behind. Left: outline mode's Include hidden layers and Include object bounds. Canvas & navigation 97%/72% → 98%/74%, 2–5 h → 1–4 h; feature breadth (~80%), ready for real work (~49%), core editor (~68%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~320–615 h → ~319–614 h | +| 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Import/export 80%/50% → 82%/53%; Dev Mode 90%/64% → 93%/70%; file formats 55% → 56%; mainstream weekly areas 68.1% → 68.4%; feature breadth (~80%), ready for real work (~49%), essentials (~57%), mainstream (~46%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | `.fig` import's tail (gap 1): vectors open as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps from the end points), falling back to outlines when the network is missing or damaged; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers import as the closest frames, text layers and vectors with a warning instead of being dropped; set-variable, set-variable-mode and conditional prototype actions come across. New parsers fuzzed on fixtures generated in code. File formats 55% → 58%, 8–15 h → 5–10 h; import/export breadth 80% → 82%, ready 50% → 54%; feature and area totals and ready for real work (~49%) unchanged; beta ~173–310 h → ~170–305 h; full parity ~321–618 h → ~318–613 h | +| 2026-10-11 | minor | Shapes and vector behaviour suite written from the public documentation (10 shape tests, 6 vector tests and 3 handle tests: about 70 table rows plus stepwise checks) and its fixes: rounded vector points are drawn and exported (the radius was stored only; a vector's corner radius now rounds every point); a boolean group looks like its top layer, a subtraction like the bottom one (always the bottom); Alt draws a line from its centre; stars keep to 3–60 points; delete and heal fits the curve through the deleted point; Shift-clicking a selected point at a corner of the points' box takes it out (it scaled); outline stroke turns the stroke's colour style into the fill's and keeps opacity and effects; the miter field shows the miter angle (it showed the ratio under that name); individual corners only for rectangles and frames; Option/Alt+Shift+F flattens; edit-mode keys reach the edit tools through MCP `key` (Delete removed the layer). Added: corner radius and star ratio handles on the canvas, Count and Ratio fields for polygons and stars, cut tool drags that divide a shape into layers, paint bucket drags across areas. Shapes & vector 97%/68% → 98%/72%, 1–2 h → 2–4 h (the suite found gaps the estimate didn't count: corner smoothing beyond rectangles, a join or cap per point). Totals recomputed by the doc's method, which earlier rows had left stale: the area table's weighted figures are breadth ~82% (core ~93%) and ready ~61% (core ~69%), not the ~80%/~58% (~91%/~68%) carried since #130; Features (depth) follows the weighted ready, 57% → 61%; ready for real work ~49% → ~51%; mainstream ~46% → ~48% (weekly areas 67.8% → 69.4%); the selection suite's area 2 hours (−3/−5) hadn't reached the totals, and neither it nor the text suite had reached the Features (depth) row: Features 60–115 h → 56–109 h, full parity ~321–618 h → ~319–615 h (−3/−5 for area 2, +1/+2 for area 4) | +| 2026-10-11 | minor | Paint and effects behaviour suite written from the public documentation (14 paint and 9 effects tests with table rows for fills, opacity, every blend mode by its formula, pass-through and Normal containers, gradient handles and stops, image scale modes and adjustments, colour models, the colour profile, shadows, blurs, effect order and exports; 3 command tests) and its fixes: groups and frames set to Normal blend their children among themselves first, and clipping frames no longer isolate their children (blend modes inside a clipping frame met only the frame's children, not its fill or what is behind it) — canvas, SVG (`isolation`; clips put on what blends, since a clipped group isolates) and PDF (isolated groups); plus darker is drawn (it was multiply) on the canvas and in PNG exports; drop shadows hide behind what the layer covers, translucent fills included, unless shown behind transparent areas, in SVG too (it never cut them out) and the SVG filter region reaches as far as the effects; Fill images cover the box to its edges (upscaled ones faded out at the edge); "Normal" names Normal, not pass-through, in commands and the blend menu (the menu's Normal set pass-through). Added: a blend menu for each paint in the colour picker. Paint 66% → 70%, 4–7 h → 3–6 h; effects 72% → 75%, 3–6 h → 2–5 h; percentages unchanged at this rounding; full parity ~321–618 h → ~319–616 h | +| 2026-10-11 | minor | Frames, groups, sections and masks behaviour suite written from the public documentation (15 frame, group and section tests, 6 mask tests) and its fixes: a layer a mask hides can't be clicked there (clicks reach what's behind; Select layer agrees), a mask group's box and exports are the mask's (plus any layers under it), sections stay on the page or in sections (moves, drags, pastes, grouping and new sections in frames refused or kept on the page), drawing a frame or section around layers puts those fully inside it in it, resizing a section leaves its contents where they are (and a frame resized with Cmd/Ctrl held keeps its layers' sizes too), and X/Y of layers in groups are measured in the frame (groups have no coordinate space). Frames, groups, sections, masks 95%/75% → 97%/80%, 2–4 h → 1–3 h; percentages unchanged at this rounding; full parity ~323–621 h → ~322–620 h | +| 2026-10-11 | minor | Styles and variables behaviour suite written from the public documentation (18 command-level tests) and its fixes: editing a styled fill, stroke, effect or layout grid list (directly, a paint row, an added fill or a bound variable) unlinks that style instead of being overwritten by the next style edit; applying a style replaces variables bound to what it sets; text styles go on text layers only; deleting a variable (or its collection) unbinds the layers using it, which keep their last value, and aliases to it take its values; bindings are type-checked (a colour variable can't drive a width) and number variables fill text content; aliases are checked when a variable is created. Added: duplicate, move (reorder, into a folder) and rename folder for styles, with a Local styles list grouped by type and folder (click to edit, right-click, drag to reorder); duplicate mode, set as default and move column for modes; per-platform code syntax (Web, iOS, Android) used by generated CSS and the variables JSON; font weight, paragraph spacing/indent and text content scopes for number variables. Found missing: variables inside styles. Styles & variables 92%/72% → 94%/76%, 1–2 h → 3–5 h; percentages unchanged at this rounding; full parity ~323–621 h → ~325–624 h | +| 2026-10-11 | minor | Prototyping behaviour suite written from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag starts only for a drag its animation's way and the animation follows the pointer, key combinations (`Shift+K`) fire (and are recorded and imported), an overlay's after-delay timer starts when it opens, instances of interactive components play their variant's interactions and "Change to" changes the right instance, spring presets are physical springs that play until they settle, smart animate overshoots with spring and back easings, fixed layers draw above scrolling content; added scroll to offsets. Prototyping 88%/70% → 90%/74%, 5–9 h → 3–6 h; mainstream weekly areas 68.1% → 68.4%, mainstream ~46% → ~47%; other percentages unchanged at this rounding; full parity ~320–615 h → ~318–612 h | +| 2026-10-11 | minor | Components behaviour suite written from the public documentation (10 tests, 45 table cases) and its fixes: instances now keep overrides of auto layout padding, gap and alignment, layout grids, export settings, prototype interactions and every text property (resizing, paragraph spacing, lists…; these were lost when the main changed); moving, rotating, resizing or re-constraining layers inside an instance, and its auto layout direction, are refused (the instance itself resizes); switching variants keeps an override only where both variants had the same value (text layers keep theirs); swapping from the Assets panel keeps only text overrides; the default variant is the top-left one (instances, Assets thumbnails, code); hidden exposed instances hide their properties; pushing changes from a nested layer goes to the outer main. Added: variant property reordering and duplicate combination warnings. Components 95%/68% → 96%/72%, 6–12 h → 4–9 h; with #133's selection and transform change, core editor ready ~68% → ~69% and mainstream ~46% → ~47% (weekly areas 68.1% → 68.8%); full parity ~320–615 h → ~315–607 h (the totals now also carry #133's area 2 hours) | +| 2026-10-11 | minor | Embedded image colour profiles: placed and opened images are converted once, when decoded, from their own colour space into the file's (sRGB or Display P3): ICC profiles in PNG (`iCCP`), JPG (APP2), WebP and TIFF, matrix/curve and table profiles in RGB or grey (moxcms, already built for the `image` crate), and a PNG's `sRGB`, `gAMA` and `cHRM` chunks; images without a profile, or with one that can't be used (CMYK, damaged, over 4 MB), are taken as sRGB as before. The original file is kept (PDF exports still embed it); pixel exports carry the converted pixels with the file's profile. The image fill's details show the source profile (Unknown — treated as sRGB when there's none) and `image.place` reports it as `imageProfile`. Paint breadth 87% → 90%, ready 66% → 67%, 4–7 h → 3–6 h; feature breadth (~80%), ready for real work (~49%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Layers panel keyboard and stroke variables: F2 renames the selected layer (also from a layer row with the keyboard, which gets it back; Escape there cancels), Shift+F10 (`view.contextMenu`) opens the context menu at the focused row or below the canvas selection and the menu takes the keyboard, F8 (`select.area`) draws a selection box for keyboard-only users (arrows move it, Shift+arrows size it, Enter selects like a marquee; agents pass a box), long lists checked at 10k layers (revealing a layer, expanding or collapsing everything and arrow keys take at most 1.3 ms a frame in the release UI bench; the revealed row takes the keyboard); colour styles on strokes were already complete, and each side's stroke weight now binds to a number variable (`strokeTopWeight`…, bindable fields in the Design panel and REST import). Layers panel 95%/78% → 98%/82%, 2–4 h → 1–2 h; Strokes 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; feature breadth (~80%), ready for real work (~49%), essentials (~57%) and mainstream (~46%) unchanged at this rounding; full parity ~321–618 h → ~320–615 h | +| 2026-10-11 | minor | Text behaviour suite written from the public documentation (13 text cases, 5 command tests) and its fixes: CJK lines break between ideographs, kana and Hangul and before and after Latin words without breaking inside them, with the line-start and line-end rules (closing punctuation, small kana and the prolonged sound mark never start a line, opening brackets never end one); a style's line height, baseline and decoration come from its own font, so lines with CJK or emoji drawn by fallback fonts keep the spacing of the others; paragraph indent applies to start-aligned text only; carets, Delete and Backspace step over whole grapheme clusters (emoji sequences, flags, combining marks), which also shape with one font; double-click and word moves take one script's run in text without spaces. Added: vertical trim (cap height to baseline; `leadingTrim`, Type settings, REST import, inspect, CSS `text-box`). Text 85%/64% → 87%/68%, 7–13 h → 5–10 h; percentages unchanged at this rounding; full parity ~323–621 h → ~321–618 h | +| 2026-10-11 | minor | Selection and transform behaviour suite written from the public documentation (5 selection tests with 38 table rows, 11 transform tests) and its fixes: Alt-resizing shrinks about the centre too (it could only grow); groups, frames with Scale or stretch constraints and several layers with different rotations scale each layer along its own axes, keeping its rotation and flip (children turned 90° grew along the wrong axis, nested turned groups too); several layers dragged past an edge mirror and flip; a flipped layer reads back the rotation typed; resizes land on whole pixels with the pixel grid on; a press inside a layer smaller than its handles moves it and the nearest handle wins (the first in reach did), with its handles hidden under 16 px; locked layers selected in the layers panel get no handles and aren't dragged with the rest; the background of a top-level frame with layers starts a marquee inside it (it moved the frame); Select inverse skips hidden layers like Select all. Added: equal spacing snapping with labelled gaps, Cmd/Ctrl while resizing a frame ignores its constraints. Selection & transform 95%/68% → 97%/72%, 6–10 h → 3–5 h; core editor ready ~67% → ~68%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~345–666 h → ~342–661 h | +| 2026-10-11 | minor | `.fig` import, best effort (gap 1): `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors (as outlines), auto layout (grid included), constraints, components, sets, instances (expanded from their main with overrides, derived layout and property values), styles, variables with modes and bindings, prototyping and image fills from the archive come across; anything else is a warning. Import/export breadth 65% → 80%, ready 35% → 50%, 30–60 h → 8–15 h; file formats 40% → 55%, 30–60 h → 8–15 h; feature breadth ~79% → ~80% (core ~90% → ~91%), areas ready ~57% → ~58% (core ~67% → ~68%); ready for real work ~47% → ~49%; essentials ~53% → ~57% (opening files people send ×0.80 → ×0.88); mainstream ~43% → ~46% (weekly areas 66.8% → 67.8%, file exchange ×0.80 → ×0.85); beta ~195–355 h → ~173–310 h; full parity ~345–666 h → ~322–620 h | +| 2026-10-11 | minor | Auto layout behaviour suite written from the public documentation (64 layout cases, 8 command tests) and its fixes: a frame is never smaller than its padding, only the frame's inside stroke counts when strokes are included (and new auto layout includes them), auto gaps stop at 0, grid `fr` tracks behave like the CSS `fr` unit (a track never gets less than its content), canvas stacking is drawn, exported and hit (it was stored only). Added: auto gaps evenly and around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Auto layout 95%/68% → 98%/74%, 6–12 h → 3–6 h; percentages unchanged at this rounding; full parity ~348–672 h → ~345–666 h | +| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h | +| 2026-10-11 | minor | Several windows (gap 18): File ▸ New window, a tab's Open in new window or a tab dragged off the tab strip opens files in another window with its own tabs, panels and canvas, sharing the files, preferences, libraries and clipboard; each window draws only when it needs to (idle: ~0% CPU), closing one closes its files after asking about unsaved changes (the main window too, which takes over another window instead of quitting; the last window quits), and the control channel and MCP open, list, focus and capture windows (`window.new`, `window.list`, `window.focus`, `window` on any request); the screen-reader tree covers the main window only (eframe 0.36), so with a screen reader running files stay tabs; one window in the browser. Hardware 55% → 60%, 13–27 h → 10–21 h; gap 18 6–12 h → 4–8 h (P3 on screen left); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~360–691 h | +| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | +| 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | +| 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~358–687 h | +| 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | +| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | +| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | +| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h | +| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure | +| 2026-10-11 | minor | Live boolean editing, Design panel arc fields, the shape builder on strokes and open paths and one-sided variable widths landed (gap 13): Shapes & vector breadth 92% → 97%, ready 66% → 68%, 8–14 h → 1–3 h; feature breadth ~78% → ~79%; ready for real work unchanged (~47%); full parity ~390–745 h → ~385–730 h | +| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | +| 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | +| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95% for full (~400–750 h), mainstream (~180–330 h) and essentials (~85–160 h). Full number confirmed as an additive weighted sum; unchanged | +| 2026-10-10 | minor | Added the essentials user number (~53%: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send) | +| 2026-10-10 | minor | Added the mainstream practitioner number (~43%: weekly areas 66.5% × 0.9 interaction × 0.9 stability × 0.8 file exchange) and user-evidence count (none). Checked that ready for real work (~46%) is built from the written dimension weights and scores; unchanged | +| 2026-10-10 | minor | Stage checked against the alpha gate: stays alpha | +| 2026-10-10 | major | Created from the ROADMAP's parity rubric, honest assessment and area narrative. Full re-measure against Figma desktop 126.10.7: added multiplayer, plugins, team libraries, Figma Draw extras and AI as weighted areas; split breadth from ready-for-real-work; core depth re-graded ~79% → ~67%; hours calibrated from git history; full parity 115–195 h → 420–790 h | From 4c494b804f29a06b396dcc270cac15b3437acfb1 Mon Sep 17 00:00:00 2001 From: ahmedhosnypro Date: Fri, 16 Oct 2026 03:16:41 +0300 Subject: [PATCH 003/208] Fix the system theme always light on Linux and the BSDs winit 0.30 reports no system theme there (EventLoop::system_theme is None), so the "system" preference resolved to light every time. When the windowing stack gives no theme, the interface now follows the freedesktop colour scheme instead: read once and watched live through the settings portal (org.freedesktop.appearance / color-scheme) in the desktop services (Services::system_dark), so GNOME's and KDE Plasma 6's appearance setting switches the app without a restart. Where the OS theme is reported (macOS, Windows, web) it still wins, and without any answer the light fallback stands. --- Cargo.lock | 1 + apps/figcraft/Cargo.toml | 6 ++ apps/figcraft/src/services.rs | 78 ++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 12 +++- crates/ui-egui/src/theme_startup_tests.rs | 43 ++++++++++++- docs/target-app-parity.md | 3 +- 6 files changed, 136 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aead39d1..b2f9d9d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1427,6 +1427,7 @@ dependencies = [ "webbrowser", "windows", "winresource", + "zbus", ] [[package]] diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index bafb6da1..b22650eb 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -31,6 +31,12 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col [target.'cfg(windows)'.dependencies] windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] } +# The system colour scheme (org.freedesktop.portal.Settings): winit reports no theme on Linux and +# the BSDs, so the interface's "system" mode follows the portal's instead. zbus comes in through +# accesskit already; this only makes it direct. +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] } + # Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a # kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft, # EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index bd8a4204..60ae2559 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -3,7 +3,7 @@ use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use figcraft_engine::{Prefs, Session}; use figcraft_ui_egui::{DialogFuture, FigApp, Services}; @@ -16,6 +16,8 @@ pub struct Desktop { pub prefs: Option, /// The system's reduce-motion setting (read in the background at startup). reduce_motion: Arc, + /// The system's colour scheme ([`watch_system_dark`]): 0 unknown, 1 dark, 2 light. + dark: Arc, /// The system's interface language (read at startup). language: Option, /// Recovery copies of unsaved files (written at once after a panic). @@ -54,7 +56,19 @@ impl Desktop { let reduce_motion = Arc::new(AtomicBool::new(false)); let flag = reduce_motion.clone(); std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed)); - Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery } + let dark = Arc::new(AtomicU8::new(DARK_UNKNOWN)); + // winit reports no system theme on Linux and the BSDs: the freedesktop colour scheme + // answers instead, read once and then watched in the background. + #[cfg(all(unix, not(target_os = "macos")))] + if let Err(e) = std::thread::Builder::new().name("figcraft-theme".into()).spawn({ + let dark = dark.clone(); + move || watch_system_dark(dark) + }) { + log::info!("colour scheme: can't start the watcher: {e}"); + } + #[cfg(not(all(unix, not(target_os = "macos"))))] + let _ = dark; + Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, dark, language: system_language(), recovery } } } @@ -136,6 +150,59 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool { .is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want) } +/// [`Desktop::dark`]: the colour scheme is unknown, dark, or light. +const DARK_UNKNOWN: u8 = 0; +const DARK_DARK: u8 = 1; +const DARK_LIGHT: u8 = 2; + +/// The desktop's colour scheme through the freedesktop settings portal +/// (`org.freedesktop.appearance` / `color-scheme`: 1 dark, 2 light), read once and then watched +/// for changes. Without a session bus or a portal the scheme stays unknown and the UI keeps its +/// light fallback. +#[cfg(all(unix, not(target_os = "macos")))] +fn watch_system_dark(dark: Arc) { + const PORTAL: &str = "org.freedesktop.portal.Desktop"; + const SETTINGS: &str = "org.freedesktop.portal.Settings"; + const APPEARANCE: &str = "org.freedesktop.appearance"; + let store = |v: u32| match v { + 1 => dark.store(DARK_DARK, Ordering::Relaxed), + 2 => dark.store(DARK_LIGHT, Ordering::Relaxed), + _ => {} + }; + let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.build()) else { + log::info!("colour scheme: no session bus: the system theme isn't followed"); + return; + }; + let read = || -> zbus::Result { + conn.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(APPEARANCE, "color-scheme"))? + .body() + .deserialize() + }; + match read() { + Ok(v) => store(v), + Err(e) => log::info!("colour scheme: can't read the system setting: {e}"), + } + let watch = || -> zbus::Result<()> { + let rule = + zbus::MatchRule::builder().msg_type(zbus::message::Type::Signal).sender(PORTAL)?.interface(SETTINGS)?.member("SettingChanged")?.build(); + for msg in zbus::blocking::MessageIterator::for_match_rule(rule, &conn, Some(8))? { + let msg = msg?; + let body = msg.body(); + let Ok((namespace, key, value)) = body.deserialize::<(String, String, zbus::zvariant::Value<'_>)>() else { continue }; + if namespace == APPEARANCE + && key == "color-scheme" + && let zbus::zvariant::Value::U32(v) = value + { + store(v); + } + } + Ok(()) + }; + if let Err(e) = watch() { + log::info!("colour scheme: can't watch the system setting: {e}"); + } +} + /// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere. fn reveal(path: &Path) -> std::io::Result<()> { #[cfg(target_os = "macos")] @@ -278,6 +345,13 @@ impl Services for Desktop { fn reduce_motion(&self) -> bool { self.reduce_motion.load(Ordering::Relaxed) } + fn system_dark(&self) -> Option { + match self.dark.load(Ordering::Relaxed) { + DARK_DARK => Some(true), + DARK_LIGHT => Some(false), + _ => None, + } + } fn gamepad_held(&mut self) -> u32 { crate::gamepad::held() } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..e0749a97 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -239,6 +239,11 @@ pub trait Services { fn reduce_motion(&self) -> bool { false } + /// The system asks for the dark interface (the freedesktop colour scheme): for platforms whose + /// windowing stack doesn't report a theme (winit gives none on Linux and the BSDs). + fn system_dark(&self) -> Option { + None + } /// After a panic in a frame: keep recovery copies of unsaved files. fn emergency_save(&mut self, _session: &Session) {} /// Keep the preferences now (the installed plugins or their settings changed), as the app @@ -1125,9 +1130,10 @@ impl FigApp { } let dark = match self.session.prefs.theme.as_str() { "dark" => true, - // The active visuals were set by us last frame (possibly for a manual theme). - // Read the integration's OS theme instead; without one, keep the light fallback. - "system" => ctx.system_theme() == Some(egui::Theme::Dark), + // The active visuals were set by us last frame (possibly for a manual theme). Read the + // integration's OS theme; winit reports none on Linux and the BSDs, where the platform + // services' colour scheme answers instead. Without either, keep the light fallback. + "system" => ctx.system_theme().map(|t| t == egui::Theme::Dark).or_else(|| self.services.system_dark()).unwrap_or(false), _ => false, }; self.ui.dark = dark; diff --git a/crates/ui-egui/src/theme_startup_tests.rs b/crates/ui-egui/src/theme_startup_tests.rs index 6f2b13e2..2f0d33c4 100644 --- a/crates/ui-egui/src/theme_startup_tests.rs +++ b/crates/ui-egui/src/theme_startup_tests.rs @@ -4,7 +4,7 @@ use egui::{Context, RawInput, Theme}; use figcraft_engine::{Prefs, Session}; use serde_json::json; -use crate::{FigApp, NoServices, theme::Tokens}; +use crate::{FigApp, NoServices, Services, theme::Tokens}; fn frame(ctx: &Context, app: &mut FigApp, system: Option) { let mut output = ctx.run_ui(RawInput { system_theme: system, ..Default::default() }, |ui| app.ui(ui)); @@ -83,3 +83,44 @@ fn unavailable_system_theme_uses_the_existing_light_fallback() { frame(&ctx, &mut app, None); assert_eq!(Tokens::get(&ctx), Tokens::light()); } + +/// Services whose colour scheme answer stands in for the platform's (winit gives no theme on Linux +/// and the BSDs, where the freedesktop colour scheme takes over). +struct Scheme(Option); +impl Services for Scheme { + fn system_dark(&self) -> Option { + self.0 + } +} + +#[test] +fn without_a_system_theme_the_colour_scheme_services_decide() { + for (dark, expected) in [(Some(true), Tokens::dark()), (Some(false), Tokens::light())] { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(Scheme(dark))); + app.session.execute("view.themeSystem", &json!({})).unwrap(); + frame(&ctx, &mut app, None); + assert_eq!(Tokens::get(&ctx), expected); + assert_eq!(app.ui.dark, expected.dark); + } +} + +#[test] +fn the_os_theme_wins_over_the_colour_scheme_services() { + for (os, scheme) in [(Theme::Light, Some(true)), (Theme::Dark, Some(false))] { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(Scheme(scheme))); + app.session.execute("view.themeSystem", &json!({})).unwrap(); + frame(&ctx, &mut app, Some(os)); + assert_eq!(Tokens::get(&ctx).dark, os == Theme::Dark); + } +} + +#[test] +fn manual_themes_ignore_the_colour_scheme_services() { + let ctx = Context::default(); + let mut app = FigApp::new(Session::empty(), Box::new(Scheme(Some(false)))); + app.session.execute("view.themeDark", &json!({})).unwrap(); + frame(&ctx, &mut app, None); + assert_eq!(Tokens::get(&ctx), Tokens::dark()); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e15ddaed..4a6cec25 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (system theme follows the freedesktop colour scheme on Linux and the BSDs; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -628,6 +628,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Fix: the "system" interface theme followed the OS only where the windowing stack reports a theme; winit gives none on Linux and the BSDs, so the desktop app always drew light there. The interface now falls back to the freedesktop colour scheme (`org.freedesktop.portal.Settings`, read once and watched through a D-Bus signal, the `Services::system_dark` platform services), so system mode follows GNOME's and KDE Plasma 6's appearance setting live; the OS theme keeps winning where winit does report one (macOS, Windows, web), and without any answer the light fallback stands. Numbers unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Vector edit mode regions: the filled area under the pointer is hatched (clear of points and segments, holes left out, spacing by octave folding fitted to measurements at several zoom levels, fixed to the shape); click, Shift-click and drag select and move areas with their points (drawn unselected); a click on an empty spot clears them and stays in edit mode (see [ui-parity](ui-parity.md)) | | 2026-10-13 | minor | Plugin log limits (#191): cap each UTF-8 line at 8 KiB, total log bytes at 1 MiB and lines at 1,000 per run/callback before copying guest memory or forwarding to the host. Over-budget runs fail and roll back document edits; callbacks get fresh budgets. Added sandbox boundary/forwarding tests and an engine rollback regression. Percentages and hours unchanged. | From d55781e56e4b63f8cdb3f9b5728c77c45cb10d8d Mon Sep 17 00:00:00 2001 From: ahmedhosnypro Date: Fri, 16 Oct 2026 10:58:06 +0300 Subject: [PATCH 004/208] Read the portal's colour scheme as the D-Bus variant it is ReadOne replies with a variant body (signature v); deserializing it straight to u32 (signature u) is a signature mismatch that zvariant rejects, so the initial colour scheme never arrived and system mode kept its light fallback. Take the Value and read the uint32 out of it. --- apps/figcraft/src/services.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 60ae2559..fa95e123 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -174,9 +174,13 @@ fn watch_system_dark(dark: Arc) { return; }; let read = || -> zbus::Result { - conn.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(APPEARANCE, "color-scheme"))? - .body() - .deserialize() + let reply = conn.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), "ReadOne", &(APPEARANCE, "color-scheme"))?; + let body = reply.body(); + let value = body.deserialize::()?; + match value { + zbus::zvariant::Value::U32(v) => Ok(v), + _ => Ok(0), + } }; match read() { Ok(v) => store(v), From 66dd31ad9fb5b600959b32aa364e05552a4307ca Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Fri, 16 Oct 2026 11:07:33 +0200 Subject: [PATCH 005/208] M7: find fonts activated through Adobe Fonts on macOS Fonts activated through Adobe Fonts rendered in the fallback font without a warning: Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes ~/Library/Application Support/Adobe/CoreSync/plugins/livetype, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Closes #298 --- ROADMAP.md | 4 +++- crates/text/src/fontdb.rs | 16 ++++++++++++++-- crates/text/src/fontdb_tests.rs | 29 +++++++++++++++++++++++++++++ docs/cli.md | 4 ++++ docs/roadmap.md | 3 ++- 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..d6159904 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Adobe Fonts found on macOS (#298); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-16** · Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. @@ -313,6 +314,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d5ff4190..34d4775e 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -694,7 +694,11 @@ fn system_font_dirs() -> Vec { v.push("/System/Library/Fonts".into()); v.push("/Library/Fonts".into()); if let Some(h) = std::env::var_os("HOME") { - v.push(std::path::PathBuf::from(h).join("Library/Fonts")); + let h = std::path::PathBuf::from(h); + v.push(h.join("Library/Fonts")); + // Adobe Fonts activates its fonts from here (hidden files named by number), without + // installing them into a font folder. + v.push(h.join("Library/Application Support/Adobe/CoreSync/plugins/livetype")); } } #[cfg(target_os = "windows")] @@ -753,7 +757,7 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap bool { + use std::io::Read; + let mut magic = [0u8; 4]; + std::fs::File::open(path).and_then(|mut f| f.read_exact(&mut magic)).is_ok() && matches!(&magic, &[0, 1, 0, 0] | b"OTTO" | b"true" | b"ttcf") +} + /// The parts of a font file the scan reads: headers, table directories and `name` tables, at their /// offsets in a zeroed buffer of the file's length (reading whole files was most of the scan). fn index_bytes(path: &std::path::Path, len: usize) -> Option> { diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index ea762be6..636309d3 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -179,3 +179,32 @@ fn user_font_dirs_preserve_non_utf8_paths() { assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts"); assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts"); } + +#[test] +fn the_scan_reads_font_files_without_an_extension() { + // #298: Adobe Fonts keeps the fonts it activates in hidden files named by number. Any installed + // font stands in for one (none on CI); a file that isn't a font is left alone. + let Some(font) = system_font_dirs().into_iter().find_map(|d| { + std::fs::read_dir(d) + .ok()? + .flatten() + .map(|e| e.path()) + .find(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("ttf") || x.eq_ignore_ascii_case("otf"))) + }) else { + eprintln!("skipping: no installed font to stand in for an activated one"); + return; + }; + let dir = std::env::temp_dir().join(format!("figcraft-font-bare-{}", std::process::id())); + std::fs::create_dir_all(dir.join(".r")).unwrap(); + std::fs::copy(&font, dir.join(".r/.12345")).unwrap(); + std::fs::write(dir.join(".r/entitlements"), b"").unwrap(); + let mut found = BTreeMap::new(); + scan_dir(&dir, 0, &mut found); + let _ = std::fs::remove_dir_all(&dir); + let families: Vec<&String> = found.values().map(|(f, _)| f).collect(); + assert_eq!(found.values().map(|(_, v)| v.len()).sum::(), faces_in(&std::fs::read(&font).unwrap()).len(), "{families:?}"); + assert!(!families.is_empty(), "{} is read as a font", font.display()); + assert!(found.values().flat_map(|(_, v)| v).all(|p| p.path.ends_with(".r/.12345"))); + #[cfg(target_os = "macos")] + assert!(system_font_dirs().iter().any(|d| d.ends_with("Adobe/CoreSync/plugins/livetype")), "Adobe Fonts' folder is scanned"); +} diff --git a/docs/cli.md b/docs/cli.md index 9f5df40e..ccd2bb77 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -19,6 +19,10 @@ On Linux and other Unix systems except macOS, user fonts are read from `$XDG_DATA_HOME/fonts`, or `$HOME/.local/share/fonts` when `XDG_DATA_HOME` is unset, empty or relative. The legacy `$HOME/.fonts` directory is also scanned. +On macOS, fonts activated through Adobe Fonts are read where Creative Cloud keeps them +(`~/Library/Application Support/Adobe/CoreSync/plugins/livetype`), so they're available while +they're active there. Files without an extension are read when they start like a font file. + The collaboration server is a binary of its own, `figcraft-server` (`cargo build --release -p figcraft-server`): it keeps files with share links and serves their live sessions, and its `key`, `files`, `links`, `reports`, `block-ip` and `check` subcommands manage them (a server can diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e9eaec9..de8c7ff1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Adobe Fonts found on macOS (#298); community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | From ed74fc67d07c4ef27e872edac92a601d28d26ae7 Mon Sep 17 00:00:00 2001 From: Dubblbindong <48181403+Dubblbindong@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:12:54 +0200 Subject: [PATCH 006/208] feat(plugin): add saveFile permission with host-owned save dialog Plugins can hand the host a file name, MIME type and bytes through the new `save_file` import (plugin API 4, `permissions.saveFile`). The host asks the user where to save (browser download on the web), so the plugin never sees a path. Includes the SDK wrapper and the `export-names` example plugin. Co-Authored-By: Claude Sonnet 5.5 Assisted-by: Claude (Anthropic) --- README.md | 2 +- apps/figcraft-server/src/directory.rs | 1 + crates/collab/src/message.rs | 2 + crates/engine/src/cmd/file.rs | 5 +- crates/engine/src/plugin_directory.rs | 2 +- crates/engine/src/plugins.rs | 75 ++++++++- crates/engine/src/plugins_tests.rs | 126 ++++++++++++++++ crates/plugin/src/lib.rs | 2 + crates/plugin/src/manifest.rs | 11 +- crates/plugin/src/sandbox.rs | 26 +++- crates/plugin/src/save_file_tests.rs | 142 ++++++++++++++++++ crates/plugin/src/tests.rs | 2 +- crates/plugin/src/widget_tests.rs | 2 +- crates/ui-egui/locales/de.toml | 4 + crates/ui-egui/locales/en.toml | 4 + crates/ui-egui/locales/es.toml | 4 + crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/plugin_save_ui_tests.rs | 71 +++++++++ crates/ui-egui/src/plugins_ui.rs | 36 +++++ docs/plugins.md | 23 ++- plugins/Cargo.lock | 7 + plugins/examples/export-names/Cargo.toml | 15 ++ .../export-names/figcraft-plugin.json | 22 +++ plugins/examples/export-names/src/lib.rs | 55 +++++++ plugins/sdk/src/lib.rs | 16 ++ xtask/src/main.rs | 2 +- 26 files changed, 638 insertions(+), 22 deletions(-) create mode 100644 crates/plugin/src/save_file_tests.rs create mode 100644 crates/ui-egui/src/plugin_save_ui_tests.rs create mode 100644 plugins/examples/export-names/Cargo.toml create mode 100644 plugins/examples/export-names/figcraft-plugin.json create mode 100644 plugins/examples/export-names/src/lib.rs diff --git a/README.md b/README.md index 0910bf9e..c232df08 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. - **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). -- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). +- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. diff --git a/apps/figcraft-server/src/directory.rs b/apps/figcraft-server/src/directory.rs index 0443c0f4..cdf18364 100644 --- a/apps/figcraft-server/src/directory.rs +++ b/apps/figcraft-server/src/directory.rs @@ -245,6 +245,7 @@ impl Directory { }, commands: m.permissions.commands.clone(), widget: m.widget, + save_file: m.permissions.save_file, bytes: bytes.len() as u64, sha256: sha.clone(), published: now(), diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index d035042b..734e4076 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -537,6 +537,8 @@ pub struct PluginEntry { pub commands: Vec, /// It draws a widget. pub widget: bool, + /// It may offer files to save (`permissions.saveFile`). + pub save_file: bool, /// The bundle's size in bytes and its SHA-256 (hex). pub bytes: u64, pub sha256: String, diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5e27c572..97be8439 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1103,9 +1103,12 @@ fn export_path(name: &str) -> String { if parts.is_empty() { "Untitled".into() } else { parts.join("/") } } +/// Characters no file name may contain (path separators and ones file systems reject). +pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|"; + /// `name` without path separators and characters file systems reject. fn safe_file_name(name: &str) -> String { - name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect() + name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect() } /// Layers on `page` that have export settings (document order). diff --git a/crates/engine/src/plugin_directory.rs b/crates/engine/src/plugin_directory.rs index c3bf7dc4..5ddc2a81 100644 --- a/crates/engine/src/plugin_directory.rs +++ b/crates/engine/src/plugin_directory.rs @@ -281,7 +281,7 @@ pub fn specs() -> Vec { spec!( "plugin.directory.list", "Browse plugin directory", - "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}", + "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}", false, list ), diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index f1c4075e..1fab5145 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -153,6 +153,39 @@ fn writes_widget_data(params: &Value, depth: usize) -> bool { struct EngineHost<'s> { s: &'s mut Session, manifest: &'s Manifest, + /// Files offered for saving in this call. + saves: u32, +} + +/// Longest file name a plugin may suggest, in characters. +const MAX_SAVE_NAME: usize = 128; +/// Longest MIME type a plugin may name, in bytes. +const MAX_SAVE_MIME: usize = 100; +/// Largest file a plugin may offer for saving, in bytes (the sandbox's message limit). +const MAX_SAVE_BYTES: usize = 16 * 1024 * 1024; +/// Files a plugin may offer per call. +const MAX_SAVES: u32 = 1; + +/// A file name a plugin suggested, cleaned up: a bare name without folders, drive letters, `..` or +/// characters no file system takes. `None` when nothing usable is left. +pub(crate) fn sanitize_save_name(name: &str) -> Option { + let name = name.trim(); + let bad = name.is_empty() + || name.chars().count() > MAX_SAVE_NAME + || name == "." + || name.contains("..") + || name.chars().any(|c| c.is_control() || crate::cmd::file::FORBIDDEN_FILE_NAME_CHARS.contains(c)); + if bad { None } else { Some(name.to_string()) } +} + +/// `mime` when it looks like `type/subtype` in ASCII, else the generic binary type. +fn sanitize_mime(mime: &str) -> String { + let mime = mime.trim(); + let token = |t: &str| !t.is_empty() && t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-' | '_')); + match mime.split_once('/') { + Some((a, b)) if mime.len() <= MAX_SAVE_MIME && token(a) && token(b) => mime.to_ascii_lowercase(), + _ => "application/octet-stream".into(), + } } impl figcraft_plugin::Host for EngineHost<'_> { @@ -226,6 +259,27 @@ impl figcraft_plugin::Host for EngineHost<'_> { } Ok(()) } + + fn save_file(&mut self, name: &str, mime: &str, data: &[u8]) -> std::result::Result<(), String> { + if !self.manifest.permissions.save_file { + return Err(format!("{} didn't ask permission to save files (`permissions.saveFile`)", self.manifest.name)); + } + let name = sanitize_save_name(name).ok_or_else(|| { + format!("not a usable file name: use a plain name of 1–{MAX_SAVE_NAME} characters without folders or `/ \\ : .. < > \" | ? *`") + })?; + if data.len() > MAX_SAVE_BYTES { + return Err(format!("a file to save is limited to {} MB", MAX_SAVE_BYTES / (1024 * 1024))); + } + if self.saves >= MAX_SAVES { + return Err("a plugin can offer one file per run".into()); + } + self.saves += 1; + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(data); + // The app asks the user where it goes; programmatic callers drop the request. + self.s.request_ui("dialog.pluginSave", json!({"plugin": self.manifest.id, "name": name, "mime": sanitize_mime(mime), "data": data})); + Ok(()) + } } /// The installed plugin `id`. @@ -424,14 +478,21 @@ pub(crate) fn call_plugin( input: &str, ) -> std::result::Result { let mut usable = false; - s.grouped(label, |s| { - let mut host = EngineHost { s, manifest: m }; - instance.call(input, &mut host).map_err(|e| { - usable = matches!(e, PluginError::Failed(_)); - plugin_error(&m.name, e) + let ui_before = s.ui_requests.len(); + let r = s + .grouped(label, |s| { + let mut host = EngineHost { s, manifest: m, saves: 0 }; + instance.call(input, &mut host).map_err(|e| { + usable = matches!(e, PluginError::Failed(_)); + plugin_error(&m.name, e) + }) }) - }) - .map_err(|e| (e, usable)) + .map_err(|e| (e, usable)); + // A failed run opens no dialog (a file it offered to save is dropped with it). + if r.is_err() { + s.ui_requests.truncate(ui_before); + } + r } /// What `plugin.run` and `plugin.event` return. diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 102d675a..ebabc09e 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -397,3 +397,129 @@ fn a_full_undo_history_keeps_its_steps_and_tampered_manifests_are_refused() { s.prefs.plugins[0].manifest.main = "../../elsewhere.wasm".into(); assert!(matches!(s.execute("plugin.run", &json!({"id": "org.test.many"})), Err(EngineError::BadParams { .. }))); } + +/// A module that offers `files` (name, mime, data) with `save_file` in order, outputs the last +/// refusal and returns `exit`. +fn saver(files: &[(&str, &str, &[u8])], exit: i32) -> Vec { + let hex = |b: &[u8]| b.iter().map(|b| format!("\\{b:02x}")).collect::(); + let mut data = String::new(); + let mut body = String::new(); + let mut at = 0usize; + for (name, mime, bytes) in files { + let (n, m) = (at, at + name.len()); + let d = m + mime.len(); + data += &format!( + "(data (i32.const {n}) \"{}\") (data (i32.const {m}) \"{}\") (data (i32.const {d}) \"{}\")\n", + hex(name.as_bytes()), + hex(mime.as_bytes()), + hex(bytes) + ); + body += &format!( + "(local.set $st (call $save_file (i32.const {n}) (i32.const {}) (i32.const {m}) (i32.const {}) (i32.const {d}) (i32.const {}))) + (if (local.get $st) (then (call $set_output (i32.const 30000) (call $reply_read (i32.const 30000) (i32.const 500)))))\n", + name.len(), + mime.len(), + bytes.len() + ); + at = d + bytes.len(); + } + let wat = format!( + r#"(module + (import "figcraft" "save_file" (func $save_file (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32))) + (import "figcraft" "set_output" (func $set_output (param i32 i32))) + (memory (export "memory") 1) + {data} + (func (export "figcraft_run") (param $n i32) (result i32) (local $st i32) + {body} + (i32.const {exit})))"# + ); + wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}")) +} + +fn save_manifest(id: &str, save_file: bool) -> Value { + json!({"id": id, "name": "Saver", "version": "1.0.0", "main": "plugin.wasm", "api": 4, "permissions": {"access": "read", "saveFile": save_file}}) +} + +fn saves(s: &Session) -> Vec<&crate::UiRequest> { + s.ui_requests.iter().filter(|r| r.kind == "dialog.pluginSave").collect() +} + +#[test] +fn a_plugin_without_the_permission_cannot_save() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.nosave", false), &saver(&[("a.txt", "text/plain", b"hi")], 0)); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.nosave"})); + assert!(out["output"].as_str().unwrap_or_default().contains("permission"), "{out}"); + assert!(saves(&s).is_empty(), "no dialog without the permission"); +} + +#[test] +fn a_plugin_with_the_permission_requests_one_save_dialog_with_its_bytes() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.save", true), &saver(&[("out.jsx", "TEXT/Javascript", b"\x00hi\xff")], 0)); + ex(&mut s, "plugin.run", json!({"id": "org.test.save"})); + let reqs = saves(&s); + let [r] = reqs.as_slice() else { panic!("one request, got {}", reqs.len()) }; + assert_eq!(r.data["plugin"], "org.test.save"); + assert_eq!(r.data["name"], "out.jsx"); + assert_eq!(r.data["mime"], "text/javascript"); + assert_eq!(r.data["data"], b64(b"\x00hi\xff")); + // Nothing in the request is a path. + assert!(r.data.get("path").is_none()); +} + +#[test] +fn a_second_file_in_one_run_and_bad_names_are_refused() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.two", true), &saver(&[("a.txt", "text/plain", b"1"), ("b.txt", "text/plain", b"2")], 0)); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.two"})); + assert_eq!(saves(&s).len(), 1, "only the first file"); + assert!(out["output"].as_str().unwrap_or_default().contains("one file"), "{out}"); + s.ui_requests.clear(); + install(&mut s, save_manifest("org.test.bad", true), &saver(&[("../etc/passwd", "text/plain", b"1")], 0)); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.bad"})); + assert!(saves(&s).is_empty()); + assert!(out["output"].as_str().unwrap_or_default().contains("file name"), "{out}"); +} + +#[test] +fn an_odd_mime_type_becomes_generic_binary() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.mime", true), &saver(&[("a.bin", "not a type", b"1")], 0)); + ex(&mut s, "plugin.run", json!({"id": "org.test.mime"})); + assert_eq!(saves(&s)[0].data["mime"], "application/octet-stream"); +} + +#[test] +fn a_run_that_fails_afterwards_leaves_no_dialog_behind() { + let mut s = Session::new(); + install(&mut s, save_manifest("org.test.late", true), &saver(&[("a.txt", "text/plain", b"1")], 1)); + run_err(&mut s, "org.test.late"); + assert!(saves(&s).is_empty(), "a failed run opens no dialog"); +} + +#[test] +fn file_commands_stay_closed_to_plugins() { + let mut s = Session::new(); + let calls = [("file.saveAs", json!({"path": "/tmp/x.figcraft"}))]; + install(&mut s, manifest("org.test.files", "read", &[]), &script(&calls, OnRefusal::Report, "")); + let out = ex(&mut s, "plugin.run", json!({"id": "org.test.files"})); + assert!(out["output"].as_str().unwrap_or_default().contains("isn't available to plugins"), "{out}"); +} + +#[test] +fn save_names_are_plain_file_names() { + use crate::plugins::sanitize_save_name as clean; + let long = "a".repeat(129); + let max = "a".repeat(128); + // 128 two-byte characters are within the limit even though they take 256 bytes. + let wide = "é".repeat(128); + for bad in ["", " ", ".", "..", "../x", "a/b", "a\\b", "C:x", "a..b", "a\nb", "a\u{0}b", "a\u{7f}b", "a*b", long.as_str()] { + assert_eq!(clean(bad), None, "{bad:?}"); + } + assert_eq!(clean(" out.jsx ").as_deref(), Some("out.jsx")); + assert_eq!(clean(&max).as_deref(), Some(max.as_str())); + assert_eq!(clean(&wide).as_deref(), Some(wide.as_str())); + assert_eq!(clean("Entwurf – Büro.jsx").as_deref(), Some("Entwurf – Büro.jsx")); +} diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 582b0e64..80a26295 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -16,6 +16,8 @@ pub use widget::{WidgetKind, WidgetNode, WidgetRender}; #[cfg(test)] mod bundle_tests; #[cfg(test)] +mod save_file_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod v2_tests; diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 48e44e88..6af92a95 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -8,7 +8,7 @@ use serde_json::{Map, Value}; /// The manifest's file name. pub const MANIFEST_FILE: &str = "figcraft-plugin.json"; /// The plugin API version this build implements (`api` in the manifest). -pub const API_VERSION: u32 = 3; +pub const API_VERSION: u32 = 4; /// Largest manifest read. pub const MAX_MANIFEST_BYTES: usize = 256 * 1024; @@ -43,7 +43,7 @@ pub struct Manifest { /// The WebAssembly module, relative to the manifest (`plugin.wasm`). pub main: String, /// The plugin API version the plugin was written for (1 when not given; panels and settings - /// need 2, widgets 3). + /// need 2, widgets 3, `saveFile` 4). #[serde(default = "api_version")] pub api: u32, #[serde(default)] @@ -71,6 +71,10 @@ pub struct Permissions { /// command its access allows. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub commands: Vec, + /// API 4: the plugin may hand the host a file to save. The host asks the user where; the plugin + /// never sees a path. + #[serde(default, rename = "saveFile", skip_serializing_if = "std::ops::Not::not")] + pub save_file: bool, } impl Permissions { @@ -314,6 +318,9 @@ impl Manifest { if self.widget && self.api < 3 { return Err("a widget needs plugin API 3 (`\"api\": 3`)".into()); } + if self.permissions.save_file && self.api < 4 { + return Err("saveFile needs plugin API 4 (`\"api\": 4`)".into()); + } if self.commands.is_empty() && !self.widget { self.commands.push(PluginCommand { id: "run".into(), label: "Run".into(), params: Vec::new() }); } diff --git a/crates/plugin/src/sandbox.rs b/crates/plugin/src/sandbox.rs index 22eb0af0..59d66118 100644 --- a/crates/plugin/src/sandbox.rs +++ b/crates/plugin/src/sandbox.rs @@ -2,7 +2,8 @@ //! memory cap, and sees the outside world only through the host functions below. There is no WASI: //! no file system, network, clock or randomness. //! -//! # ABI (version 3; version 1 is the first seven imports, version 2 adds panels and settings) +//! # ABI (version 4; version 1 is the first seven imports, version 2 adds panels and settings, +//! version 3 widgets, version 4 `save_file`) //! //! The module exports its `memory` and `figcraft_run(input_len: i32) -> i32`. Host functions are //! imported from the module `figcraft`; strings are UTF-8 `(ptr, len)` pairs in the plugin's memory. @@ -22,6 +23,7 @@ //! | `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> i32` | Stores JSON under the key. 1: refused, the reply says why. | //! | `setting_delete` | `(key_ptr, key_len)` | Forgets a setting. | //! | `widget_render` | `(ptr, len) -> i32` | Draws the widget being called back: a [`WidgetRender`] as JSON. 1: invalid, the reply says why. | +//! | `save_file` | `(name_ptr, name_len, mime_ptr, mime_len, data_ptr, data_len) -> i32` | Offers a file to save (needs `permissions.saveFile`). The host asks the user where it goes; the plugin never gets a path. 0: accepted, 1: refused, the reply says why. | //! //! When `figcraft_run` starts, the reply holds the input JSON (`input_len` bytes): //! `{"api", "command", "params", "selection"}`, plus `event` and `values` when a panel calls the @@ -58,6 +60,7 @@ const IMPORTS: &[(&str, usize, usize)] = &[ ("setting_set", 4, 1), ("setting_delete", 2, 0), ("widget_render", 2, 1), + ("save_file", 6, 1), ]; /// What a run may use. @@ -136,6 +139,11 @@ pub trait Host { fn set_setting(&mut self, _key: &str, _value: Option<&str>) -> Result<(), String> { Err("settings aren't available here".into()) } + /// Offer the file `name` (MIME type `mime`) for saving, or say why not. The host picks the + /// destination; the plugin never learns it. + fn save_file(&mut self, _name: &str, _mime: &str, _data: &[u8]) -> Result<(), String> { + Err("saving files isn't available here".into()) + } } /// What a run asked of the panel. @@ -172,6 +180,7 @@ enum Request { SettingGet(String), SettingSet(String, String), SettingDelete(String), + SaveFile(String, String, Vec), } struct State { @@ -237,6 +246,13 @@ impl State { let _ = host.set_setting(&key, None); None } + Request::SaveFile(name, mime, data) => Some(match host.save_file(&name, &mime, &data) { + Ok(()) => 0, + Err(m) => { + self.set_reply(m.into_bytes()); + 1 + } + }), } } } @@ -575,5 +591,13 @@ fn linker(engine: &Engine) -> Result, PluginError> { Err(ask(&mut c, Request::SettingDelete(key))) }) .map_err(err)?; + l.func_wrap(IMPORT_MODULE, "save_file", |mut c: Ctx<'_>, np: i32, nl: i32, mp: i32, ml: i32, dp: i32, dl: i32| -> Result { + charge(&mut c)?; + let name = read_str(&mut c, np, nl)?; + let mime = read_str(&mut c, mp, ml)?; + let data = read_bytes(&mut c, dp, dl)?; + Err(ask(&mut c, Request::SaveFile(name, mime, data))) + }) + .map_err(err)?; Ok(l) } diff --git a/crates/plugin/src/save_file_tests.rs b/crates/plugin/src/save_file_tests.rs new file mode 100644 index 00000000..34241bb7 --- /dev/null +++ b/crates/plugin/src/save_file_tests.rs @@ -0,0 +1,142 @@ +//! `save_file` (API 4): the manifest permission, the host call with its bytes, and the limits. + +use serde_json::json; + +use crate::{Host, Instance, Limits, Manifest, PluginError}; + +/// A host that records the files offered to it (and refuses names starting with `no`). +#[derive(Default)] +struct Saves(Vec<(String, String, Vec)>); + +impl Host for Saves { + fn call(&mut self, _command: &str, _params: &str) -> Result { + Ok("{}".into()) + } + fn selection(&mut self) -> String { + "[]".into() + } + fn notify(&mut self, _message: &str) {} + fn save_file(&mut self, name: &str, mime: &str, data: &[u8]) -> Result<(), String> { + if name.starts_with("no") { + return Err("nope".into()); + } + self.0.push((name.into(), mime.into(), data.to_vec())); + Ok(()) + } +} + +/// A host that keeps the default `save_file`. +struct Plain; + +impl Host for Plain { + fn call(&mut self, _command: &str, _params: &str) -> Result { + Ok("{}".into()) + } + fn selection(&mut self) -> String { + "[]".into() + } + fn notify(&mut self, _message: &str) {} +} + +/// Memory: name at 0, MIME at 100, data at 200. `call` is the arguments of `save_file`; its status +/// is the run's status, and its reply is the output. +fn module(name: &str, mime: &str, data: &[u8], call: &str) -> Vec { + let bytes = |b: &[u8]| b.iter().map(|b| format!("\\{b:02x}")).collect::(); + let wat = format!( + r#"(module + (import "figcraft" "save_file" (func $save_file (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32))) + (import "figcraft" "set_output" (func $set_output (param i32 i32))) + (memory (export "memory") 1) + (data (i32.const 0) "{}") (data (i32.const 100) "{}") (data (i32.const 200) "{}") + (func (export "figcraft_run") (param $n i32) (result i32) (local $st i32) + (local.set $st {call}) + (call $set_output (i32.const 1000) (call $reply_read (i32.const 1000) (i32.const 100))) + (local.get $st)))"#, + bytes(name.as_bytes()), + bytes(mime.as_bytes()), + bytes(data) + ); + wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}")) +} + +fn call(name: &str, mime: &str, data: &[u8]) -> String { + format!( + "(call $save_file (i32.const 0) (i32.const {}) (i32.const 100) (i32.const {}) (i32.const 200) (i32.const {}))", + name.len(), + mime.len(), + data.len() + ) +} + +fn manifest(api: u32, save_file: bool) -> String { + json!({"id": "org.test.save", "name": "Save", "main": "p.wasm", "api": api, "permissions": {"saveFile": save_file}}).to_string() +} + +#[test] +fn the_host_gets_the_name_the_type_and_the_exact_bytes() { + let data = [0u8, 1, 2, 255, 0x80, b'x']; + let wasm = module("out.jsx", "text/plain", &data, &call("out.jsx", "text/plain", &data)); + let mut host = Saves::default(); + Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut host).unwrap(); + assert_eq!(host.0, vec![("out.jsx".to_string(), "text/plain".to_string(), data.to_vec())]); +} + +#[test] +fn a_refusal_returns_status_one_with_the_reason() { + let wasm = module("no.txt", "text/plain", b"x", &call("no.txt", "text/plain", b"x")); + // The module returns the status as its own, so the run fails with the reason as its message. + let err = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut Saves::default()).unwrap_err(); + assert_eq!(err, PluginError::Failed("nope".into())); +} + +#[test] +fn a_host_without_saving_refuses_by_default() { + let wasm = module("a.txt", "text/plain", b"x", &call("a.txt", "text/plain", b"x")); + let err = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut Plain).unwrap_err(); + assert_eq!(err, PluginError::Failed("saving files isn't available here".into())); +} + +#[test] +fn too_much_data_is_a_limit_error_and_bad_utf8_a_trap() { + let limits = Limits { max_message: 4, ..Limits::default() }; + let wasm = module("a.txt", "text/plain", b"12345", &call("a.txt", "text/plain", b"12345")); + let mut host = Saves::default(); + let err = Instance::new(&wasm, &limits).unwrap().call("{}", &mut host).unwrap_err(); + assert!(matches!(err, PluginError::Limit(_)), "{err:?}"); + assert!(host.0.is_empty()); + + // The name points at a lone 0xff byte: not UTF-8. + let wasm = module( + "a", + "text/plain", + &[0xff], + "(call $save_file (i32.const 200) (i32.const 1) (i32.const 100) (i32.const 1) (i32.const 200) (i32.const 1))", + ); + let err = Instance::new(&wasm, &Limits::default()).unwrap().call("{}", &mut host).unwrap_err(); + assert!(matches!(err, PluginError::Trap(_)), "{err:?}"); + assert!(host.0.is_empty()); +} + +#[test] +fn a_wrong_signature_is_rejected() { + let wat = r#"(module (import "figcraft" "save_file" (func (param i32 i32) (result i32))) + (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#; + let wasm = wat::parse_str(wat).unwrap(); + assert!(matches!(crate::check_module(&wasm, &Limits::default()), Err(PluginError::InvalidModule(m)) if m.contains("wrong signature"))); +} + +#[test] +fn the_permission_needs_api_four_and_survives_a_round_trip() { + let err = Manifest::parse(&manifest(3, true)).unwrap_err(); + assert!(err.contains("saveFile needs plugin API 4"), "{err}"); + let m = Manifest::parse(&manifest(4, true)).unwrap(); + assert!(m.permissions.save_file); + let again = Manifest::parse(&serde_json::to_string(&m).unwrap()).unwrap(); + assert_eq!(m, again); + assert!(serde_json::to_string(&m).unwrap().contains("\"saveFile\":true")); + // Older manifests stay valid and don't grow the key. + let old = Manifest::parse(&manifest(3, false)).unwrap(); + assert!(!old.permissions.save_file); + assert!(!serde_json::to_string(&old).unwrap().contains("saveFile")); +} diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index 5d094ab4..27d7ac23 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -441,7 +441,7 @@ fn bad_manifests_are_refused() { json!({"main": "C:\\plugin.wasm"}), json!({"main": "plugin.js"}), json!({"main": "a//b.wasm"}), - json!({"api": 4}), + json!({"api": 5}), json!({"api": 0}), json!({"permissions": {"access": "root"}}), json!({"permissions": {"commands": ["*.x"]}}), diff --git a/crates/plugin/src/widget_tests.rs b/crates/plugin/src/widget_tests.rs index 0d4ec2df..35418866 100644 --- a/crates/plugin/src/widget_tests.rs +++ b/crates/plugin/src/widget_tests.rs @@ -110,7 +110,7 @@ fn widgets_need_api_3() { assert!(m(2).unwrap_err().contains("API 3")); let ok = m(3).unwrap(); assert!(ok.widget && ok.commands.is_empty(), "a widget-only plugin has no menu command"); - assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 4})).is_err()); + assert!(Manifest::from_value(&json!({"id": "w", "name": "W", "main": "w.wasm", "api": 5})).is_err()); let v2 = Manifest::from_value(&json!({"id": "p", "name": "P", "main": "p.wasm", "api": 2})).unwrap(); assert!(!v2.widget && v2.commands.len() == 1, "v2 manifests are unchanged"); } diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index cd1e3482..6d8c9128 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -305,6 +305,7 @@ status = "draft — needs native review" "Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." "Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}" +"Couldn't save the file" = "Die Datei konnte nicht gespeichert werden" "Count" = "Anzahl" "Create" = "Erstellen" "Create a variable collection first" = "Erstellen Sie zuerst eine Variablensammlung" @@ -1082,6 +1083,7 @@ status = "draft — needs native review" "Save" = "Speichern" "Save as" = "Speichern unter" "Save as preset…" = "Als Vorgabe speichern…" +"Save file" = "Datei speichern" "Save files automatically" = "Dateien automatisch speichern" "Save version {key}" = "Version speichern {key}" "Saved" = "Gespeichert" @@ -1435,6 +1437,7 @@ status = "draft — needs native review" "call any command its access allows" = "jeden Befehl aufrufen, den sein Zugriff erlaubt" "call only: {commands}" = "nur aufrufen: {commands}" "can edit the document" = "kann das Dokument bearbeiten" +"can save files" = "kann Dateien speichern" "crop marks" = "Schnittmarken" "development: {path}" = "Entwicklung: {path}" "e.g. count + 1" = "z. B. count + 1" @@ -1452,6 +1455,7 @@ status = "draft — needs native review" "read and edit the document" = "das Dokument lesen und bearbeiten" "read the document" = "das Dokument lesen" "reads the document" = "liest das Dokument" +"save files you choose" = "Dateien an einem von Ihnen gewählten Ort speichern" "the plugin" = "das Plugin" "this session only" = "nur diese Sitzung" "{instances} in {files}" = "{instances} in {files}" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..91bf2ae1 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -314,6 +314,7 @@ name = "English" "Couldn't download the variables" = "Couldn't download the variables" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" +"Couldn't save the file" = "Couldn't save the file" "Count" = "Count" "Create" = "Create" "Create a variable collection first" = "Create a variable collection first" @@ -1124,6 +1125,7 @@ name = "English" "Rulers" = "Rulers" "Run" = "Run" "Run {label}" = "Run {label}" +"Save file" = "Save file" "Save files automatically" = "Save files automatically" "Search templates" = "Search templates" "See all templates" = "See all templates" @@ -1506,6 +1508,7 @@ name = "English" "call any command its access allows" = "call any command its access allows" "call only: {commands}" = "call only: {commands}" "can edit the document" = "can edit the document" +"can save files" = "can save files" "crop marks" = "crop marks" "development: {path}" = "development: {path}" "e.g. count + 1" = "e.g. count + 1" @@ -1524,6 +1527,7 @@ name = "English" "read and edit the document" = "read and edit the document" "read the document" = "read the document" "reads the document" = "reads the document" +"save files you choose" = "save files you choose" "the plugin" = "the plugin" "this session only" = "this session only" "yesterday" = "yesterday" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..f551146c 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -315,6 +315,7 @@ status = "draft — needs native review" "Corner smoothing" = "Suavizado de esquinas" "Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda." "Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}" +"Couldn't save the file" = "No se pudo guardar el archivo" "Count" = "Número" "Create" = "Crear" "Create a variable collection first" = "Crea primero una colección de variables" @@ -1125,6 +1126,7 @@ status = "draft — needs native review" "Rulers" = "Reglas" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" +"Save file" = "Guardar archivo" "Save files automatically" = "Guardar archivos automáticamente" "Search templates" = "Buscar plantillas" "See all templates" = "Ver todas las plantillas" @@ -1507,6 +1509,7 @@ status = "draft — needs native review" "call any command its access allows" = "llamar a cualquier comando que su acceso permita" "call only: {commands}" = "llamar solo a: {commands}" "can edit the document" = "puede editar el documento" +"can save files" = "puede guardar archivos" "crop marks" = "marcas de corte" "development: {path}" = "desarrollo: {path}" "e.g. count + 1" = "p. ej. count + 1" @@ -1525,6 +1528,7 @@ status = "draft — needs native review" "read and edit the document" = "leer y editar el documento" "read the document" = "leer el documento" "reads the document" = "lee el documento" +"save files you choose" = "guardar archivos donde tú elijas" "the plugin" = "el plugin" "this session only" = "solo esta sesión" "yesterday" = "ayer" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..3ee02891 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -148,6 +148,8 @@ mod perf_tests; #[cfg(test)] mod plugin_panel_tests; #[cfg(test)] +mod plugin_save_ui_tests; +#[cfg(test)] mod plugins_browse_tests; #[cfg(test)] mod popup_width_tests; @@ -891,6 +893,7 @@ impl FigApp { } "dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")), "dialog.plugins" => self.ui.plugins_window = true, + "dialog.pluginSave" => plugins_ui::save_file(self, &r.data), "dialog.cursorChat" => live::open_chat(self), "window.new" | "window.focus" | "window.fullscreen" => self.window_requests.push(r), _ => {} diff --git a/crates/ui-egui/src/plugin_save_ui_tests.rs b/crates/ui-egui/src/plugin_save_ui_tests.rs new file mode 100644 index 00000000..6302e2d2 --- /dev/null +++ b/crates/ui-egui/src/plugin_save_ui_tests.rs @@ -0,0 +1,71 @@ +//! A plugin's `dialog.pluginSave` request: the browser downloads the file, a platform with save +//! dialogs asks the user (and writes nothing when they cancel). + +use std::sync::{Arc, Mutex}; + +use base64::Engine as _; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, NoServices, Services}; + +type Downloads = Arc)>>>; + +/// Services that save by downloads and keep them (`deliver` false: the browser refused). +struct Browser { + got: Downloads, + deliver: bool, +} + +impl Services for Browser { + fn downloads(&self) -> bool { + true + } + fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + self.got.lock().unwrap().push((name.to_string(), bytes.to_vec())); + self.deliver + } +} + +fn request(app: &mut FigApp, name: &str, data: &str) { + app.session.request_ui("dialog.pluginSave", json!({"plugin": "org.test.save", "name": name, "mime": "text/plain", "data": data})); + app.handle_ui_requests(); +} + +fn toast(app: &FigApp) -> String { + app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +fn b64(bytes: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +#[test] +fn the_browser_downloads_the_file_with_its_exact_bytes() { + let got = Downloads::default(); + let mut app = FigApp::new(Session::new(), Box::new(Browser { got: got.clone(), deliver: true })); + request(&mut app, "out.jsx", &b64(b"\x00hello\xff")); + let got = got.lock().unwrap(); + assert_eq!(got.as_slice(), [("out.jsx".to_string(), b"\x00hello\xff".to_vec())]); + assert!(toast(&app).contains("out.jsx"), "{}", toast(&app)); +} + +#[test] +fn a_refused_download_and_broken_data_say_so() { + let got = Downloads::default(); + let mut app = FigApp::new(Session::new(), Box::new(Browser { got: got.clone(), deliver: false })); + request(&mut app, "out.jsx", &b64(b"x")); + assert_eq!(toast(&app), "Couldn't save the file"); + app.ui.toast = None; + request(&mut app, "out.jsx", "***not base64***"); + assert_eq!(toast(&app), "Couldn't save the file"); + assert_eq!(got.lock().unwrap().len(), 1, "broken data is never handed over"); +} + +#[test] +fn a_cancelled_save_dialog_writes_nothing() { + // `NoServices` has no downloads and answers the save dialog with "cancelled". + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + request(&mut app, "out.jsx", &b64(b"x")); + assert!(app.ui.toast.is_none()); +} diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 481b446c..ed00604d 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -110,6 +110,36 @@ pub fn start(app: &mut FigApp, id: &str, command: &str) { } } +/// A plugin offered a file to save (`dialog.pluginSave`: `name`, `data` as base64): the user picks +/// where it goes in FigCraft's own save dialog, or the browser downloads it. The plugin never learns +/// the path. +pub(crate) fn save_file(app: &mut FigApp, data: &Value) { + use base64::Engine as _; + let name = data["name"].as_str().unwrap_or_default().to_string(); + let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(data["data"].as_str().unwrap_or_default()) else { + app.toast(tr("Couldn't save the file")); + return; + }; + if app.services.downloads() { + if app.services.download(&name, &bytes) { + app.toast(trf("Saved {name}", &[("name", &name)])); + } else { + app.toast(tr("Couldn't save the file")); + } + return; + } + let ext = std::path::Path::new(&name).extension().and_then(|e| e.to_str()).unwrap_or_default().to_string(); + let exts: Vec<&str> = if ext.is_empty() { Vec::new() } else { vec![ext.as_str()] }; + let shown = name.clone(); + app.pick_save(tr("Save file"), &name, &exts, move |app, path| { + let file = path.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or(shown); + match std::fs::write(&path, &bytes) { + Ok(()) => app.toast(trf("Saved {name}", &[("name", &file)])), + Err(_) => app.toast(tr("Couldn't save the file")), + } + }); +} + /// Run `plugin.run` or `plugin.event`; a text output is shown as a toast unless the plugin showed /// a message. fn run(app: &mut FigApp, cmd: &str, params: Value) { @@ -447,6 +477,9 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { if e.widget { asks.push(tr("put a widget on the canvas").to_string()); } + if e.save_file { + asks.push(tr("save files you choose").to_string()); + } for a in asks { ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(regular(11.0)).color(t.text)).wrap()); } @@ -501,6 +534,9 @@ fn summary(p: &InstalledPlugin) -> String { parts.push(m.version.clone()); } parts.push(access.to_string()); + if m.permissions.save_file { + parts.push(tr("can save files").to_string()); + } parts.push(match (&p.source, &p.path) { (Some(server), _) => trf("from {server}", &[("server", server)]), (None, Some(path)) => trf("development: {path}", &[("path", path)]), diff --git a/docs/plugins.md b/docs/plugins.md index 6f23e6ec..f5182930 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -135,7 +135,7 @@ plugins: import them in the script. | `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. | | `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | | `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. | -| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | +| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | | `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. | | `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. | | `plugin.directory.unpublish` | `{id, version?, server}` | Removes one version (default: every version) from the directory; needs the key. Returns `{id, removed}`. | @@ -224,7 +224,8 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p | `version`, `description` | Optional text. A plugin published to a [directory](#plugin-directories) needs a `version` of one to four numbers (`1.2.0`), each version newer than the last. | | `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | -| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings) or `3` (widgets). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels or widgets fails clearly on an older FigCraft. | +| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | +| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. The install confirmation lists it. | | `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | | `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. | @@ -236,6 +237,9 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p Whatever its manifest says, FigCraft refuses these calls (the plugin gets the refusal as an error it can handle): +- read or write files: a plugin never gets a path or the disk. With `permissions.saveFile` it can + only hand FigCraft a file (one per run, 16 MB at most, a plain file name) for the user to save + through FigCraft's own dialog; the user can cancel it; - files and the app: `file.*`, `app.*`, `pref.*`, `help.*`, `library.add`, `libraries.*`, `image.place` with a `path` (pass the image as base64 `data` instead), `view.theme*`; - other documents and the version history: `doc.*` and `version.*` (except `doc.list` and @@ -262,9 +266,10 @@ allocation or copy. This also applies when the configured per-call overhead is z read is charged for the bytes actually returned, not the requested buffer size. `calls` counts every ABI import, including calls which only query the reply length. -## ABI (version 3) +## ABI (version 4) -Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2 +Version 2 adds panels and settings to version 1, version 3 adds widgets and version 4 adds +`save_file`; a version 1, 2 or 3 module runs unchanged and gets the input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values are `i32`. @@ -311,6 +316,7 @@ included, is refused when the plugin is imported): | `setting_get` | `(key_ptr, key_len) -> status` | API 2. `0`: the reply holds the setting's JSON. `1`: there is none. | | `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> status` | API 2. Stores JSON under the key, for this plugin only, kept with the preferences. `1`: refused (not JSON, a bad key, over 64 KB), the reply says why. | | `setting_delete` | `(key_ptr, key_len)` | API 2. Forgets a setting. | +| `save_file` | `(name_ptr, name_len, mime_ptr, mime_len, data_ptr, data_len) -> status` | API 4, needs `permissions.saveFile`. Offers a file to save; FigCraft asks the user where it goes once the run succeeds. `1`: refused (no permission, a bad file name, over 16 MB, a second file in one run), the reply says why. | | `widget_render` | `(ptr, len) -> status` | API 3. Draws the widget being inserted or clicked (the widget description below, as JSON). `1`: the description is invalid, the reply says why. A widget call that doesn't draw keeps the widget as it was (inserting one must draw it). | A pointer or length outside the plugin's memory traps, which fails the run. Host functions can only @@ -369,7 +375,7 @@ image sizes) and refuses it if anything is off. `plugins/` in the repository is a workspace of its own: `plugins/sdk` (the crate `figcraft-plugin-sdk`, `no_std` with `alloc`) wraps the ABI with serde_json, and -`plugins/examples` has three plugins: +`plugins/examples` has four plugins: - **Tidy names** (`tidy-names`): cleans up the selected layers' names (extra spaces, the "copy" left by duplicating) and optionally their letter case, inside the selection too. Two commands: one @@ -382,6 +388,9 @@ image sizes) and refuses it if anything is off. hex colours in the parameters form. - **Counter** (`counter`, API 3): a widget, a count between − and + buttons; each click changes the count kept in the widget's state. It only reads the document. +- **Export names** (`export-names`, API 4): saves the names of the selected layers (optionally the + layers inside them too) as `layer-names.txt`. It reads the document and offers the file with + `save_file`; FigCraft asks where it goes. `cargo xtask plugins` (part of `cargo xtask wasm` and `cargo xtask ci`; it needs the `wasm32-unknown-unknown` target: `rustup target add wasm32-unknown-unknown`) checks, tests and @@ -438,7 +447,9 @@ panel calls back: `control`, `value` and every field's `values`); returning `Err rolls it back. With `"api": 2` in the manifest: `show_ui(&json!({...}))` and `close_ui()` for the panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. With `"api": 3` and `"widget": true`: `input.widget` (`id`, `state`, `target`) when the widget is -inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. Outside +inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. With +`"api": 4` and `"permissions": {"saveFile": true}`: `save_file(name, mime, &bytes)` offers a file; +FigCraft asks the user where it goes and the plugin never learns a path. Outside WebAssembly every host call fails, so the plugin's own logic can be unit-tested with `cargo test`. Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`, diff --git a/plugins/Cargo.lock b/plugins/Cargo.lock index 7738ceb5..87244377 100644 --- a/plugins/Cargo.lock +++ b/plugins/Cargo.lock @@ -9,6 +9,13 @@ dependencies = [ "figcraft-plugin-sdk", ] +[[package]] +name = "export-names" +version = "0.1.0" +dependencies = [ + "figcraft-plugin-sdk", +] + [[package]] name = "figcraft-plugin-sdk" version = "0.1.0" diff --git a/plugins/examples/export-names/Cargo.toml b/plugins/examples/export-names/Cargo.toml new file mode 100644 index 00000000..35b7eb81 --- /dev/null +++ b/plugins/examples/export-names/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "export-names" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] + +[lints] +workspace = true + +[dependencies] +figcraft-plugin-sdk = { workspace = true } diff --git a/plugins/examples/export-names/figcraft-plugin.json b/plugins/examples/export-names/figcraft-plugin.json new file mode 100644 index 00000000..ab8f943f --- /dev/null +++ b/plugins/examples/export-names/figcraft-plugin.json @@ -0,0 +1,22 @@ +{ + "id": "org.figcraft.examples.export-names", + "name": "Export names", + "version": "1.0.0", + "description": "Saves the names of the selected layers as a text file, one per line.", + "main": "export_names.wasm", + "api": 4, + "permissions": { + "access": "read", + "commands": ["node.get"], + "saveFile": true + }, + "commands": [ + { + "id": "export", + "label": "Export names…", + "params": [ + { "name": "children", "label": "Include layers inside", "type": "boolean", "default": true } + ] + } + ] +} diff --git a/plugins/examples/export-names/src/lib.rs b/plugins/examples/export-names/src/lib.rs new file mode 100644 index 00000000..4525a39c --- /dev/null +++ b/plugins/examples/export-names/src/lib.rs @@ -0,0 +1,55 @@ +//! Export names: saves the names of the selected layers (optionally the layers inside them too) as +//! the text file `layer-names.txt`, through the host's own Save dialog (`save_file`). + +use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, notify, save_file}; + +/// Layers looked at in one run, at most. +const MAX_LAYERS: usize = 10_000; + +/// The names as the file's text: one per line, a line break at the end, line breaks inside a name +/// turned into spaces. +fn names_text(names: &[String]) -> String { + let mut text = String::new(); + for name in names { + text.extend(name.chars().map(|c| if c == '\n' || c == '\r' { ' ' } else { c })); + text.push('\n'); + } + text +} + +fn run(input: Input) -> Result { + if input.selection.is_empty() { + return Err("Select the layers to export first".into()); + } + let inside = input.bool("children").unwrap_or(true); + // Depth first, in layer order: the stack takes the children reversed. + let mut stack: Vec = input.selection.iter().rev().copied().collect(); + let mut names = Vec::new(); + while let Some(id) = stack.pop() { + if names.len() >= MAX_LAYERS { + break; + } + let node = call("node.get", &json!({"id": id}))?; + names.push(node["name"].as_str().unwrap_or_default().to_string()); + if inside && let Some(children) = node["children"].as_array() { + stack.extend(children.iter().rev().filter_map(|c| c["id"].as_u64())); + } + } + save_file("layer-names.txt", "text/plain", names_text(&names).as_bytes())?; + notify(&format!("Exporting {} names", names.len())); + Ok(json!({"names": names.len()})) +} + +entry!(run); + +#[cfg(test)] +mod tests { + use super::names_text; + + #[test] + fn names_become_lines() { + let names = ["Card".to_string(), "Title\nline".to_string(), String::new()]; + assert_eq!(names_text(&names), "Card\nTitle line\n\n"); + assert_eq!(names_text(&[]), ""); + } +} diff --git a/plugins/sdk/src/lib.rs b/plugins/sdk/src/lib.rs index 967af788..65f70f8d 100644 --- a/plugins/sdk/src/lib.rs +++ b/plugins/sdk/src/lib.rs @@ -187,6 +187,14 @@ pub fn delete_setting(key: &str) { host::setting_delete(key.as_bytes()); } +/// Offer a file to save: a suggested file `name`, its MIME type and the bytes. FigCraft asks the +/// user where it goes (a download in the browser) once the run succeeds; the plugin never learns +/// a path. Needs `"api": 4` and `permissions.saveFile` in the manifest. A refusal (no permission, +/// a bad name, too much data, a second file in one run) is an error. +pub fn save_file(name: &str, mime: &str, data: &[u8]) -> Result<()> { + if host::save_file(name.as_bytes(), mime.as_bytes(), data) == 0 { Ok(()) } else { Err(String::from_utf8_lossy(&host::reply()).into_owned()) } +} + /// The body of `figcraft_run` (use [`entry!`]): read the input, run `f`, hand back its output. pub fn run_plugin(input_len: i32, f: fn(Input) -> Result) -> i32 { let input = host::reply(); @@ -238,6 +246,7 @@ mod host { pub fn setting_set(key: *const u8, key_len: usize, value: *const u8, value_len: usize) -> i32; pub fn setting_delete(key: *const u8, key_len: usize); pub fn widget_render(ptr: *const u8, len: usize) -> i32; + pub fn save_file(name: *const u8, name_len: usize, mime: *const u8, mime_len: usize, data: *const u8, data_len: usize) -> i32; } } @@ -283,6 +292,9 @@ mod host { pub fn widget_render(s: &[u8]) -> i32 { unsafe { sys::widget_render(s.as_ptr(), s.len()) } } + pub fn save_file(name: &[u8], mime: &[u8], data: &[u8]) -> i32 { + unsafe { sys::save_file(name.as_ptr(), name.len(), mime.as_ptr(), mime.len(), data.as_ptr(), data.len()) } + } } /// Outside WebAssembly (a plugin's own unit tests) every host call fails. @@ -316,6 +328,9 @@ mod host { pub fn widget_render(_s: &[u8]) -> i32 { 1 } + pub fn save_file(_name: &[u8], _mime: &[u8], _data: &[u8]) -> i32 { + 1 + } } #[cfg(test)] @@ -339,6 +354,7 @@ mod tests { assert_eq!((e.control.as_str(), &e.value, &e.values), ("go", &Value::Null, &json!({"n": 2}))); assert!(show_ui(&json!({})).is_err() && setting("k").is_none() && set_setting("k", &json!(1)).is_err()); assert!(render_widget(&json!({})).is_err() && i.widget.is_none()); + assert!(save_file("a.txt", "text/plain", b"x").is_err()); let w = Input::parse(r#"{"command":"widget","widget":{"id":9,"state":{"n":1},"event":{"target":"inc"}}}"#) .unwrap_or_default() .widget diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e3f00e8b..bd863dbe 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -81,7 +81,7 @@ fn wasm() -> bool { /// The example plugins in `plugins/examples` (each folder's name is its package; its module is /// `.wasm`). -const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter"]; +const EXAMPLE_PLUGINS: &[&str] = &["tidy-names", "swatch-grid", "counter", "export-names"]; /// The plugin workspace: fmt, clippy and build for wasm32, host tests, then each example's manifest /// and module side by side in `/plugins/dist//`. From 9397d2ca0300bb1672eecc658d32f78ff2d8aafd Mon Sep 17 00:00:00 2001 From: timrrrr <54307723+timrrrr@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:16:26 +0300 Subject: [PATCH 007/208] M1: crop image fills during Command/Ctrl resize (#702) --- ROADMAP.md | 4 +- contributors/people.toml | 3 + crates/engine/src/tools.rs | 33 ++++ .../engine/src/transform_behaviour_tests.rs | 181 ++++++++++++++++++ docs/ui-parity.md | 4 +- 5 files changed, 223 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..82d48ca2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command/Ctrl image crop; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-16** · Command/Ctrl-resize with the Move tool crops image fills on a single non-text layer outside auto layout, keeping their scale and position and the original image bytes. Fill/Fit/Crop, rotated layers and image fills, undo and native save/reopen are covered by regression tests. Text, auto-layout contexts, Tile and the Scale tool keep their existing behaviour. Estimates unchanged. + - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. - **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. - **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged. diff --git a/contributors/people.toml b/contributors/people.toml index bc8cf71b..1d6cd8b7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -44,3 +44,6 @@ sync_github_name = true [people.volkovskey] real_name = "Andrii Volkov" + +[people.timrrrr] +display_name = "Timur" diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 3d3b9acb..fea703b9 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; +use figcraft_color::{ImageScaleMode, PaintKind}; use figcraft_doc::geometry::select_target; use figcraft_doc::*; use figcraft_geom::{Affine, Point, Rect, Vec2}; @@ -1558,6 +1559,38 @@ impl Session { if flip_y { t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); } + // Text reflow and auto layout can recompute this box after the preview. + let crop_images = ev.cmd + && !matches!(doc.get(*id)?.kind, NodeKind::Text(_)) + && !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some())); + if crop_images { + // Crop the layer's image fills without moving their pixels on the page. + // Work from the interaction snapshot, just like the geometry on every move. + let n = doc.get(*id)?; + let (w, h) = (n.width.max(1e-6), n.height.max(1e-6)); + let to_old = + Affine::scale_non_uniform(1.0 / w, 1.0 / h) * world.inverse() * t * Affine::scale_non_uniform(nw.max(0.01), nh.max(0.01)); + let mut fills = n.fills.clone(); + for paint in &mut fills { + let PaintKind::Image(img) = &mut paint.kind else { continue }; + // A repeating image cannot be represented by a single Crop placement. + if img.scale_mode == ImageScaleMode::Tile { + continue; + } + let Some(data) = doc.images.get(&img.image) else { continue }; + let (iw, ih) = (f64::from(data.width), f64::from(data.height)); + let crop = if img.scale_mode == ImageScaleMode::Crop { + img.effective_crop(iw, ih, w, h) + } else { + let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0)); + let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; + [w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)] + }; + img.crop = Some((Affine::new(crop) * to_old).as_coeffs()); + img.scale_mode = ImageScaleMode::Crop; + } + doc.get_mut(*id)?.fills = fills; + } doc.set_world_transform(*id, t)?; if kept.is_empty() { crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?; diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index 47270a42..250b7da1 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -98,6 +98,187 @@ const ALT: Mods = Mods { shift: false, alt: true, cmd: false }; const BOTH: Mods = Mods { shift: true, alt: true, cmd: false }; const CMD: Mods = Mods { shift: false, alt: false, cmd: true }; +/// An asymmetric synthetic image: every column is different, so recentering is visible. +fn image_layer(s: &mut Session) -> NodeId { + use base64::Engine as _; + let image = image::RgbaImage::from_fn(200, 100, |x, y| image::Rgba([x as u8, y as u8, 120, 255])); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + id(&ex( + s, + "file.import", + json!({"data": base64::engine::general_purpose::STANDARD.encode(bytes.into_inner()), "name": "crop.png", "x": 0, "y": 0}), + )) +} + +#[test] +fn command_resize_crops_image_pixels_and_round_trips() { + let mut s = session(); + let layer = image_layer(&mut s); + let original = s.doc().unwrap().clone(); + let before = figcraft_render::render_node(&original, layer, 1.0).unwrap(); + let steps = undo_steps(&s); + // Public crop documentation: Command (macOS) / Ctrl (elsewhere) quickly crops an image. + // Moving the right edge must keep the left half's pixels, not recenter or scale them. + s.pointer(&ev(PointerKind::Down, Point::new(200.0, 50.0), CMD)).unwrap(); + for x in [150.0, 100.0] { + s.pointer(&ev(PointerKind::Move, Point::new(x, 50.0), CMD)).unwrap(); + } + s.pointer(&ev(PointerKind::Up, Point::new(100.0, 50.0), CMD)).unwrap(); + assert_eq!(size(&s, layer), (100.0, 100.0)); + let after = figcraft_render::render_node(s.doc().unwrap(), layer, 1.0).unwrap(); + for y in 0..100usize { + assert_eq!(&after.data()[y * 400..(y + 1) * 400], &before.data()[y * 800..y * 800 + 400], "row {y}: the image stays put"); + } + assert_eq!(s.doc().unwrap().images, original.images, "cropping keeps the source bytes"); + assert_eq!(undo_steps(&s), steps + 1); + let cropped = s.doc().unwrap().clone(); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap(), &original); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc().unwrap(), &cropped); + let path = std::env::temp_dir().join(format!("figcraft-command-crop-{}.figcraft", std::process::id())); + ex(&mut s, "file.saveAs", json!({"path": path})); + let mut reopened = session(); + ex(&mut reopened, "file.open", json!({"path": path})); + assert_eq!(reopened.doc().unwrap().node(layer), cropped.node(layer)); + assert_eq!(reopened.doc().unwrap().images, cropped.images); + assert_eq!(figcraft_render::render_node(reopened.doc().unwrap(), layer, 1.0).unwrap().data(), after.data()); + std::fs::remove_file(path).unwrap(); +} + +#[test] +fn command_crop_keeps_image_placement_in_the_layers_axes() { + use figcraft_doc::color::{ImageScaleMode, PaintKind}; + use figcraft_geom::Affine; + // Each row exercises a distinct placement: Fill, Fit, and an existing affine Crop. + // Geometry follows the existing resize rules; image pixels stay fixed in page space. + let rows = [ + ("fill", None, 0.0, Point::new(0.0, 50.0), Point::new(40.0, 50.0), CMD, (40.0, 0.0, 160.0, 150.0)), + ("fit", None, 30.0, Point::new(100.0, 0.0), Point::new(100.0, 20.0), CMD, (0.0, 20.0, 200.0, 130.0)), + ("fill", None, 0.0, Point::new(0.0, 50.0), Point::new(240.0, 50.0), CMD, (240.0, 0.0, 40.0, 150.0)), + ( + "crop", + Some([0.8, 0.1, -0.2, 0.9, 0.1, 0.05]), + 30.0, + Point::new(200.0, 150.0), + Point::new(150.0, 112.5), + Mods { alt: true, ..CMD }, + (50.0, 37.5, 100.0, 75.0), + ), + ]; + for (mode, crop, angle, from, to, mods, (x, y, w, h)) in rows { + let mut s = session(); + let layer = image_layer(&mut s); + let mut fills = s.doc().unwrap().node(layer).unwrap().fills.clone(); + let PaintKind::Image(img) = &mut fills[0].kind else { panic!("image fill") }; + img.scale_mode = match mode { + "fit" => ImageScaleMode::Fit, + "crop" => ImageScaleMode::Crop, + _ => ImageScaleMode::Fill, + }; + img.crop = crop; + set(&mut s, &[layer], json!({"height": 150, "rotation": angle, "fills": fills})); + let doc = s.doc().unwrap(); + let world = doc.world_transform(layer); + let n = doc.node(layer).unwrap(); + let old_crop = crop.unwrap_or(if mode == "fit" { [1.0, 0.0, 0.0, 1.5, 0.0, -0.25] } else { [2.0 / 3.0, 0.0, 0.0, 1.0, 1.0 / 6.0, 0.0] }); + let old_mapping = Affine::new(old_crop) * Affine::scale_non_uniform(1.0 / n.width, 1.0 / n.height) * world.inverse(); + drag(&mut s, world * from, world * to, mods); + let doc = s.doc().unwrap(); + let n = doc.node(layer).unwrap(); + assert!(near(n.width, w) && near(n.height, h), "{mode}: crop box size"); + assert!((doc.world_transform(layer) * Point::ZERO - world * Point::new(x, y)).hypot() < 1e-6, "{mode}: crop box anchor"); + let PaintKind::Image(img) = &n.fills[0].kind else { panic!("image fill") }; + assert_eq!(img.scale_mode, ImageScaleMode::Crop, "{mode}"); + let mapping = + Affine::new(img.crop.unwrap()) * Affine::scale_non_uniform(1.0 / n.width, 1.0 / n.height) * doc.world_transform(layer).inverse(); + for p in [Point::ZERO, Point::new(80.0, 60.0), Point::new(170.0, 90.0)] { + assert!((mapping * p - old_mapping * p).hypot() < 1e-6, "{mode}: same page point samples the same source pixel"); + } + } +} + +#[test] +fn ordinary_resize_and_scale_keep_image_fill_settings() { + for (tool, cmd, ctrl) in [("move", false, false), ("move", false, true), ("scale", true, false)] { + let mut s = session(); + let layer = image_layer(&mut s); + let fills = s.doc().unwrap().node(layer).unwrap().fills.clone(); + ex(&mut s, "tool.set", json!({"tool": tool})); + let to = Point::new(100.0, 50.0); + for (kind, p) in [(PointerKind::Down, Point::new(200.0, 100.0)), (PointerKind::Move, to), (PointerKind::Up, to)] { + let mut event = ev(kind, p, Mods { cmd, ..NONE }); + event.ctrl = ctrl; + s.pointer(&event).unwrap(); + } + assert_eq!(s.doc().unwrap().node(layer).unwrap().fills, fills, "{tool}, cmd={cmd}, ctrl={ctrl}"); + assert_eq!(size(&s, layer), (100.0, 50.0)); + } +} + +#[test] +fn command_crop_preserves_each_image_fill_and_leaves_tiles() { + use figcraft_doc::color::{Color, ImageScaleMode, Paint, PaintKind}; + let mut s = session(); + let layer = image_layer(&mut s); + let image = s.doc().unwrap().node(layer).unwrap().fills[0].clone(); + let mut rotated = image.clone(); + rotated.opacity = 0.4; + let PaintKind::Image(img) = &mut rotated.kind else { panic!("image fill") }; + img.rotation = 1; + let mut tile = image.clone(); + let PaintKind::Image(img) = &mut tile.kind else { panic!("image fill") }; + img.scale_mode = ImageScaleMode::Tile; + let mut expected = vec![Paint::solid(Color::from_rgba8(20, 40, 60, 255)), rotated, image, tile]; + set(&mut s, &[layer], json!({"fills": expected})); + drag(&mut s, Point::new(200.0, 50.0), Point::new(100.0, 50.0), CMD); + for (i, crop) in [(1, [0.5, 0.0, 0.0, 0.25, 0.0, 0.375]), (2, [0.5, 0.0, 0.0, 1.0, 0.0, 0.0])] { + let PaintKind::Image(img) = &mut expected[i].kind else { panic!("image fill") }; + img.scale_mode = ImageScaleMode::Crop; + img.crop = Some(crop); + } + assert_eq!(s.doc().unwrap().node(layer).unwrap().fills, expected); +} + +#[test] +fn command_resize_leaves_fills_when_text_or_auto_layout_recomputes_the_box() { + for case in ["text", "child", "frame"] { + let mut s = session(); + let image = image_layer(&mut s); + let fills = s.doc().unwrap().node(image).unwrap().fills.clone(); + let layer = match case { + "text" => id(&ex( + &mut s, + "text.create", + json!({"text": "hello world hello world hello world", "props": { + "width": 200, "height": 100, "fontSize": 24, "textAutoResize": "none", "fills": fills + }}), + )), + _ => { + let frame = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100}))); + set(&mut s, &[frame], json!({"layout": "horizontal", "padding": 20})); + if case == "child" { + ex(&mut s, "node.reparent", json!({"ids": [image], "parent": frame})); + set(&mut s, &[image], json!({"minWidth": 150})); + image + } else { + set(&mut s, &[frame], json!({"fills": fills})); + frame + } + } + }; + ex(&mut s, "select.set", json!({"ids": [layer]})); + let doc = s.doc().unwrap(); + let world = doc.world_transform(layer); + let n = doc.node(layer).unwrap(); + let from = world * Point::new(n.width, n.height / 2.0); + let to = world * Point::new(10.0, n.height / 2.0); + drag(&mut s, from, to, CMD); + assert_eq!(s.doc().unwrap().node(layer).unwrap().fills, fills, "{case}: keep the existing resize behaviour"); + } +} + #[test] fn handle_drags_resize_by_the_documented_rules() { // A 100×50 box; (handle, pointer in the box's axes, modifiers, new box x0 y0 x1 y1, derivation). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 62001715..faeb7430 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command/Ctrl image crop; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -88,6 +88,7 @@ interaction feel not yet compared. | Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | | Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | | Image crop handles | done | | +| Command/Ctrl-resize to crop image fills | partial | The Move tool crops Fill/Fit/Crop image fills on a single non-text layer outside auto layout, keeping image scale and position. Text, auto-layout contexts and Tile retain their existing resize behaviour (`transform_behaviour_tests`) | ## Canvas @@ -166,6 +167,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Command/Ctrl-resize crops image fills without moving or scaling their source pixels; one undo step, saved with the document. Single non-text layers outside auto layout; Tile excluded. Estimates unchanged. | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | | 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard | From 8a77ef6759158f97d1768a367726f50541adccf9 Mon Sep 17 00:00:00 2001 From: timrrrr <54307723+timrrrr@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:18:07 +0300 Subject: [PATCH 008/208] M1: preserve image fills with variable-bound resize dimensions --- ROADMAP.md | 3 +- crates/engine/src/tools.rs | 7 ++-- .../engine/src/transform_behaviour_tests.rs | 39 +++++++++++++++++++ docs/ui-parity.md | 5 ++- 4 files changed, 48 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 82d48ca2..f9754a2d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command/Ctrl image crop; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (variable-bound sizes retain existing resize behaviour during Command/Ctrl image crop; Command/Ctrl image crop; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-17** · Command/Ctrl image crop excludes variable-bound width/height (including `w`/`h` aliases), preserving the existing resize behaviour when normalization restores the bound size. Other bindings still allow crop; pixel and binding regressions cover both dimensions and ordinary resize. Estimates unchanged. - **2026-10-16** · Command/Ctrl-resize with the Move tool crops image fills on a single non-text layer outside auto layout, keeping their scale and position and the original image bytes. Fill/Fit/Crop, rotated layers and image fills, undo and native save/reopen are covered by regression tests. Text, auto-layout contexts, Tile and the Scale tool keep their existing behaviour. Estimates unchanged. - **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged. diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index fea703b9..fe502581 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1559,14 +1559,15 @@ impl Session { if flip_y { t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); } - // Text reflow and auto layout can recompute this box after the preview. + // Text reflow, size bindings and auto layout can recompute this box after the preview. + let n = doc.get(*id)?; let crop_images = ev.cmd - && !matches!(doc.get(*id)?.kind, NodeKind::Text(_)) + && !matches!(n.kind, NodeKind::Text(_)) + && !n.bound.keys().any(|field| matches!(field.as_str(), "width" | "height" | "w" | "h")) && !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some())); if crop_images { // Crop the layer's image fills without moving their pixels on the page. // Work from the interaction snapshot, just like the geometry on every move. - let n = doc.get(*id)?; let (w, h) = (n.width.max(1e-6), n.height.max(1e-6)); let to_old = Affine::scale_non_uniform(1.0 / w, 1.0 / h) * world.inverse() * t * Affine::scale_non_uniform(nw.max(0.01), nh.max(0.01)); diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index 250b7da1..146bdfd6 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -279,6 +279,45 @@ fn command_resize_leaves_fills_when_text_or_auto_layout_recomputes_the_box() { } } +#[test] +fn image_crop_respects_variable_bound_dimensions() { + for (field, value, from, to) in [ + ("width", 200, Point::new(200.0, 50.0), Point::new(100.0, 50.0)), + ("height", 100, Point::new(100.0, 100.0), Point::new(100.0, 50.0)), + ("w", 200, Point::new(200.0, 50.0), Point::new(100.0, 50.0)), + ("h", 100, Point::new(100.0, 100.0), Point::new(100.0, 50.0)), + ("opacity", 100, Point::new(200.0, 50.0), Point::new(100.0, 50.0)), + ] { + for mods in [CMD, NONE] { + let mut s = session(); + let layer = image_layer(&mut s); + ex(&mut s, "variable.createCollection", json!({"name": "Tokens"})); + ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "bound", "type": "number", "value": value})); + ex(&mut s, "variable.bind", json!({"ids": [layer], "field": field, "variable": "bound"})); + let before = s.doc().unwrap().clone(); + let pixels = figcraft_render::render_node(&before, layer, 1.0).unwrap(); + drag(&mut s, from, to, mods); + let doc = s.doc().unwrap(); + let n = doc.node(layer).unwrap(); + assert_eq!(n.bound, before.node(layer).unwrap().bound, "{field}: keep the binding"); + assert_eq!(doc.variables, before.variables, "{field}: do not edit the variable"); + let after = figcraft_render::render_node(doc, layer, 1.0).unwrap(); + if field != "opacity" { + assert_eq!((n.width, n.height), (200.0, 100.0), "{field}: the binding restores the size"); + assert!(after.data() == pixels.data(), "{field}, cmd={}: restored bounds must not stretch a partial crop", mods.cmd); + } else if mods.cmd { + assert_eq!((n.width, n.height), (100.0, 100.0)); + for y in 0..100usize { + assert_eq!(&after.data()[y * 400..(y + 1) * 400], &pixels.data()[y * 800..y * 800 + 400], "opacity binding still allows crop"); + } + } + if field != "opacity" || !mods.cmd { + assert_eq!(n.fills, before.node(layer).unwrap().fills, "{field}: keep the existing resize behaviour"); + } + } + } +} + #[test] fn handle_drags_resize_by_the_documented_rules() { // A 100×50 box; (handle, pointer in the box's axes, modifiers, new box x0 y0 x1 y1, derivation). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index faeb7430..4eff3aac 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command/Ctrl image crop; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (variable-bound sizes retain existing resize behaviour during Command/Ctrl image crop; Command/Ctrl image crop; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -88,7 +88,7 @@ interaction feel not yet compared. | Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | | Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | | Image crop handles | done | | -| Command/Ctrl-resize to crop image fills | partial | The Move tool crops Fill/Fit/Crop image fills on a single non-text layer outside auto layout, keeping image scale and position. Text, auto-layout contexts and Tile retain their existing resize behaviour (`transform_behaviour_tests`) | +| Command/Ctrl-resize to crop image fills | partial | The Move tool crops Fill/Fit/Crop image fills on a single non-text layer outside auto layout, keeping image scale and position. Text, variable-bound width/height, auto-layout contexts and Tile retain their existing resize behaviour (`transform_behaviour_tests`) | ## Canvas @@ -167,6 +167,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-17 | minor | Command/Ctrl image crop leaves fills unchanged when width or height has a variable binding (including `w`/`h` aliases); normalization may restore those dimensions after preview. Other bindings still allow crop. Regression covers pixels, binding preservation and ordinary resize. Estimates unchanged. | | 2026-10-16 | minor | Command/Ctrl-resize crops image fills without moving or scaling their source pixels; one undo step, saved with the document. Single non-text layers outside auto layout; Tile excluded. Estimates unchanged. | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | From e409f9efc0c5ef98b430e4e66263021478bac046 Mon Sep 17 00:00:00 2001 From: timrrrr <54307723+timrrrr@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:48:50 +0300 Subject: [PATCH 009/208] M1: retain GitHub username for contributor credit --- contributors/people.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/contributors/people.toml b/contributors/people.toml index 1d6cd8b7..bc8cf71b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -44,6 +44,3 @@ sync_github_name = true [people.volkovskey] real_name = "Andrii Volkov" - -[people.timrrrr] -display_name = "Timur" From 31d6566f3b02d915a16ecb559ac028193a3833d4 Mon Sep 17 00:00:00 2001 From: leandrodslv <205958219+leandrodslv@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:27:54 +0200 Subject: [PATCH 010/208] i18n(fr): land the native-speaker wording fixes from the draft review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #783 completed the French catalog to every current string, superseding the translations added in #681. This keeps only the ~50 UI strings and 8 command labels whose wording a native French speaker corrected during that review (keyboard focus, "Off" as "Désactivé", blend modes, stroke join, spotlight, the no-break space before : ; ? !…), applied on top of #783. The draft status is unchanged. Co-Authored-By: Claude Sonnet 5 --- crates/ui-egui/locales/fr.toml | 116 ++++++++++++++++----------------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5edf1a62..ec080d67 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -148,7 +148,7 @@ status = "draft — needs native review" "Back to assets" = "Retour aux ressources" "Back to current" = "Revenir à l’actuelle" "Back to styles" = "Retour aux styles" -"Back to the canvas from a focused region" = "Revenir au canevas depuis une région active" +"Back to the canvas from a focused region" = "Revenir au canevas depuis une région qui a le focus" "Back to the current version" = "Revenir à la version actuelle" "Back to the values the font style implies" = "Revenir aux valeurs du style de police" "Background" = "Arrière-plan" @@ -184,7 +184,7 @@ status = "draft — needs native review" "Branch {name}" = "Branche {name}" "Branches" = "Branches" "Bring forward" = "Avancer" -"Bring the main file's changes into the branch" = "Apporte dans la branche les modifications du fichier principal" +"Bring the main file's changes into the branch" = "Apporter dans la branche les modifications du fichier principal" "Bring to front" = "Mettre au premier plan" "Bristle" = "Soies" "Browse" = "Parcourir" @@ -252,8 +252,8 @@ status = "draft — needs native review" "Collection" = "Collection" "Collections" = "Collections" "Color" = "Couleur" -"Color burn" = "Brûlage de couleur" -"Color dodge" = "Éclaircissement de couleur" +"Color burn" = "Densité couleur +" +"Color dodge" = "Densité couleur −" "Color format" = "Format de couleur" "Color profile" = "Profil colorimétrique" "Color styles" = "Styles de couleur" @@ -317,8 +317,8 @@ status = "draft — needs native review" "Corner radius" = "Rayon d’angle" "Corner smoothing" = "Lissage des angles" "Couldn't download the variables" = "Impossible de télécharger les variables" -"Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." -"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}" +"Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." +"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}" "Count" = "Nombre" "Create" = "Créer" "Create a variable collection first" = "Créez d’abord une collection de variables" @@ -446,14 +446,14 @@ status = "draft — needs native review" "Empty" = "Vide" "End" = "Fin" "End point" = "Point final" -"Ends the session; everyone keeps their copy" = "Met fin à la session ; chacun conserve sa copie" +"Ends the session; everyone keeps their copy" = "Met fin à la session ; chacun conserve sa copie" "Enter" = "Entrée" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Saisissez l’adresse d’un figcraft-server et appuyez sur Parcourir pour voir les plugins qu’il propose." "Enter to save · Esc to cancel" = "Entrée pour enregistrer · Échap pour annuler" "Eraser" = "Gomme" "Esc" = "Échap" "Essential" = "Essentiels" -"Every conflict has a choice." = "Chaque conflit a un choix." +"Every conflict has a choice." = "Un choix est fait pour chaque conflit." "Every top-level frame already starts a flow" = "Chaque cadre de premier niveau démarre déjà un flux" "Exact opacity (4 then 5: 45%)" = "Opacité exacte (4 puis 5 : 45 %)" "Exclude" = "Exclure" @@ -471,9 +471,9 @@ status = "draft — needs native review" "Export variables" = "Exporter les variables" "Export {name}" = "Exporter {name}" "Export {n} layers" = { one = "Exporter {n} calque", other = "Exporter {n} calques" } -"Exported {file}" = "Exporté : {file}" +"Exported {file}" = "{file} exporté" "Exported {name}" = "{name} exporté" -"Exported {name}; {n} kinds of content were left out" = { one = "{name} exporté ; {n} type de contenu a été omis", other = "{name} exporté ; {n} types de contenu ont été omis" } +"Exported {name}; {n} kinds of content were left out" = { one = "{name} exporté ; {n} type de contenu a été omis", other = "{name} exporté ; {n} types de contenu ont été omis" } "Exported {n} files" = { one = "{n} fichier exporté", other = "{n} fichiers exportés" } "Export…" = "Exporter…" "Expose properties to instances" = "Exposer les propriétés aux instances" @@ -493,7 +493,7 @@ status = "draft — needs native review" "File name" = "Nom du fichier" "File not found" = "Fichier introuvable" "File published" = "Fichier publié" -"Files aren't end-to-end encrypted: the server's operator can read them." = "Les fichiers ne sont pas chiffrés de bout en bout : l’opérateur du serveur peut les lire." +"Files aren't end-to-end encrypted: the server's operator can read them." = "Les fichiers ne sont pas chiffrés de bout en bout : l’opérateur du serveur peut les lire." "Files nobody opens for {n} days are deleted." = { one = "Les fichiers que personne n’ouvre pendant {n} jour sont supprimés.", other = "Les fichiers que personne n’ouvre pendant {n} jours sont supprimés." } "Fill" = "Remplissage" "Fill (fr)" = "Remplir (fr)" @@ -571,14 +571,14 @@ status = "draft — needs native review" "Groups" = "Groupes" "Gutter" = "Gouttière" "Hand tool" = "Outil Main" -"Hanging list" = "Liste en retrait" +"Hanging list" = "Liste à retrait suspendu" "Hanging punctuation" = "Ponctuation en débord" "Hard light" = "Lumière dure" "Height" = "Hauteur" "Help" = "Aide" "Help and resources" = "Aide et ressources" "Help page" = "Page d’aide" -"Here: {names}" = "Présents : {names}" +"Here: {names}" = "Présents : {names}" "Hidden" = "Masqué" "Hide contents" = "Masquer le contenu" "Hide from publishing" = "Exclure de la publication" @@ -668,7 +668,7 @@ status = "draft — needs native review" "Joined" = "Connecté" "Joined {file}" = "Connecté à {file}" "Joining…" = "Connexion…" -"Join|stroke" = "Jointure" +"Join|stroke" = "Jonction" "Jul" = "juil." "Jun" = "juin" "Just now" = "À l’instant" @@ -693,7 +693,7 @@ status = "draft — needs native review" "Layer name" = "Nom du calque" "Layers" = "Calques" "Layers in an instance take their position from the main component" = "Les calques d’une instance prennent leur position du composant principal" -"Layout" = "Disposition" +"Layout" = "Mise en page" "Layout grid" = "Grille de mise en page" "Layout grid settings" = "Réglages de la grille de mise en page" "Layout grids" = "Grilles de mise en page" @@ -761,9 +761,9 @@ status = "draft — needs native review" "Maximize" = "Agrandir" "May" = "mai" "Measure to the selection" = "Mesurer jusqu’à la sélection" -"Merge into main" = "Fusionner dans le principal" +"Merge into main" = "Fusionner dans le fichier principal" "Merge regions" = "Fusionner les régions" -"Merge the branch into the main file (one undo step) and archive it" = "Fusionne la branche dans le fichier principal (une seule étape d’annulation) et l’archive" +"Merge the branch into the main file (one undo step) and archive it" = "Fusionner la branche dans le fichier principal (une seule étape d’annulation) et l’archiver" "Merged" = "Fusionnée" "Merged PRs" = "PR fusionnées" "Min H" = "H min." @@ -784,7 +784,7 @@ status = "draft — needs native review" "Mobile app" = "Application mobile" "Mode" = "Mode" "Model" = "Modèle" -"Models" = "Modèles" +"Models" = "Modèles d’IA" "Mono" = "Mono" "More" = "Plus" "More instance actions" = "Autres actions d’instance" @@ -797,8 +797,8 @@ status = "draft — needs native review" "Move column left" = "Déplacer la colonne à gauche" "Move column right" = "Déplacer la colonne à droite" "Move down" = "Descendre" -"Move in" = "Entrée" -"Move out" = "Sortie" +"Move in" = "Déplacer vers l’intérieur" +"Move out" = "Déplacer vers l’extérieur" "Move tools" = "Outils de déplacement" "Move up" = "Monter" "Moved to {name}" = "Déplacé vers {name}" @@ -813,7 +813,7 @@ status = "draft — needs native review" "New design file" = "Nouveau fichier de design" "Newest first" = "Plus récent d’abord" "Next frame" = "Cadre suivant" -"Next or previous control in the focused region" = "Contrôle suivant ou précédent dans la région active" +"Next or previous control in the focused region" = "Contrôle suivant ou précédent dans la région qui a le focus" "Next ready for dev" = "Prêt pour le dev suivant" "Next size option" = "Option de taille suivante" "No assets: no export settings, icons or images. Add export settings in the Design tab." = "Aucune ressource : ni réglage d’exportation, ni icône, ni image. Ajoutez des réglages d’exportation dans l’onglet Design." @@ -832,7 +832,7 @@ status = "draft — needs native review" "No contributor data was built into this copy." = "Cette copie ne contient aucune donnée de contributeurs." "No device" = "Aucun appareil" "No fonts match" = "Aucune police ne correspond" -"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Pas de code d’accès ici (le serveur est antérieur aux codes d’accès, ou cette adresse ne tient pas dans un code) : partagez plutôt le lien." +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Pas de code d’accès ici (le serveur est antérieur aux codes d’accès, ou cette adresse ne tient pas dans un code) : partagez plutôt le lien." "No layer changes to show." = "Aucune modification de calque à afficher." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aucune bibliothèque pour l’instant. Ajoutez un fichier .figcraft publié en tant que bibliothèque, ou collez le lien d’une bibliothèque d’équipe." "No list" = "Aucune liste" @@ -846,7 +846,7 @@ status = "draft — needs native review" "No number variables fit here." = "Aucune variable numérique ne convient ici." "No number variables yet. Create them in Local variables." = "Aucune variable numérique pour l’instant. Créez-en dans Variables locales." "No other pages" = "Aucune autre page" -"No paints" = "Aucune peinture" +"No paints" = "Aucun remplissage" "No plugins on {server}." = "Aucun plugin sur {server}." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Aucun plugin pour l’instant. Importez un plugin en développement depuis son figcraft-plugin.json." "No preferred components" = "Aucun composant préféré" @@ -889,7 +889,7 @@ status = "draft — needs native review" "Numbered list" = "Liste numérotée" "Object" = "Objet" "Oct" = "oct." -"Off" = "Non" +"Off" = "Désactivé" "Offered first when filling the slot (any content is allowed)" = "Proposés en premier pour remplir l’emplacement (tout contenu est autorisé)" "Offered first when swapping the instance (any component is allowed)" = "Proposés en premier pour permuter l’instance (tout composant est autorisé)" "Offset" = "Décalage" @@ -900,7 +900,7 @@ status = "draft — needs native review" "One announcement in five common sizes: square, portrait, story, link and banner" = "Une annonce en cinq formats courants : carré, portrait, story, lien et bannière" "Online" = "En ligne" "Online files" = "Fichiers en ligne" -"Online sharing is off: files are shared on the local network only." = "Le partage en ligne est désactivé : les fichiers ne sont partagés que sur le réseau local." +"Online sharing is off: files are shared on the local network only." = "Le partage en ligne est désactivé : les fichiers ne sont partagés que sur le réseau local." "Only the first changed layers are listed." = "Seuls les premiers calques modifiés sont listés." "Only the first {n} changed layers are listed." = "Seuls les {n} premiers calques modifiés sont listés." "Only your threads" = "Vos fils uniquement" @@ -930,7 +930,7 @@ status = "draft — needs native review" "Order" = "Ordre" "Other" = "Autre" "Other apps may not open every .fig file exported here." = "D’autres applications risquent de ne pas ouvrir tous les fichiers .fig exportés ici." -"Others lose access and keep their copy; the file stays on the server for you" = "Les autres perdent l’accès et conservent leur copie ; le fichier reste sur le serveur pour vous" +"Others lose access and keep their copy; the file stays on the server for you" = "Les autres perdent l’accès et conservent leur copie ; le fichier reste sur le serveur pour vous" "Others type this under Share ▸ Join with a code" = "Les autres le saisissent dans Partager ▸ Rejoindre avec un code" "Outline stroke" = "Convertir le contour en tracé" "Outline text" = "Convertir le texte en tracés" @@ -973,7 +973,7 @@ status = "draft — needs native review" "Pen" = "Plume" "Pencil" = "Crayon" "People in this file" = "Personnes dans ce fichier" -"People join from anywhere with a code. The file is kept on {server} while you share it." = "On vous rejoint de partout avec un code. Le fichier est conservé sur {server} tant que vous le partagez." +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Les personnes vous rejoignent de partout avec un code. Le fichier est conservé sur {server} tant que vous le partagez." "People on your network join with a code. Nothing goes through a server." = "Les personnes de votre réseau vous rejoignent avec un code. Rien ne passe par un serveur." "Phone" = "Téléphone" "Pick color" = "Prélever une couleur" @@ -1047,7 +1047,7 @@ status = "draft — needs native review" "Ratio" = "Rapport" "Reading the library…" = "Lecture de la bibliothèque…" "Ready for dev" = "Prêt pour le dev" -"Real name" = "Vrai nom" +"Real name" = "Nom réel" "Recent" = "Récents" "Recent files" = "Fichiers récents" "Recents" = "Récents" @@ -1080,9 +1080,9 @@ status = "draft — needs native review" "Remove text from path" = "Retirer le texte du tracé" "Remove the else and its actions" = "Retirer le « sinon » et ses actions" "Remove this condition and its actions" = "Retirer cette condition et ses actions" -"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Retirer ce fichier du serveur ? Il ne pourra plus être partagé ni ouvert depuis celui-ci. Votre copie reste ouverte ici." +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Retirer ce fichier du serveur ? Il ne pourra plus être partagé ni ouvert depuis ce serveur. Votre copie reste ouverte ici." "Remove {name} from session" = "Retirer {name} de la session" -"Remove {name} from {server}?" = "Retirer {name} de {server} ?" +"Remove {name} from {server}?" = "Retirer {name} de {server} ?" "Removed" = "Supprimé" "Removed from the server" = "Retiré du serveur" "Removed since it was marked" = "Supprimé depuis le marquage" @@ -1113,7 +1113,7 @@ status = "draft — needs native review" "Reset to style" = "Revenir au style" "Reset to the inherited value" = "Revenir à la valeur héritée" "Resize by the nudge amount" = "Redimensionner du pas de décalage" -"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Redimensionne les cadres à l’écran de l’appareil (ou à la fenêtre sans appareil) : leurs contraintes et leur mise en page auto s’adaptent" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Redimensionner les cadres à l’écran de l’appareil (ou à la fenêtre sans appareil) : leurs contraintes et leur mise en page auto s’adaptent" "Resize to fit" = "Redimensionner au contenu" "Resizing" = "Redimensionnement" "Resolve" = "Résoudre" @@ -1186,7 +1186,7 @@ status = "draft — needs native review" "Select a frame to preview it" = "Sélectionnez un cadre pour le prévisualiser" "Select a layer to see its properties and code" = "Sélectionnez un calque pour voir ses propriétés et son code" "Select all" = "Tout sélectionner" -"Select all with same" = "Sélectionner tout ce qui a le même" +"Select all with same" = "Sélectionner par propriété commune" "Select layer" = "Sélectionner le calque" "Select layers and they space themselves as their content changes" = "Sélectionnez des calques et leur espacement s’ajuste quand leur contenu change" "Select layers to apply the style to" = "Sélectionnez les calques auxquels appliquer le style" @@ -1219,12 +1219,12 @@ status = "draft — needs native review" "Share with a code" = "Partager avec un code" "Share: live session, export" = "Partager : session en direct, exportation" "Shared" = "Partagé" -"Shared: copy its code from the file's menu" = "Partagé : copiez son code depuis le menu du fichier" -"Sharing a file needs the desktop app; here you can join one." = "Le partage d’un fichier nécessite l’application de bureau ; ici, vous pouvez en rejoindre un." -"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Partage sur le réseau local (le partage en ligne est désactivé : aucun serveur n’est défini dans les Préférences)." +"Shared: copy its code from the file's menu" = "Partagé : copiez son code depuis le menu du fichier" +"Sharing a file needs the desktop app; here you can join one." = "Le partage d’un fichier nécessite l’application de bureau ; ici, vous pouvez en rejoindre un." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Partage sur le réseau local (le partage en ligne est désactivé : aucun serveur n’est défini dans les Préférences)." "Sharing on your network · {people}" = "Partage sur votre réseau · {people}" "Sharing online · {people}" = "Partage en ligne · {people}" -"Sharing stopped: the file stays on the server, open only to you" = "Partage arrêté : le fichier reste sur le serveur, accessible à vous seul" +"Sharing stopped: the file stays on the server, open only to you" = "Partage arrêté : le fichier reste sur le serveur, accessible à vous seul" "Show" = "Afficher" "Show all templates" = "Afficher tous les modèles" "Show as grid" = "Afficher en grille" @@ -1281,7 +1281,7 @@ status = "draft — needs native review" "Spacing" = "Espacement" "Spam, stolen work, abuse…" = "Spam, travail volé, abus…" "Splay" = "Évasement" -"Spotlight me" = "Me mettre sous les projecteurs" +"Spotlight me" = "Faire suivre ma vue" "Spread" = "Étendue" "Square" = "Carré" "Standard" = "Standard" @@ -1303,7 +1303,7 @@ status = "draft — needs native review" "Stop following {name}" = "Ne plus suivre {name}" "Stop session" = "Arrêter la session" "Stop sharing" = "Arrêter le partage" -"Stop spotlighting" = "Quitter les projecteurs" +"Stop spotlighting" = "Arrêter de faire suivre ma vue" "Stop {n} {hex}" = "Arrêt {n} {hex}" "Stretch" = "Étirer" "Strikethrough" = "Barré" @@ -1357,7 +1357,7 @@ status = "draft — needs native review" "The destination document is unavailable. Nothing was imported." = "Le document de destination est indisponible. Rien n’a été importé." "The destination document was closed. Nothing was imported." = "Le document de destination a été fermé. Rien n’a été importé." "The destination page was removed. Nothing was imported." = "La page de destination a été supprimée. Rien n’a été importé." -"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Le fichier est supprimé du serveur : plus personne ne pourra l’ouvrir ni le rejoindre depuis celui-ci, et c’est irréversible. Les copies ouvertes restent à ceux qui les ont." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Le fichier est supprimé du serveur : plus personne ne pourra l’ouvrir ni le rejoindre depuis ce serveur, et c’est irréversible. Les copies ouvertes restent à ceux qui les ont." "The file now holds another library" = "Le fichier contient désormais une autre bibliothèque" "The file now holds another library (\"{name}\")" = "Le fichier contient désormais une autre bibliothèque (« {name} »)" "The files you shared or published online from this computer. They stay on their server until you remove them." = "Les fichiers que vous avez partagés ou publiés en ligne depuis cet ordinateur. Ils restent sur leur serveur jusqu’à ce que vous les retiriez." @@ -1372,16 +1372,16 @@ status = "draft — needs native review" "This branch can't be reviewed: {e}" = "Impossible de relire cette branche : {e}" "This branch was merged or archived." = "Cette branche a été fusionnée ou archivée." "This computer only" = "Cet ordinateur uniquement" -"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Cet ordinateur mémorise le jeton de propriétaire : le fichier se gère depuis la liste En ligne de l’écran d’accueil. Copiez-le pour gérer le fichier depuis un autre endroit : c’est un mot de passe pour ce fichier et il ne sera plus affiché." +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Cet ordinateur mémorise le jeton de propriétaire : le fichier se gère depuis la liste En ligne de l’écran d’accueil. Copiez-le pour gérer le fichier depuis un autre endroit : c’est un mot de passe pour ce fichier et il ne sera plus affiché." "This condition is edited as an expression" = "Cette condition se modifie en tant qu’expression" "This file" = "Ce fichier" "This file has unsaved changes" = "Ce fichier contient des modifications non enregistrées" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Ce fichier est sur {server}, accessible à vous seul. Partagez-le pour laisser entrer d’autres personnes avec un nouveau code." "This frame is new on the branch." = "Ce cadre est nouveau dans la branche." "This frame isn't on the branch." = "Ce cadre n’est pas dans la branche." -"This server is open to the public: no key needed." = "Ce serveur est ouvert au public : aucune clé nécessaire." -"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ce serveur exige sa clé : ajoutez ?key=… à l’adresse (`figcraft-server key` l’affiche sur la machine du serveur)." -"This server takes files only with its key: see Advanced." = "Ce serveur n’accepte les fichiers qu’avec sa clé : voir Avancé." +"This server is open to the public: no key needed." = "Ce serveur est ouvert au public : aucune clé nécessaire." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ce serveur exige sa clé : ajoutez ?key=… à l’adresse (`figcraft-server key` l’affiche sur la machine du serveur)." +"This server takes files only with its key: see Advanced." = "Ce serveur n’accepte les fichiers qu’avec sa clé : voir Avancé." "Tidy up" = "Ranger" "Tile" = "Mosaïque" "Tile type" = "Type de mosaïque" @@ -1427,7 +1427,7 @@ status = "draft — needs native review" "Unsaved changes: save first, or close again with {key} to discard" = "Modifications non enregistrées : enregistrez d’abord ou fermez à nouveau avec {key} pour les abandonner" "Up" = "Haut" "Update" = "Mettre à jour" -"Update from main" = "Mettre à jour depuis le principal" +"Update from main" = "Mettre à jour depuis le fichier principal" "Update from selection" = "Mettre à jour depuis la sélection" "Update plugin" = "Mettre à jour le plugin" "Updated {components} and {styles}" = "{components} et {styles} mis à jour" @@ -1488,7 +1488,7 @@ status = "draft — needs native review" "Wedge" = "Coin" "Weight" = "Graisse" "What changed since it was marked ready for dev" = "Ce qui a changé depuis le marquage prêt pour le dev" -"What is wrong with this file? The server's operator gets your report." = "Qu’est-ce qui ne va pas avec ce fichier ? L’opérateur du serveur reçoit votre signalement." +"What is wrong with this file? The server's operator gets your report." = "Qu’est-ce qui ne va pas avec ce fichier ? L’opérateur du serveur reçoit votre signalement." "What it's for" = "À quoi il sert" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "L’état d’origine de cette modification est inconnu : elle date d’avant que FigCraft ne conserve une copie des cadres prêts pour le dev. Résolvez la modification (ou marquez-la à nouveau comme prête) pour comparer à partir de maintenant." "What you try here changes this preview and the code below, not the file" = "Ce que vous essayez ici change cet aperçu et le code ci-dessous, pas le fichier" @@ -1510,9 +1510,9 @@ status = "draft — needs native review" "Yesterday" = "Hier" "You" = "Vous" "You'll get the shared file as a new tab and edit it together." = "Vous recevrez le fichier partagé dans un nouvel onglet et le modifierez ensemble." -"You're spotlighting: everyone follows your view" = "Vous êtes sous les projecteurs : tout le monde suit votre vue" +"You're spotlighting: everyone follows your view" = "Vous faites suivre votre vue : tout le monde vous suit" "Your copy of the file stays open" = "Votre copie du fichier reste ouverte" -"Your copy stays open; the file stays on the server" = "Votre copie reste ouverte ; le fichier reste sur le serveur" +"Your copy stays open; the file stays on the server" = "Votre copie reste ouverte ; le fichier reste sur le serveur" "Your name" = "Votre nom" "Your name in comments" = "Votre nom dans les commentaires" "Your system asks for reduced motion: animations stay off." = "Votre système demande de réduire les animations : elles restent désactivées." @@ -1662,11 +1662,11 @@ status = "draft — needs native review" "{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}) : {min} – {max}, par défaut {default}. Double-cliquez sur le curseur pour revenir à la valeur du style ; Échap pendant le glissement annule." "{name} cleared from the list" = "{name} effacé de la liste" "{name} from {path}" = "{name} depuis {path}" -"{name} is forgotten on this computer; the file stays on {server}" = "{name} est oublié sur cet ordinateur ; le fichier reste sur {server}" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} est oublié sur cet ordinateur ; le fichier reste sur {server}" "{name} is on the server." = "{name} est sur le serveur." -"{name} is spotlighting" = "{name} est sous les projecteurs" +"{name} is spotlighting" = "{name} fait suivre sa vue" "{name} was removed from the server" = "{name} a été retiré du serveur" -"{name} · deleted on main" = "{name} · supprimé dans le principal" +"{name} · deleted on main" = "{name} · supprimé dans le fichier principal" "{name} · deleted on the branch" = "{name} · supprimé dans la branche" "{name} · not published" = "{name} · non publié" "{name} · on a server" = "{name} · sur un serveur" @@ -1766,7 +1766,7 @@ status = "draft — needs native review" "branch.merge" = "Fusionner la branche" "branch.open" = "Ouvrir la branche" "branch.review" = "Relire la branche" -"branch.updateFromMain" = "Mettre à jour depuis le principal" +"branch.updateFromMain" = "Mettre à jour depuis le fichier principal" "codeConnect.export" = "Exporter les liaisons au code" "codeConnect.import" = "Importer les liaisons au code" "codeConnect.remove" = "Délier du code" @@ -1790,7 +1790,7 @@ status = "draft — needs native review" "collab.report" = "Signaler le fichier à l’opérateur du serveur" "collab.serverInfo" = "Informations sur le serveur" "collab.share" = "Partager avec un code d’accès" -"collab.spotlight" = "Me mettre sous les projecteurs" +"collab.spotlight" = "Faire suivre ma vue" "collab.stop" = "Arrêter la session en direct" "command.batch" = "Exécuter plusieurs commandes" "comment.add" = "Ajouter un commentaire" @@ -2051,10 +2051,10 @@ status = "draft — needs native review" "select.inverse" = "Inverser la sélection" "select.layersAt" = "Calques en un point" "select.matching" = "Sélectionner les calques correspondants" -"select.next" = "Sélectionner l’élément suivant" +"select.next" = "Sélectionner l’élément suivant du même niveau" "select.none" = "Tout désélectionner" "select.parent" = "Sélectionner le parent" -"select.previous" = "Sélectionner l’élément précédent" +"select.previous" = "Sélectionner l’élément précédent du même niveau" "select.remove" = "Retirer de la sélection" "select.sameEffect" = "Sélectionner le même effet" "select.sameFill" = "Sélectionner le même remplissage" @@ -2141,7 +2141,7 @@ status = "draft — needs native review" "text.sizeDown" = "Réduire la taille de police" "text.sizeUp" = "Augmenter la taille de police" "text.strikethrough" = "Barré" -"text.style" = "Styler une plage de texte" +"text.style" = "Appliquer un style à une plage de texte" "text.trackingDown" = "Réduire l’interlettrage" "text.trackingUp" = "Augmenter l’interlettrage" "text.underline" = "Souligné" @@ -2224,7 +2224,7 @@ status = "draft — needs native review" "vector.edit.smooth" = "Lisser ou rendre anguleux les points" "vector.edit.state" = "État de la modification vectorielle" "vector.edit.strokeCap" = "Extrémité du contour au point" -"vector.edit.strokeJoin" = "Jointure du contour au point" +"vector.edit.strokeJoin" = "Jonction du contour au point" "vector.edit.tool" = "Outil de modification vectorielle" "vector.edit.transform" = "Transformer les points" "vector.edit.widthPoint" = "Point de largeur" @@ -2258,7 +2258,7 @@ status = "draft — needs native review" "view.multiplayerCursors" = "Curseurs des autres participants" "view.nextFrame" = "Zoomer sur le cadre suivant" "view.nextPage" = "Page suivante" -"view.nextRegion" = "Activer la région suivante" +"view.nextRegion" = "Placer le focus sur la région suivante" "view.outlines" = "Contours" "view.outlinesHiddenLayers" = "Inclure les calques masqués dans les contours" "view.outlinesObjectBounds" = "Inclure les limites des objets dans les contours" @@ -2269,7 +2269,7 @@ status = "draft — needs native review" "view.pixelPreview" = "Aperçu des pixels" "view.prevFrame" = "Zoomer sur le cadre précédent" "view.prevPage" = "Page précédente" -"view.previousRegion" = "Activer la région précédente" +"view.previousRegion" = "Placer le focus sur la région précédente" "view.proofSrgb" = "Épreuve écran sRGB" "view.fullscreen" = "Plein écran" "view.prototype" = "Panneau Prototype" From 4223031c5c287da7187409fa68bdc1f0eaa8d1e6 Mon Sep 17 00:00:00 2001 From: leandrodslv <205958219+leandrodslv@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:28:13 +0200 Subject: [PATCH 011/208] docs(contributors): credit leandrodslv by the name on their GitHub profile Co-Authored-By: Claude Sonnet 5 --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 8b14373e..5bdf5983 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -56,3 +56,6 @@ sync_github_name = true [people.pechnikov2026-star] sync_github_name = true + +[people.leandrodslv] +sync_github_name = true From a370211b486ba7e36b0efc05d4700adbba731aec Mon Sep 17 00:00:00 2001 From: Dubblbindong <48181403+Dubblbindong@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:11:59 +0200 Subject: [PATCH 012/208] fix(plugin): keep the plugin directory's wire format; refuse oversized save_file data - PluginEntry no longer carries save_file: postcard is positional and existing messages keep their fields (docs/collab-protocol.md), so mixed app/server versions keep reading the directory. Manage plugins still shows "can save files". - save_file checks the data length before reading it and refuses with status 1 and the reason, as documented, instead of failing the run with a limit error. Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-server/src/directory.rs | 1 - crates/collab/src/message.rs | 2 -- crates/engine/src/plugin_directory.rs | 2 +- crates/plugin/src/sandbox.rs | 11 +++++++++++ crates/plugin/src/save_file_tests.rs | 10 ++++++---- crates/ui-egui/locales/de.toml | 1 - crates/ui-egui/locales/en.toml | 1 - crates/ui-egui/locales/es.toml | 1 - crates/ui-egui/src/plugins_ui.rs | 3 --- docs/plugins.md | 4 ++-- 10 files changed, 20 insertions(+), 16 deletions(-) diff --git a/apps/figcraft-server/src/directory.rs b/apps/figcraft-server/src/directory.rs index cdf18364..0443c0f4 100644 --- a/apps/figcraft-server/src/directory.rs +++ b/apps/figcraft-server/src/directory.rs @@ -245,7 +245,6 @@ impl Directory { }, commands: m.permissions.commands.clone(), widget: m.widget, - save_file: m.permissions.save_file, bytes: bytes.len() as u64, sha256: sha.clone(), published: now(), diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 734e4076..d035042b 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -537,8 +537,6 @@ pub struct PluginEntry { pub commands: Vec, /// It draws a widget. pub widget: bool, - /// It may offer files to save (`permissions.saveFile`). - pub save_file: bool, /// The bundle's size in bytes and its SHA-256 (hex). pub bytes: u64, pub sha256: String, diff --git a/crates/engine/src/plugin_directory.rs b/crates/engine/src/plugin_directory.rs index 1d33178f..2f721fea 100644 --- a/crates/engine/src/plugin_directory.rs +++ b/crates/engine/src/plugin_directory.rs @@ -288,7 +288,7 @@ pub fn specs() -> Vec { spec!( "plugin.directory.list", "Browse plugin directory", - "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}", + "{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}", false, list ), diff --git a/crates/plugin/src/sandbox.rs b/crates/plugin/src/sandbox.rs index 59d66118..c7337dca 100644 --- a/crates/plugin/src/sandbox.rs +++ b/crates/plugin/src/sandbox.rs @@ -195,6 +195,12 @@ struct State { } impl State { + /// Make `message` the reply and return status 1 (refused). + fn refuse(&mut self, message: String) -> i32 { + self.set_reply(message.into_bytes()); + 1 + } + /// Make `bytes` the reply; returns its length. fn set_reply(&mut self, bytes: Vec) -> i32 { let max = self.limits.max_message; @@ -595,6 +601,11 @@ fn linker(engine: &Engine) -> Result, PluginError> { charge(&mut c)?; let name = read_str(&mut c, np, nl)?; let mime = read_str(&mut c, mp, ml)?; + // Too much data is a refusal the plugin can handle, not a limit that fails the run. + let (len, max) = (dl as u32 as usize, c.data().limits.max_message); + if len > max { + return Ok(c.data_mut().refuse(format!("a file to save is limited to {max} bytes, this one has {len}"))); + } let data = read_bytes(&mut c, dp, dl)?; Err(ask(&mut c, Request::SaveFile(name, mime, data))) }) diff --git a/crates/plugin/src/save_file_tests.rs b/crates/plugin/src/save_file_tests.rs index 34241bb7..7915323f 100644 --- a/crates/plugin/src/save_file_tests.rs +++ b/crates/plugin/src/save_file_tests.rs @@ -98,12 +98,14 @@ fn a_host_without_saving_refuses_by_default() { } #[test] -fn too_much_data_is_a_limit_error_and_bad_utf8_a_trap() { - let limits = Limits { max_message: 4, ..Limits::default() }; - let wasm = module("a.txt", "text/plain", b"12345", &call("a.txt", "text/plain", b"12345")); +fn too_much_data_is_refused_and_bad_utf8_a_trap() { + let limits = Limits { max_message: 64, ..Limits::default() }; + let data = [b'x'; 65]; + let wasm = module("a.txt", "text/plain", &data, &call("a.txt", "text/plain", &data)); let mut host = Saves::default(); + // Status 1 with the reason, which the module passes on as its own failure. let err = Instance::new(&wasm, &limits).unwrap().call("{}", &mut host).unwrap_err(); - assert!(matches!(err, PluginError::Limit(_)), "{err:?}"); + assert_eq!(err, PluginError::Failed("a file to save is limited to 64 bytes, this one has 65".into())); assert!(host.0.is_empty()); // The name points at a lone 0xff byte: not UTF-8. diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 08aa5c1a..03de53af 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1533,7 +1533,6 @@ status = "draft — needs native review" "read and edit the document" = "das Dokument lesen und bearbeiten" "read the document" = "das Dokument lesen" "reads the document" = "liest das Dokument" -"save files you choose" = "Dateien an einem von Ihnen gewählten Ort speichern" "set-variable actions on variables that aren't in the variables JSON were skipped" = "Aktionen „Variable festlegen“ für Variablen, die nicht im Variablen-JSON sind, wurden übersprungen" "set-variable actions with values that can't be evaluated were skipped" = "Aktionen „Variable festlegen“ mit nicht auswertbaren Werten wurden übersprungen" "set-variable actions with values that don't fit the variable were skipped" = "Aktionen „Variable festlegen“ mit Werten, die nicht zur Variable passen, wurden übersprungen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 58890934..8a31028e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1621,7 +1621,6 @@ name = "English" "read and edit the document" = "read and edit the document" "read the document" = "read the document" "reads the document" = "reads the document" -"save files you choose" = "save files you choose" "set-variable actions on variables that aren't in the variables JSON were skipped" = "set-variable actions on variables that aren't in the variables JSON were skipped" "set-variable actions with values that can't be evaluated were skipped" = "set-variable actions with values that can't be evaluated were skipped" "set-variable actions with values that don't fit the variable were skipped" = "set-variable actions with values that don't fit the variable were skipped" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 1f92bf91..08e47492 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1622,7 +1622,6 @@ status = "draft — needs native review" "read and edit the document" = "leer y editar el documento" "read the document" = "leer el documento" "reads the document" = "lee el documento" -"save files you choose" = "guardar archivos donde tú elijas" "set-variable actions on variables that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer variable» sobre variables que no están en el JSON de variables" "set-variable actions with values that can't be evaluated were skipped" = "se omitieron las acciones «Establecer variable» con valores que no se pueden evaluar" "set-variable actions with values that don't fit the variable were skipped" = "se omitieron las acciones «Establecer variable» con valores que no encajan con la variable" diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 919fd484..7933fa7b 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -477,9 +477,6 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { if e.widget { asks.push(tr("put a widget on the canvas").to_string()); } - if e.save_file { - asks.push(tr("save files you choose").to_string()); - } for a in asks { ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(regular(11.0)).color(t.text)).wrap()); } diff --git a/docs/plugins.md b/docs/plugins.md index ed4b9927..a14dd7a4 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -135,7 +135,7 @@ plugins: import them in the script. | `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. | | `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | | `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. | -| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | +| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | | `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. | | `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. | | `plugin.directory.unpublish` | `{id, version?, server}` | Removes one version (default: every version) from the directory; needs the key. Returns `{id, removed}`. | @@ -225,7 +225,7 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p | `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | | `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | -| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. The install confirmation lists it. | +| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"); a plugin directory's entries don't carry it, so their wire format stays as it is. | | `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | | `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. | From 31da698dd5509d9f5af26add0e4999b6f04f7a1b Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:38:11 +0500 Subject: [PATCH 013/208] Fill selected layer when pasting an image --- ROADMAP.md | 1 + crates/engine/src/cmd/create.rs | 23 +++++++++++- crates/engine/src/cmd/edit.rs | 26 ++++++++++++- crates/ui-egui/src/menus.rs | 16 ++++++++ crates/ui-egui/src/paste_image_tests.rs | 49 +++++++++++++++++++++++++ docs/ui-parity.md | 7 +++- 6 files changed, 118 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4e0d088d..7d85338a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-20** · Pasting an image into a selected layer (M7): Cmd/Ctrl+V fills one selected shape, frame, text layer or boolean result with an image copied from another app or from FigCraft, keeping the layer's geometry and one undo step; with no selection or Paste here, it makes a separate image layer. Synthetic clipboard and oval export tests cover the paths. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 3a9f6d67..68375716 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -83,7 +83,7 @@ pub fn specs() -> Vec { spec!( "image.place", "Place image", - "{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}", + "{data (base64) | path, x?, y?, width?, height?, parent?, target? (an existing shape to fill instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}", true, place_image ), @@ -372,9 +372,21 @@ fn read_path(path: &str) -> Result> { Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser"))) } +/// Layers that can show an image inside their own outline. +pub fn accepts_image_fill(kind: &NodeKind) -> bool { + kind.is_shape() || kind.is_frame_like() || matches!(kind, NodeKind::Text(_) | NodeKind::BooleanOp { .. }) +} + /// Place an image as a rectangle with an image fill (Figma's image layer). pub fn place_image(s: &mut Session, p: &Value) -> Result { let cmd = "image.place"; + let target = opt_id(cmd, p, "target")?; + if let Some(id) = target { + let node = s.doc()?.node(id).ok_or(crate::EngineError::NoNode(id))?; + if !accepts_image_fill(&node.kind) { + return Err(bad(cmd, "`target` must be a layer that can have a fill")); + } + } let bytes = if let Some(b64) = string(p, "data") { use base64::Engine as _; let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64); @@ -390,6 +402,15 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result { "tile" => ImageScaleMode::Tile, _ => ImageScaleMode::Fill, }; + if let Some(id) = target { + let doc = s.doc_mut()?; + let (hash, _, _) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?; + let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash)); + let node = doc.get_mut(id)?; + node.fills = vec![Paint::image(hash.clone(), mode)]; + node.styles.fill = None; + return Ok(json!({"id": id, "image": hash, "width": node.width, "height": node.height, "imageProfile": profile})); + } let page = s.page()?; let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let ds = s.ds_mut()?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..dc9c7b77 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { spec!( "edit.paste", "Paste", - "{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)", + "{parent?, x?, y? (page coordinates of the pasted top-left)} a single copied image fills a selected shape; a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)", true, paste ), @@ -681,11 +681,33 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re Ok(out) } +fn copied_image(s: &Session) -> Option<(String, ImageData)> { + let [(_, nodes)] = s.clipboard.nodes.as_slice() else { return None }; + let [node] = nodes.as_slice() else { return None }; + let [paint] = node.fills.as_slice() else { return None }; + let figcraft_color::PaintKind::Image(image) = &paint.kind else { return None }; + s.clipboard.images.get(&image.image).map(|data| (image.image.clone(), data.clone())) +} + fn paste(s: &mut Session, p: &Value) -> Result { + let sel = s.selection(); + if get(p, "parent").is_none() + && get(p, "x").is_none() + && get(p, "y").is_none() + && let [target] = sel.as_slice() + && s.doc()?.node(*target).is_some_and(|node| super::create::accepts_image_fill(&node.kind)) + && let Some((hash, data)) = copied_image(s) + { + let doc = s.doc_mut()?; + doc.images.entry(hash.clone()).or_insert(data); + let node = doc.get_mut(*target)?; + node.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; + node.styles.fill = None; + return Ok(json!({"ids": [target]})); + } let page = s.page()?; // Figma pastes into the selected frame (if one is selected), at the same position when it fits, // else in the frame's centre; otherwise in place on the page (or the viewport centre). - let sel = s.selection(); let doc = s.doc()?; let copy_local = s.ds()?.uid == s.clipboard.source_doc; let target = id(p, "parent").or_else(|| { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e81a7c49..2002be0b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -36,6 +36,22 @@ pub const UI_COMMANDS: &[&str] = &[ /// other way to read it). `at`: the page point to paste at (Paste here); `None` pastes in the /// middle of the view. pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { + let selected_layer = match app.session.selection().as_slice() { + [id] if at.is_none() + && app.session.doc().ok().and_then(|d| d.node(*id)).is_some_and(|n| figcraft_engine::cmd::create::accepts_image_fill(&n.kind)) => + { + Some(*id) + } + _ => None, + }; + if let Some(target) = selected_layer + && let Some((png, _, _)) = clipboard_png(app) + { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(png); + app.run("image.place", json!({"data": b64, "target": target})); + return; + } // Only HTML that holds readable design data takes this path; other HTML pastes as before. if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { let mut params = json!({"html": html}); diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index b6775f93..6fbc4101 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -58,6 +58,7 @@ impl Win { fn new() -> Self { let mut s = Session::new(); s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); let ctx = egui::Context::default(); let clip = Rc::new(RefCell::new(Clip::default())); let app = FigApp::new(s, Box::new(Desktop(clip.clone()))); @@ -176,10 +177,57 @@ fn cmd_v_pastes_an_image_copied_elsewhere() { w.copy_image_elsewhere(); w.cmd_v(CMD); assert_eq!(w.named("Pasted image"), 1, "the image was pasted from the key's release"); + w.app.session.execute("select.none", &json!({})).unwrap(); w.cmd_v(CMD); assert_eq!(w.named("Pasted image"), 2, "and again"); } +#[test] +fn cmd_v_fills_a_selected_oval_without_adding_a_layer() { + let mut w = Win::new(); + let oval = + NodeId(w.app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap()); + let before = w.app.session.doc().unwrap().node_count(); + let original_fill = w.app.session.doc().unwrap().node(oval).unwrap().fills.clone(); + w.copy_image_elsewhere(); + w.cmd_v(CMD); + + let doc = w.app.session.doc().unwrap(); + let node = doc.node(oval).unwrap(); + assert_eq!(doc.node_count(), before); + assert!(matches!(node.kind, figcraft_doc::NodeKind::Ellipse { .. })); + assert!(matches!(node.fills[0].kind, figcraft_color::PaintKind::Image(_))); + assert_eq!((node.width, node.height), (120.0, 80.0)); + assert_eq!(w.app.session.selection(), vec![oval]); + let png = figcraft_engine::cmd::file::export_bytes(doc, &[oval], figcraft_doc::ExportFormat::Png, 1.0).unwrap(); + let pixels = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(pixels.get_pixel(0, 0)[3], 0, "the picture stays inside the oval"); + assert_eq!(pixels.get_pixel(60, 40)[3], 255, "the oval contains the picture"); + + w.app.session.undo().unwrap(); + assert_eq!(w.app.session.doc().unwrap().node(oval).unwrap().fills, original_fill); +} + +#[test] +fn cmd_v_fills_a_selected_oval_with_an_image_copied_in_figcraft() { + let mut w = Win::new(); + w.copy_image_elsewhere(); + w.cmd_v(CMD); + let source = w.app.session.selection()[0]; + crate::menus::invoke(&mut w.app, "edit.copy"); + + let oval = + NodeId(w.app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap()); + let before = w.app.session.doc().unwrap().node_count(); + w.cmd_v(CMD); + + let doc = w.app.session.doc().unwrap(); + assert_eq!(doc.node_count(), before); + assert!(doc.contains(source)); + assert!(matches!(doc.node(oval).unwrap().fills[0].kind, figcraft_color::PaintKind::Image(_))); + assert_eq!(w.app.session.selection(), vec![oval]); +} + #[test] fn cmd_released_before_v_still_pastes_the_image() { let mut w = Win::new(); @@ -247,6 +295,7 @@ fn a_paste_event_from_another_key_does_not_block_the_next_cmd_v() { // Shift+Insert on Windows. w.frame(vec![Event::Paste("text".into())]); w.frame(vec![key(Key::Insert, false, Modifiers::SHIFT)]); + w.app.session.execute("select.none", &json!({})).unwrap(); w.copy_image_elsewhere(); w.cmd_v(CMD); assert_eq!(w.named("Pasted image"), 1); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d87103f0..3a53e747 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (pasting a copied image into one selected fillable layer; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -143,6 +143,10 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look - **Fixed (2026-10-17):** Shift+Cmd/Ctrl+V (Paste over selection) and Alt+Cmd/Ctrl+V (Paste properties) work on the desktop (they were bound but didn't act there, #716); Cmd/Ctrl+V pastes an image copied in another app (#663). Counts above unchanged (the suite checks bindings). +- **Fixed (2026-10-20):** Cmd/Ctrl+V makes an image fill when one fillable layer is selected (a + shape, frame, text or boolean result), whether the image came from the system clipboard or a + copied FigCraft image layer. With no selection it still creates an image layer; Paste here still + uses the requested position. The oval stays an oval and clips the image (`paste_image_tests`). - **Where the documentation disagrees with itself** (noted per row): pixel grid (Shift+' or Cmd+'), letter spacing (Alt or Cmd with < >; Cmd would be the font size keys, Alt bound), Windows remove fill (Alt+/ or Alt+,; Alt+, is letter spacing), the Layers panel on Windows (Alt+1 @@ -172,6 +176,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | A copied image pastes as the fill of one selected fillable layer, preserving its outline and size. With no selection, or with Paste here, the image remains a separate layer. Synthetic desktop clipboard tests cover both external and FigCraft copies, an oval's exported pixels, and Undo. Counts and hours unchanged | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | From a55963841973ae4fc31122c49349fe6aacc7cdee Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:57:10 +0500 Subject: [PATCH 014/208] M7: avoid Windows GNU UIA crash --- .cargo/config.toml | 7 +++++++ apps/figcraft/build.rs | 1 + apps/figcraft/src/main.rs | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index acebfe9f..8abc94d7 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,5 +3,12 @@ [env] CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true } +# The Windows UI Automation adapter can be asked about a node that disappeared in the +# same frame. On MinGW, converting that HRESULT through windows-result's thread COM +# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra +# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. +[target.x86_64-pc-windows-gnu] +rustflags = ["--cfg=windows_slim_errors"] + [alias] xtask = "run -q -p xtask --" diff --git a/apps/figcraft/build.rs b/apps/figcraft/build.rs index 64f97835..6ed8a047 100644 --- a/apps/figcraft/build.rs +++ b/apps/figcraft/build.rs @@ -1,6 +1,7 @@ //! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack //! (no-op elsewhere). fn main() { + println!("cargo::rustc-check-cfg=cfg(windows_slim_errors)"); println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico"); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); if target_os == "windows" { diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 00856c5d..887db45a 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -15,6 +15,11 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +// A missing Windows GNU flag re-enables the COM error-info lookup that crashes while AccessKit +// reports a removed UI Automation node. Catch build configurations that drop the workaround. +#[cfg(all(target_os = "windows", target_env = "gnu", not(windows_slim_errors)))] +compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Automation responsive"); + #[cfg(target_os = "macos")] mod apple_events; mod args; From cabe2e3cfb14a26cf4d5049ece9d27b812781f66 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 20 Oct 2026 15:58:55 +0200 Subject: [PATCH 015/208] i18n(pl): native review, catch-up and terminology fixes - Mark Polish catalog as reviewed and approved by native speaker (@lukasz-sklad) - Replace "Lekki" with "Jasny" for Light mode - Translate disambiguated context keys (Fill|sizing, Join|stroke, Light|glass, Open|comments) - Catch up with 15 UI strings added in recent main commits - Update docs/translating.md to list Polish as reviewed --- crates/ui-egui/locales/pl.toml | 30 +++++++++++++++++++++++------- docs/translating.md | 12 ++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 23f7dee8..b6a7bf53 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1,10 +1,11 @@ # FigCraft interface strings: Polish. # -# Draft: machine-drafted, waiting for a native speaker's review. +# Wording reviewed and approved by Lucas (@lukasz-sklad). +# Complete Polish localization for FigCraft. +# See docs/translating.md; plural forms follow Polish CLDR integer rules. [meta] name = "Polski" -status = "draft — needs native review" [ui] "% of all commits" = "% wszystkich commitów" @@ -171,6 +172,7 @@ status = "draft — needs native review" "Both directions" = "W obu kierunkach" "Bottom" = "Dół" "Bottom left radius" = "Promień lewego dolnego narożnika" +"Bottom padding" = "Margines dolny" "Bottom right radius" = "Promień prawego dolnego narożnika" "Branch" = "Gałąź" "Branch archived" = "Gałąź zarchiwizowana" @@ -501,7 +503,7 @@ status = "draft — needs native review" "Fill container" = "Wypełnij kontener" "Fill window" = "Wypełnij okno" "Fills" = "Wypełnienia" -"Fill|sizing" = "Fill|sizing" +"Fill|sizing" = "Wypełnij" "Filter and sort" = "Filtruj i sortuj" "Find" = "Znajdź" "Find and replace" = "Znajdź i zamień" @@ -632,6 +634,7 @@ status = "draft — needs native review" "Included" = "Uwzględniono" "Included in layout" = "Uwzględniono w układzie" "Individual corners" = "Niezależne narożniki" +"Individual padding" = "Niezależne marginesy" "Individual strokes" = "Niezależne obrysy" "Ink" = "Farba" "Inner shadow" = "Cień wewnętrzny" @@ -653,6 +656,7 @@ status = "draft — needs native review" "Interaction" = "Interakcja" "Interactions" = "Interakcje" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Projektowanie interfejsów i prototypowanie. Darmowe, otwartoźródłowe, czysto implementowane narzędzie projektowe napisane od podstaw w Rust, część ArtCraft Crafting Apps." +"Interface size" = "Rozmiar interfejsu" "Intersect" = "Przetnij" "Intersects" = "Przecina" "Invalid color" = "Nieprawidłowy kolor" @@ -669,7 +673,7 @@ status = "draft — needs native review" "Joined" = "Dołączono" "Joined {file}" = "Dołączono do: {file}" "Joining…" = "Dołączanie…" -"Join|stroke" = "Join|stroke" +"Join|stroke" = "Łączenie" "Jul" = "Lip" "Jun" = "Cze" "Just now" = "Przed chwilą" @@ -702,6 +706,7 @@ status = "draft — needs native review" "Leaves" = "Opuszcza" "Left" = "Do lewej" "Left & right" = "Lewa i prawa" +"Left padding" = "Margines lewy" "Left side" = "Lewa strona" "Left side width" = "Szerokość lewej strony" "Letter case" = "Wielkość liter" @@ -712,9 +717,9 @@ status = "draft — needs native review" "Library: click to collapse" = "Biblioteka: kliknij, aby zwinąć" "Library: click to expand" = "Biblioteka: kliknij, aby rozwinąć" "Library: its variables are read-only here; edit them in the library file and publish it" = "Biblioteka: jej zmienne są tutaj tylko do odczytu; edytuj je w pliku biblioteki i opublikuj" -"Light" = "Lekki" +"Light" = "Jasny" "Lighten" = "Jaśniej" -"Light|glass" = "Light|glass" +"Light|glass" = "Światło" "Line" = "Linia" "Line arrow" = "Strzałka liniowa" "Line delta" = "Różnica wierszy" @@ -927,7 +932,7 @@ status = "draft — needs native review" "Opened “{name}” in a new tab" = "Otwarto „{name}” w nowej karcie" "Opening the file…" = "Otwieranie pliku…" "Opening {n} files as new files" = { one = "Otwieranie jako nowy plik", few = "Otwieranie {n} plików jako nowe pliki", many = "Otwieranie {n} plików jako nowe pliki", other = "Otwieranie {n} plików jako nowe pliki" } -"Open|comments" = "Open|comments" +"Open|comments" = "Otwarte" "Options" = "Opcje" "Orange" = "Pomarańczowy" "Order" = "Kolejność" @@ -950,6 +955,7 @@ status = "draft — needs native review" "PDF print settings" = "Ustawienia druku PDF" "Padding" = "Marginesy wewnętrzne" "Page" = "Strona" +"Page deleted" = "Strona została usunięta" "Pages" = "Strony" "Pages added to the document" = "Strony dodane do dokumentu" "Placed on the current page" = "Umieszczono na bieżącej stronie" @@ -1061,6 +1067,7 @@ status = "draft — needs native review" "Reduce motion" = "Ogranicz ruch" "Refraction" = "Załamanie światła (refrakcja)" "Regular expression" = "Wyrażenie regularne" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Względem skalowania ekranu Twojego systemu (100% to domyślny rozmiar systemu). Powiększenie dokumentu jest niezależne." "Remove" = "Usuń" "Remove all" = "Usuń wszystko" "Remove annotation" = "Usuń adnotację" @@ -1099,6 +1106,7 @@ status = "draft — needs native review" "Rename on the server…" = "Zmień nazwę na serwerze…" "Rename page" = "Zmień nazwę strony" "Rename to" = "Zmień nazwę na" +"Reorder selection" = "Zmień kolejność zaznaczenia" "Replace all" = "Zamień wszystko" "Replace mixed export settings" = "Zastąp zróżnicowane ustawienia eksportu" "Replace with" = "Zamień na" @@ -1134,6 +1142,7 @@ status = "draft — needs native review" "Review and merge" = "Przejrzyj i scal" "Ribbon" = "Wstążka" "Right" = "Do prawej" +"Right padding" = "Margines prawy" "Right side" = "Prawa strona" "Right side width" = "Szerokość prawej strony" "Rotate 90°" = "Obróć o 90°" @@ -1184,6 +1193,7 @@ status = "draft — needs native review" "Search results" = "Wyniki wyszukiwania" "Search fonts" = "Szukaj czcionek" "Search layers" = "Szukaj warstw" +"Search pages" = "Szukaj stron" "Search shortcuts" = "Szukaj skrótów" "Section" = "Sekcja" "Section color" = "Kolor sekcji" @@ -1298,6 +1308,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "Zacznij od pustego płótna" "Start from a template" = "Zacznij od szablonu" "Start on path" = "Początek na ścieżce" +"Start on the Home screen" = "Rozpoczynaj od ekranu głównego" "Start point" = "Punkt początkowy" "Start sharing" = "Rozpocznij udostępnianie" "Start sharing with the Share button, then pass on the code it gives" = "Rozpocznij udostępnianie przyciskiem Udostępnij, a następnie przekaż wygenerowany kod" @@ -1371,6 +1382,7 @@ status = "draft — needs native review" "The selection uses several text styles: click to apply one" = "Zaznaczenie używa kilku stylów tekstu: kliknij, aby zastosować jeden" "The server deletes this file if nobody opens it before {date}." = "Serwer usunie ten plik, jeśli nikt go nie otworzy przed {date}." "The whole file" = "Cały plik" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "Okno jest zbyt małe dla {size}%, więc interfejs jest wyświetlany w {drawn}%. Powiększ okno, aby użyć {size}%." "Theme" = "Motyw" "There's no destination for that action" = "Brak miejsca docelowego dla tej akcji" "Thickness" = "Grubość" @@ -1381,6 +1393,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Ten warunek jest edytowany jako wyrażenie" "This file" = "Ten plik" "This file has unsaved changes" = "Ten plik ma niezapisane zmiany" +"This file is too large to join here (up to {mb} MB)" = "Ten plik jest zbyt duży, aby do niego dołączyć (maksymalnie {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Ten plik używa czcionek, które nie są zainstalowane na tym komputerze. Do czasu ich zainstalowania tekst jest wyświetlany czcionką zastępczą." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Ten plik znajduje się na {server}, otwarty tylko dla Ciebie. Udostępnij go, aby wpuścić innych nowym kodem." "This frame is new on the branch." = "Ta ramka jest nowa w gałęzi." @@ -1402,6 +1415,7 @@ status = "draft — needs native review" "Top" = "Góra" "Top & bottom" = "Góra i dół" "Top left radius" = "Promień górnego lewego narożnika" +"Top padding" = "Margines górny" "Top right radius" = "Promień górnego prawego narożnika" "Transform" = "Przekształć" "Triangle arrow" = "Strzałka trójkątna" @@ -1450,6 +1464,7 @@ status = "draft — needs native review" "Use number keys for opacity" = "Użyj klawiszy numerycznych dla nieprzezroczystości" "Use the .figcraft or .fig extension." = "Użyj rozszerzenia .figcraft lub .fig." "Use the layer's fill" = "Użyj wypełnienia warstwy" +"Use your browser's zoom to change the interface size." = "Użyj powiększenia przeglądarki, aby zmienić rozmiar interfejsu." "Used in {files} · {instances}" = "Używane w {files} · {instances}" "Username" = "Nazwa użytkownika" "Value" = "Wartość" @@ -1721,6 +1736,7 @@ status = "draft — needs native review" "{n}m" = "{n} min" "{n}w" = "{n} tyg." "{page}, nothing selected" = "{page}, nic nie zaznaczono" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (bieżący)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} to jej wartość; {property ? \"a\" : \"b\"} wybiera tekst. Pozostałe wyrażenia {…} pozostają bez zmian." "{server} can't say what became of your files (it is older than this app)." = "{server} nie może określić stanu Twoich plików (jest starszy niż ta aplikacja)." diff --git a/docs/translating.md b/docs/translating.md index eaa59755..7b01d391 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -17,12 +17,12 @@ Simplified. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish -(`fi`), Polish (`pl`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Georgian -(`ka`) was written with AI assistance and reviewed by a native speaker, and stays a draft until a -maintainer changes its status. Russian (`ru`, Русский) was -reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar -tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker, -and Swedish (`sv`) translates every UI string, command label and menu wording. +(`fi`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Polish (`pl`, +Polski) was reviewed and approved by Lucas (@lukasz-sklad). Georgian (`ka`) was written with AI +assistance and reviewed by a native speaker, and stays a draft until a maintainer changes its status. +Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and +Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and +reviewed by a native speaker, and Swedish (`sv`) translates every UI string, command label and menu wording. ## The catalogs From 728f5c48ef1a0357f1fd86b0c0a6b4939c2add45 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 20 Oct 2026 16:46:58 +0200 Subject: [PATCH 016/208] i18n(pl): add Polish labels for remaining 7 engine commands - Add Polish command labels for arrange.reorder, file.moveTab, pref.startOnHome, pref.uiScale, text.loadFont, text.refit, and text.rescanFonts. - Ensures 100% command coverage with zero fallback to English in the Actions palette. --- crates/ui-egui/locales/pl.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index b6a7bf53..209de490 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1779,6 +1779,7 @@ name = "Polski" "arrange.front" = "Przesuń na sam wierzch" "arrange.packH" = "Upakuj w poziomie" "arrange.packV" = "Upakuj w pionie" +"arrange.reorder" = "Zmień kolejność zaznaczenia" "arrange.roundToPixels" = "Zaokrąglij do pikseli" "arrange.spacing" = "Odstępy" "arrange.tidy" = "Uporządkuj" @@ -1906,6 +1907,7 @@ name = "Polski" "file.exportLayers" = "Eksportuj warstwy" "file.exportSvg" = "Eksportuj zaznaczenie jako SVG" "file.import" = "Importuj z pliku…" +"file.moveTab" = "Przenieś kartę" "file.new" = "Nowy plik projektu" "file.newFromTemplate" = "Nowy z szablonu" "file.open" = "Otwórz…" @@ -2048,6 +2050,8 @@ name = "Polski" "pref.reduceMotion" = "Ogranicz animacje" "pref.renameDuplicates" = "Zmieniaj nazwy duplikatów" "pref.showDimensions" = "Pokazuj wymiary przy zaznaczeniu" +"pref.startOnHome" = "Rozpoczynaj od ekranu głównego" +"pref.uiScale" = "Rozmiar interfejsu" "pref.userName" = "Twoja nazwa w komentarzach" "proto.action.add" = "Dodaj akcję prototypu" "proto.action.move" = "Zmień kolejność akcji prototypu" @@ -2154,6 +2158,7 @@ name = "Polski" "text.listIndent" = "Zwiększ wcięcie listy" "text.listOutdent" = "Zmniejsz wcięcie listy" "text.listStyle" = "Styl listy" +"text.loadFont" = "Wczytaj plik czcionki" "text.maxLines" = "Maksymalna liczba wierszy" "text.missingFonts" = "Brakujące czcionki" "text.numberList" = "Lista numerowana" @@ -2162,7 +2167,9 @@ name = "Polski" "text.paragraphSpacing" = "Odstęp między akapitami" "text.pathOptions" = "Opcje ścieżki tekstu" "text.preview" = "Podgląd zmiany tekstu" +"text.refit" = "Dopasuj tekst do czcionek" "text.removeFromPath" = "Usuń tekst ze ścieżki" +"text.rescanFonts" = "Wyszukaj ponownie zainstalowane czcionki" "text.set" = "Ustaw tekst" "text.setFeatures" = "Funkcje OpenType" "text.setVariations" = "Osie czcionki zmiennej (Variable Font)" From 6165d2ec85a0b139a73af82b8e9e1e8d3d75c6d2 Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:12:47 +0500 Subject: [PATCH 017/208] M7: preserve bindings and design layers when pasting images --- crates/engine/src/cmd/create.rs | 10 ++++++-- crates/engine/src/cmd/edit.rs | 3 +-- crates/ui-egui/src/design_paste_tests.rs | 28 +++++++++++++++++++++ crates/ui-egui/src/menus.rs | 32 ++++++++++++------------ crates/ui-egui/src/paste_image_tests.rs | 17 +++++++++++++ 5 files changed, 70 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 68375716..dc316056 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -377,6 +377,13 @@ pub fn accepts_image_fill(kind: &NodeKind) -> bool { kind.is_shape() || kind.is_frame_like() || matches!(kind, NodeKind::Text(_) | NodeKind::BooleanOp { .. }) } +/// Replace the fill list and detach variable bindings on paints that the image replaces. +pub(crate) fn replace_with_image_fill(node: &mut Node, image: String, mode: ImageScaleMode) { + node.fills = vec![Paint::image(image, mode)]; + node.bound.retain(|key, _| !matches!(super::lists::list_binding(key), Some((super::lists::BoundList::Fills, _, _)))); + node.styles.fill = None; +} + /// Place an image as a rectangle with an image fill (Figma's image layer). pub fn place_image(s: &mut Session, p: &Value) -> Result { let cmd = "image.place"; @@ -407,8 +414,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result { let (hash, _, _) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?; let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash)); let node = doc.get_mut(id)?; - node.fills = vec![Paint::image(hash.clone(), mode)]; - node.styles.fill = None; + replace_with_image_fill(node, hash.clone(), mode); return Ok(json!({"id": id, "image": hash, "width": node.width, "height": node.height, "imageProfile": profile})); } let page = s.page()?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index dc9c7b77..93636cab 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -701,8 +701,7 @@ fn paste(s: &mut Session, p: &Value) -> Result { let doc = s.doc_mut()?; doc.images.entry(hash.clone()).or_insert(data); let node = doc.get_mut(*target)?; - node.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; - node.styles.fill = None; + super::create::replace_with_image_fill(node, hash, figcraft_color::ImageScaleMode::Fill); return Ok(json!({"ids": [target]})); } let page = s.page()?; diff --git a/crates/ui-egui/src/design_paste_tests.rs b/crates/ui-egui/src/design_paste_tests.rs index 35743a81..82c366c7 100644 --- a/crates/ui-egui/src/design_paste_tests.rs +++ b/crates/ui-egui/src/design_paste_tests.rs @@ -45,6 +45,34 @@ fn paste_takes_layers_copied_in_another_design_app() { assert_eq!(selected_names(&app), ["Copied layer"]); } +struct HtmlAndImage(String); + +impl Services for HtmlAndImage { + fn clipboard_get_html(&mut self) -> Option { + Some(self.0.clone()) + } + fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { + Some((2, 2, vec![255; 16])) + } +} + +#[test] +fn editable_design_layers_take_priority_over_a_png_representation() { + let mut session = Session::new(); + let selected = session.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap(); + let mut app = FigApp::new(session, Box::new(HtmlAndImage(copied_html()))); + let before = app.session.doc().unwrap().node_count(); + + crate::menus::paste(&mut app, None, None); + + assert_eq!(selected_names(&app), ["Copied layer"]); + assert_eq!(app.session.doc().unwrap().node_count(), before + 1); + assert!(matches!( + app.session.doc().unwrap().node(figcraft_doc::NodeId(selected)).unwrap().fills[0].kind, + figcraft_color::PaintKind::Solid { .. } + )); +} + #[test] fn paste_here_puts_copied_design_layers_at_the_point() { let html = copied_html(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2002be0b..b3647a8f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -36,22 +36,6 @@ pub const UI_COMMANDS: &[&str] = &[ /// other way to read it). `at`: the page point to paste at (Paste here); `None` pastes in the /// middle of the view. pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { - let selected_layer = match app.session.selection().as_slice() { - [id] if at.is_none() - && app.session.doc().ok().and_then(|d| d.node(*id)).is_some_and(|n| figcraft_engine::cmd::create::accepts_image_fill(&n.kind)) => - { - Some(*id) - } - _ => None, - }; - if let Some(target) = selected_layer - && let Some((png, _, _)) = clipboard_png(app) - { - use base64::Engine as _; - let b64 = base64::engine::general_purpose::STANDARD.encode(png); - app.run("image.place", json!({"data": b64, "target": target})); - return; - } // Only HTML that holds readable design data takes this path; other HTML pastes as before. if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { let mut params = json!({"html": html}); @@ -69,6 +53,22 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) } return; } + let selected_layer = match app.session.selection().as_slice() { + [id] if at.is_none() + && app.session.doc().ok().and_then(|d| d.node(*id)).is_some_and(|n| figcraft_engine::cmd::create::accepts_image_fill(&n.kind)) => + { + Some(*id) + } + _ => None, + }; + if let Some(target) = selected_layer + && let Some((png, _, _)) = clipboard_png(app) + { + use base64::Engine as _; + let b64 = base64::engine::general_purpose::STANDARD.encode(png); + app.run("image.place", json!({"data": b64, "target": target})); + return; + } let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); let ours = sys_text.is_some() && sys_text == app.ui.last_copy; let (cx, cy) = at.or_else(|| app.session.ds().ok().map(|d| (d.view.cx, d.view.cy))).unwrap_or((0.0, 0.0)); diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index 6fbc4101..c659569c 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -208,6 +208,23 @@ fn cmd_v_fills_a_selected_oval_without_adding_a_layer() { assert_eq!(w.app.session.doc().unwrap().node(oval).unwrap().fills, original_fill); } +#[test] +fn pasting_an_image_detaches_the_replaced_fill_variable() { + let mut w = Win::new(); + let oval = + NodeId(w.app.session.execute("shape.ellipse", &json!({"x": 200, "y": 100, "width": 120, "height": 80})).unwrap()["id"].as_u64().unwrap()); + w.app.session.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap(); + w.app.session.execute("variable.create", &json!({"collection": "Theme", "name": "brand", "type": "color", "value": "#FF0000"})).unwrap(); + w.app.session.execute("variable.bind", &json!({"ids": [oval.0], "field": "fill", "variable": "brand"})).unwrap(); + assert!(!w.app.session.doc().unwrap().node(oval).unwrap().bound.is_empty()); + + w.copy_image_elsewhere(); + w.cmd_v(CMD); + let node = w.app.session.doc().unwrap().node(oval).unwrap(); + assert!(matches!(node.fills[0].kind, figcraft_color::PaintKind::Image(_))); + assert!(node.bound.is_empty(), "the old colour variable must not remain on the image fill"); +} + #[test] fn cmd_v_fills_a_selected_oval_with_an_image_copied_in_figcraft() { let mut w = Win::new(); From 570144ee90e217da2ef27c1c5a4e839f6f0c06df Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:49:45 +0000 Subject: [PATCH 018/208] Reject multiple leading '#' in Color::from_hex Found while testing crates/color/src/lib.rs:125 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 3 ++- crates/color/src/tests.rs | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 8e9f98d9..e2ca7016 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -122,7 +122,8 @@ impl Color { /// Parse hex the way the colour field does: `#` optional, 1, 2, 3, 4, 6 or 8 digits /// (`F` → `FFFFFF`, `AB` → `ABABAB`, `ABC` → `AABBCC`). pub fn from_hex(s: &str) -> Option { - let s = s.trim().trim_start_matches('#'); + let s = s.trim(); + let s = s.strip_prefix('#').unwrap_or(s); if !s.chars().all(|c| c.is_ascii_hexdigit()) { return None; } diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 4cf875bb..fdec5b56 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -114,3 +114,12 @@ fn infinite_channels_clamp() { let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY); assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0)); } + +#[test] +fn hex_allows_at_most_one_leading_hash() { + assert!(Color::from_hex("##ff0000").is_none()); + assert!(Color::from_hex("###fff").is_none()); + assert!(Color::from_hex_or_name("##ff0000").is_none()); + assert_eq!(Color::from_hex("#ff0000").unwrap().to_hex(), "FF0000"); + assert_eq!(Color::from_hex("ff0000").unwrap().to_hex(), "FF0000"); +} From f3e5d2abc4c5db3289dce999bc1c1ed47303975d Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 21 Oct 2026 00:06:57 +0500 Subject: [PATCH 019/208] .fig import: a note says how much of the file it covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/format/src/fig/map.rs | 18 +++++---- .../map_connector_snapshot_budget_tests.rs | 40 +++++++++++++++++-- crates/format/src/rest/mod.rs | 19 +++++++-- 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 8e467151..f36cbb83 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -58,7 +58,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec { library_contexts: Vec, /// Layers visited while scanning internal canvases, across all of them. library_scan_count: usize, - warnings: Vec, + warnings: crate::rest::Warnings, count: usize, /// Most layers produced or scanned, instance expansion included; the one place the cap comes /// from (see [`super::layer_budget`]). @@ -464,7 +464,7 @@ impl<'a> Mapper<'a> { override_scope_entries: 0, library_contexts: Vec::new(), library_scan_count: 0, - warnings: Vec::new(), + warnings: crate::rest::Warnings::default(), count: 0, max_nodes, networks: Vec::new(), @@ -493,12 +493,14 @@ impl<'a> Mapper<'a> { .cloned() } + /// A note about what this file's mapping left out or changed. Counted, as the REST importer + /// counts its own: these notes are met once per layer, so the kinds were named once each with + /// no sign of how much of the file they covered -- a file whose every text layer lost a + /// setting read the same as one where a single layer did (#467). The collector keeps the + /// first-seen order and the same cap, since some notes name fields of the file (image + /// filters). fn warn(&mut self, w: impl Into) { - let w = w.into(); - // Capped like the REST importer's list: some warnings name fields of the file (image filters). - if self.warnings.len() < crate::rest::MAX_WARNINGS && !self.warnings.contains(&w) { - self.warnings.push(w); - } + self.warnings.add(w.into()); } fn children(&self, id: &str) -> Vec { diff --git a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs index ca47578d..8727c31b 100644 --- a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs +++ b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs @@ -115,14 +115,14 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit"); assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10"); assert_eq!(mapper.count, MAX_NODES); - assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); + assert!(mapper.warnings.seen().any(|warning| warning == format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES; let children = mapper.connector_snapshot_children(&view, "1:123"); assert!(children.is_empty()); assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded"); - assert!(mapper.warnings.iter().any(|warning| warning.contains("connector snapshot children were skipped"))); + assert!(mapper.warnings.seen().any(|warning| warning.contains("connector snapshot children were skipped"))); } #[test] @@ -133,7 +133,7 @@ fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { assert_eq!(depth_limited["type"], "VECTOR"); assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty()); assert!(depth_limited.get("children").is_none()); - assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); + assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES - 1; @@ -142,5 +142,37 @@ fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty()); assert!(budget_limited.get("children").is_none()); assert_eq!(mapper.count, MAX_NODES); - assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children")); + assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children")); +} + +/// A mapper note is met once per layer, so the kinds were named once each with no sign of how much +/// of the file they covered: a file whose every layer lost a setting read the same as one where a +/// single layer did (#467). They are counted now, with the `(×n)` the REST importer already uses +/// and the import report already reads back off before translating a note. +#[test] +fn a_note_met_more_than_once_carries_its_count() { + let message = message(); + + let mut mapper = Mapper::new(&message, MAX_NODES); + for _ in 0..2 { + mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + } + let notes = std::mem::take(&mut mapper.warnings).finish(); + let counted: Vec<&String> = notes.iter().filter(|n| n.contains("(×")).collect(); + assert!( + counted.iter().any(|n| n.ends_with("(×2)") && n.contains("connector label was skipped: depth limit")), + "the note met twice says so: {notes:?}" + ); + assert!(notes.iter().all(|n| !n.ends_with("(×1)")), "a count of one is left off: {notes:?}"); + + // One layer, and the same notes have no count at all. + let mut mapper = Mapper::new(&message, MAX_NODES); + mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + let once = std::mem::take(&mut mapper.warnings).finish(); + assert!(once.iter().all(|n| !n.contains("(×")), "{once:?}"); + assert_eq!( + once.len(), + counted.len() + notes.iter().filter(|n| !n.contains("(×")).count(), + "the same kinds, counted or not: {once:?} vs {notes:?}" + ); } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 78279e91..b86b0b91 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -329,15 +329,16 @@ fn children(v: &Value) -> &[Value] { v.get("children").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() } -/// Distinct warnings in first-seen order, with repeat counts. +/// Distinct warnings in first-seen order, with repeat counts. Shared with the `.fig` mapper +/// (`crate::fig::map`), whose notes repeat per layer in the same way. #[derive(Default)] -struct Warnings { +pub(crate) struct Warnings { list: Vec<(String, usize)>, index: BTreeMap, } impl Warnings { - fn add(&mut self, msg: String) { + pub(crate) fn add(&mut self, msg: String) { if let Some(i) = self.index.get(&msg) { if let Some(e) = self.list.get_mut(*i) { e.1 += 1; @@ -348,7 +349,17 @@ impl Warnings { } } - fn finish(self) -> Vec { + /// The notes met so far, without their counts, for the tests that ask whether one was met + /// (the product reads them through [`Self::finish`], with the counts). + #[cfg(test)] + pub(crate) fn seen(&self) -> impl Iterator { + self.list.iter().map(|(msg, _)| msg.as_str()) + } + + /// The notes, each with `(×n)` after it where it was met more than once. The import report + /// reads that suffix off before it looks the note up in the catalogue, so a counted note is + /// still translated (`ui-egui`'s `note_text`). + pub(crate) fn finish(self) -> Vec { self.list.into_iter().map(|(m, n)| if n > 1 { format!("{m} (×{n})") } else { m }).collect() } } From df5ffec87b8a1efbbef7ffe397ba33312df9345c Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:25:49 +0200 Subject: [PATCH 020/208] M0.9: allow literal dash-prefixed CLI filenames --- ROADMAP.md | 4 ++++ apps/figcraft-cli/src/args.rs | 4 ++++ apps/figcraft-cli/src/main.rs | 39 +++++++++++++++++++++------------- apps/figcraft-cli/tests/cli.rs | 32 ++++++++++++++++++++++++++++ docs/cli.md | 4 ++++ 5 files changed, 68 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4e0d088d..f80b829d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed + filenames open normally; the global `--no-system-fonts` flag also respects the terminator. + Binary regression coverage opens five such native files and checks their JSON info. + - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 4c162dbb..665f7ee0 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -18,6 +18,10 @@ impl Args { let mut a = Args::default(); let mut it = argv.iter(); while let Some(arg) = it.next() { + if arg == "--" { + a.positional.extend(it.cloned()); + break; + } let name = if let Some(n) = arg.strip_prefix("--") { n } else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) { diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index badc5bc3..c5d7833c 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -101,15 +101,24 @@ impl From for CliError { pub type CliResult = Result; -fn run(argv: &[String]) -> CliResult { - let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts"); - let argv: Vec = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect(); - if system_fonts { +fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult { + let flags = [flags, &["no-system-fonts"]].concat(); + let args = Args::parse(argv, valued, &flags)?; + if !args.flag("no-system-fonts") { // Text lays out with the installed fonts, like the desktop app, so headless renders and // auto layout sizes match what users see. figcraft_text::scan_system_fonts_on_demand(); } - let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) }; + Ok(args) +} + +fn run(argv: &[String]) -> CliResult { + let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else { + return Err(CliError::Usage("missing subcommand".into())); + }; + let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?; + // Keep leading global flags with the subcommand's options, where values and `--` are parsed. + let rest: Vec = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect(); match cmd.as_str() { "help" | "--help" | "-h" => { println!("{USAGE}"); @@ -120,13 +129,13 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "info" => { - let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?; + let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?; let file = a.one_positional("info ")?; confined(&a, || files::info(&file, a.flag("json"))) } "export" => { let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; + let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; let file = a.one_positional("export -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?; let target = @@ -134,7 +143,7 @@ fn run(argv: &[String]) -> CliResult { confined(&a, || files::export(&file, &out, &target)) } "convert" => { - let a = Args::parse(rest, &ROOT_FLAGS, &[])?; + let a = parse_args(&rest, &ROOT_FLAGS, &[])?; match a.positional.as_slice() { [i, o] => confined(&a, || files::convert(i, o)), _ => Err(CliError::Usage("convert ".into())), @@ -142,19 +151,19 @@ fn run(argv: &[String]) -> CliResult { } "render" => { let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; let file = a.one_positional("render -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o ".into()))?; confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref())) } "run" => { let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; let script = a.one_positional("run ")?; confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())) } "commands" => { - let a = Args::parse(rest, &["filter"], &["json"])?; + let a = parse_args(&rest, &["filter"], &["json"])?; if !a.positional.is_empty() { return Err(CliError::Usage("commands takes no positional arguments".into())); } @@ -163,7 +172,7 @@ fn run(argv: &[String]) -> CliResult { } "mcp" => { let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); } @@ -178,7 +187,7 @@ fn run(argv: &[String]) -> CliResult { mcp(attach.as_deref(), roots_of(&a)?) } "plugin" => { - let a = Args::parse(rest, &["server"], &[])?; + let a = parse_args(&rest, &["server"], &[])?; match a.positional.as_slice() { [verb, folder] if verb == "publish" => { let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?; @@ -188,10 +197,10 @@ fn run(argv: &[String]) -> CliResult { } } "perf" => { - let a = Args::parse(rest, &["layers", "instances"], &[])?; + let a = parse_args(&rest, &["layers", "instances"], &[])?; let sizes: Vec = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect(); let instances: Vec = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect(); - perf::run(&sizes, &instances, system_fonts) + perf::run(&sizes, &instances, !a.flag("no-system-fonts")) } other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))), } diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index eb42b8d8..1928cab5 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -14,6 +14,38 @@ fn tmp(name: &str) -> std::path::PathBuf { d } +#[test] +fn option_terminator_allows_dash_prefixed_filenames() { + let dir = tmp("option-terminator"); + let doc = figcraft_doc::Document::new("Dash-prefixed file"); + let bytes = figcraft_format::save_bytes(&doc, None).unwrap(); + for name in ["--drawing.figcraft", "-h", "--json", "--no-system-fonts", "--"] { + std::fs::write(dir.join(name), &bytes).unwrap(); + let output = bin().current_dir(&dir).args(["info", "--json", "--no-system-fonts", "--", name]).output().unwrap(); + assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr)); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["file"], name); + assert_eq!(info["name"], "Dash-prefixed file"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn global_font_flag_respects_option_values() { + for filter in ["--", "--no-system-fonts"] { + let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap(); + assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr)); + assert!(serde_json::from_slice::(&output.stdout).unwrap().is_array()); + } +} + +#[test] +fn global_font_flag_can_precede_the_subcommand() { + let output = bin().args(["--no-system-fonts", "commands", "--json"]).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(!serde_json::from_slice::>(&output.stdout).unwrap().is_empty()); +} + #[test] fn bad_arguments_exit_2_without_panicking() { for args in [ diff --git a/docs/cli.md b/docs/cli.md index 699b6ba2..37e4b594 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,6 +6,10 @@ commands, serve MCP and measure performance. Build it with Bad arguments print usage and exit with code 2; failures exit with 1. The CLI never panics on input. +Put options before `--` to pass a filename beginning with a dash, for example +`figcraft-cli info --json -- --drawing.figcraft`. Every argument after `--` is literal, +including a filename named `--no-system-fonts`. + ## Fonts Text lays out and renders with the same fonts as the desktop app: the fonts built into FigCraft From 12e0cdc147fd646e1c4eea550323de0bf8640494 Mon Sep 17 00:00:00 2001 From: Nicek Date: Wed, 21 Oct 2026 22:18:30 +0800 Subject: [PATCH 021/208] =?UTF-8?q?feat:=20=E4=B8=BA=E5=A1=AB=E5=85=85?= =?UTF-8?q?=E3=80=81=E6=8F=8F=E8=BE=B9=E5=92=8C=E6=95=88=E6=9E=9C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=8F=B3=E9=94=AE=E5=A4=8D=E5=88=B6=E7=B2=98=E8=B4=B4?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了在属性行和分区标题上右键菜单,支持复制粘贴单个填充、描边和效果。新增了相关的命令、测试和国际化文本,完善了剪贴板数据结构以支持这些新功能。 --- crates/engine/src/cmd/edit.rs | 103 +++++++++++++++++++++- crates/engine/src/lib.rs | 8 ++ crates/engine/src/property_copy_tests.rs | 90 +++++++++++++++++++ crates/ui-egui/locales/en.toml | 6 ++ crates/ui-egui/locales/es.toml | 12 +++ crates/ui-egui/locales/zh-Hans.toml | 12 +++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panel_remove_tests.rs | 19 ++++ crates/ui-egui/src/property_menu_tests.rs | 101 +++++++++++++++++++++ crates/ui-egui/src/right.rs | 60 +++++++++++-- crates/ui-egui/src/widgets.rs | 18 +++- docs/parity-checklist.md | 4 +- 12 files changed, 425 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/property_copy_tests.rs create mode 100644 crates/ui-egui/src/property_menu_tests.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..0791f7e3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -229,6 +229,22 @@ pub fn specs() -> Vec { spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)), spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)), spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)), + spec!("fill.copy", "Copy fill", "{id?, index? (default the top fill)}", false, |s, p| paint_copy(s, p, true)), + spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one)}", true, |s, p| paint_paste(s, p, true)), + spec!( + "stroke.copy", + "Copy stroke", + "{id?, index? (default the top stroke)} its paint and the stroke's weight, alignment, sides, caps, joins and dashes", + false, + |s, p| paint_copy(s, p, false) + ), + spec!( + "stroke.paste", + "Paste stroke", + "{ids?, index? (replace the stroke there; without: add one)} the copied stroke's settings come with its paint", + true, + |s, p| paint_paste(s, p, false) + ), spec!( "fill.pattern", "Pattern fill", @@ -276,6 +292,8 @@ pub fn specs() -> Vec { } ok() }), + spec!("effect.copy", "Copy effect", "{id?, index? (default the top effect)}", false, effect_copy), + spec!("effect.paste", "Paste effect", "{ids?, index? (replace the effect there; without: add one)}", true, effect_paste), spec!("grid.add", "Add layout grid", "{ids?, grid? {kind: grid|columns|rows, ..}}", true, |s, p| { let g: LayoutGrid = parse("grid.add", p, "grid")?.unwrap_or_default(); for id in req_targets("grid.add", s, p)? { @@ -608,7 +626,7 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { let props = s.clipboard.props.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. s.clipboard = - crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() }; + crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, ..crate::Clipboard::default() }; let mut out = paste(s, p)?; if !other.issues.is_empty() { out["warnings"] = json!(other.issues); @@ -1386,6 +1404,53 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { ok() } +/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke +/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along. +fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.copy" } else { "stroke.copy" }; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; + let at = opt_index(cmd, p, "index")?; + let (paint, stroke) = { + let doc = s.doc()?; + let n = doc.get(id)?; + let list = if fills { &n.fills } else { &n.strokes }; + let at = at.unwrap_or(list.len().saturating_sub(1)); + let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?; + (paint, (!fills).then(|| n.stroke.clone())) + }; + s.clipboard.paint = Some((fills, paint.clone())); + s.clipboard.stroke = stroke; + serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string())) +} + +/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A +/// stroke's copied settings go onto each target too. +fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.paste" } else { "stroke.paste" }; + let Some((copied_fills, paint)) = s.clipboard.paint.clone() else { + return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into())); + }; + if copied_fills != fills { + let what = if fills { "stroke" } else { "fill" }; + return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}"))); + } + let ids = req_targets(cmd, s, p)?; + let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?}); + let out = match opt_index(cmd, p, "index")? { + Some(at) => { + q["index"] = json!(at); + paint_set(s, &q, fills)? + } + None => paint_list(s, &q, fills, true)?, + }; + if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) { + for id in ids { + s.doc_mut()?.get_mut(id)?.stroke = stroke.clone(); + } + } + Ok(out) +} + fn pattern_fill(s: &mut Session, p: &Value) -> Result { const CMD: &str = "fill.pattern"; let src = req_id(CMD, p, "source")?; @@ -1422,3 +1487,39 @@ fn effect_add(s: &mut Session, p: &Value) -> Result { } ok() } + +/// Copy effect `index` (the top one by default) of the first target into the clipboard. +fn effect_copy(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.copy"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?; + let at = opt_index(CMD, p, "index")?; + let e = { + let doc = s.doc()?; + let list = &doc.get(id)?.effects; + let at = at.unwrap_or(list.len().saturating_sub(1)); + list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))? + }; + s.clipboard.effect = Some(e.clone()); + serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string())) +} + +/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one. +fn effect_paste(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.paste"; + let Some(e) = s.clipboard.effect.clone() else { + return Err(EngineError::Disabled(CMD.into(), "no copied effect".into())); + }; + let at = opt_index(CMD, p, "index")?; + for id in req_targets(CMD, s, p)? { + let n = s.doc_mut()?.get_mut(id)?; + match at { + Some(i) => { + if let Some(slot) = n.effects.get_mut(i) { + *slot = e.clone(); + } + } + None => n.effects.push(e.clone()), + } + } + ok() +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..3cb7de22 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -759,6 +759,12 @@ pub struct Clipboard { pub images: std::collections::BTreeMap, /// Copied properties (Copy properties / Paste properties). pub props: Option, + /// A copied fill or stroke row (`fill.copy`, `stroke.copy`): whether it's a fill, and the paint. + pub paint: Option<(bool, figcraft_color::Paint)>, + /// The stroke settings (`weight`, alignment, sides…) copied with a stroke's paint. + pub stroke: Option, + /// A copied effect row (`effect.copy`). + pub effect: Option, /// The page-space bounds of the copied selection. pub bounds: Option, pub source_doc: u64, @@ -1787,6 +1793,8 @@ pub(crate) mod plugins_tests; #[cfg(test)] mod progressive_blur_tests; #[cfg(test)] +mod property_copy_tests; +#[cfg(test)] mod proto_behaviour_tests; #[cfg(test)] mod proto_conditional_tests; diff --git a/crates/engine/src/property_copy_tests.rs b/crates/engine/src/property_copy_tests.rs new file mode 100644 index 00000000..f6cc70d7 --- /dev/null +++ b/crates/engine/src/property_copy_tests.rs @@ -0,0 +1,90 @@ +//! Copying one fill, stroke or effect row (`fill.copy`, `stroke.copy`, `effect.copy`) and pasting +//! it onto other layers (`fill.paste`, `stroke.paste`, `effect.paste`): the Design panel's row +//! menus. + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{Effect, EffectKind, NodeId}; +use serde_json::{Value, json}; + +use crate::Session; + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn rect(s: &mut Session) -> NodeId { + id(ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))) +} + +fn node(s: &Session, id: NodeId) -> figcraft_doc::Node { + s.doc().unwrap().node(id).unwrap().clone() +} + +#[test] +fn a_copied_fill_replaces_a_fill_row_or_adds_one() { + let mut s = Session::new(); + let a = rect(&mut s); + let b = rect(&mut s); + let red = Paint::solid(Color::from_hex("#FF0000").unwrap()); + ex(&mut s, "fill.add", json!({"ids": [a], "paint": red})); + let copied = ex(&mut s, "fill.copy", json!({"id": a, "index": 1})); + assert_eq!(copied["type"], "solid", "the copy reports the paint it took"); + // Onto b's fill row: it replaces that paint. + ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0})); + assert_eq!(node(&s, b).fills, vec![red.clone()]); + // With no row: it adds one on top. + ex(&mut s, "fill.paste", json!({"ids": [b]})); + assert_eq!(node(&s, b).fills, [red.clone(), red]); + // Each paste is one undo step. + ex(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, b).fills.len(), 1); +} + +#[test] +fn a_copied_stroke_pastes_as_a_stroke_only() { + let mut s = Session::new(); + let a = rect(&mut s); + let b = rect(&mut s); + let blue = Paint::solid(Color::from_hex("#0000FF").unwrap()); + ex(&mut s, "stroke.add", json!({"ids": [a], "paint": blue})); + ex(&mut s, "node.set", json!({"ids": [a], "props": {"strokeWeight": 3.0, "strokeAlign": "outside"}})); + ex(&mut s, "stroke.copy", json!({"id": a, "index": 0})); + ex(&mut s, "stroke.add", json!({"ids": [b]})); + ex(&mut s, "stroke.paste", json!({"ids": [b], "index": 0})); + assert_eq!(node(&s, b).strokes, [blue]); + assert_eq!(node(&s, b).stroke, node(&s, a).stroke, "the weight and alignment come with the paint"); + assert_eq!((node(&s, b).stroke.weight, node(&s, b).stroke.align), (3.0, figcraft_doc::StrokeAlign::Outside)); + // A copied fill doesn't paste as a stroke, and the target keeps its stroke. + ex(&mut s, "fill.copy", json!({"id": a})); + assert!(s.execute("stroke.paste", &json!({"ids": [b]})).is_err()); + assert_eq!(node(&s, b).strokes.len(), 1); + // A layer with no stroke has nothing to copy. + assert!(s.execute("stroke.copy", &json!({"id": b, "index": 1})).is_err()); +} + +#[test] +fn a_copied_effect_replaces_an_effect_row_or_adds_one() { + let mut s = Session::new(); + let a = rect(&mut s); + let b = rect(&mut s); + ex(&mut s, "effect.add", json!({"ids": [a], "effect": {"type": "dropShadow", "radius": 7, "offset": [2, 3]}})); + ex(&mut s, "effect.copy", json!({"id": a, "index": 0})); + let e = node(&s, a).effects[0].clone(); + // Onto b's effect row: it replaces that effect. + ex(&mut s, "effect.add", json!({"ids": [b], "type": "layerBlur"})); + ex(&mut s, "effect.paste", json!({"ids": [b], "index": 0})); + assert_eq!(node(&s, b).effects, vec![e.clone()]); + // With no row: it adds one on top. + ex(&mut s, "effect.paste", json!({"ids": [b]})); + assert_eq!(node(&s, b).effects, [e.clone(), e]); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(node(&s, b).effects.len(), 1); + let Effect { kind: EffectKind::DropShadow(shadow), visible } = node(&s, b).effects[0].clone() else { + panic!("the pasted effect is a drop shadow") + }; + assert_eq!((shadow.radius, shadow.offset, visible), (7.0, (2.0, 3.0), true)); +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..aa9f97b3 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -313,7 +313,10 @@ name = "English" "Copy as PNG" = "Copy as PNG" "Copy as SVG" = "Copy as SVG" "Copy code" = "Copy code" +"Copy effect" = "Copy effect" +"Copy fill" = "Copy fill" "Copy link" = "Copy link" +"Copy stroke" = "Copy stroke" "Copy the {label}" = "Copy the {label}" "Copy {value}" = "Copy {value}" "Corner radius" = "Corner radius" @@ -971,7 +974,10 @@ name = "English" "Pass through" = "Pass through" "Paste a link (https://…)" = "Paste a link (https://…)" "Paste a team library link (a view link)" = "Paste a team library link (a view link)" +"Paste effect" = "Paste effect" +"Paste fill" = "Paste fill" "Paste here" = "Paste here" +"Paste stroke" = "Paste stroke" "Paste to replace" = "Paste to replace" "Pasted design layers; {n} kinds of content were left out" = { one = "Pasted design layers; {n} kind of content was left out", other = "Pasted design layers; {n} kinds of content were left out" } "Pattern" = "Pattern" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..f47ab965 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -315,7 +315,10 @@ status = "draft — needs native review" "Copy as PNG" = "Copiar como PNG" "Copy as SVG" = "Copiar como SVG" "Copy code" = "Copiar código" +"Copy effect" = "Copiar efecto" +"Copy fill" = "Copiar relleno" "Copy link" = "Copiar enlace" +"Copy stroke" = "Copiar trazo" "Copy the {label}" = "Copiar {label}" "Copy {value}" = "Copiar {value}" "Corner radius" = "Radio de esquina" @@ -972,7 +975,10 @@ status = "draft — needs native review" "Pass through" = "Atravesar" "Paste a link (https://…)" = "Pega un enlace (https://…)" "Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)" +"Paste effect" = "Pegar efecto" +"Paste fill" = "Pegar relleno" "Paste here" = "Pegar aquí" +"Paste stroke" = "Pegar trazo" "Paste to replace" = "Pegar para reemplazar" "Pasted design layers; {n} kinds of content were left out" = { one = "Capas de diseño pegadas; se omitió {n} tipo de contenido", other = "Capas de diseño pegadas; se omitieron {n} tipos de contenido" } "Pattern" = "Patrón" @@ -1894,7 +1900,9 @@ status = "draft — needs native review" "edit.setDefaultProperties" = "Establecer propiedades predeterminadas" "edit.undo" = "Deshacer" "effect.add" = "Añadir efecto" +"effect.copy" = "Copiar efecto" "effect.move" = "Mover efecto" +"effect.paste" = "Pegar efecto" "effect.remove" = "Quitar efecto" "effect.set" = "Establecer efecto" "export.add" = "Añadir ajuste de exportación" @@ -1922,7 +1930,9 @@ status = "draft — needs native review" "file.saveVersion" = "Guardar en el historial de versiones" "file.versionHistory" = "Mostrar historial de versiones" "fill.add" = "Añadir relleno" +"fill.copy" = "Copiar relleno" "fill.move" = "Mover relleno" +"fill.paste" = "Pegar relleno" "fill.pattern" = "Relleno de patrón" "fill.remove" = "Quitar relleno" "fill.set" = "Establecer relleno" @@ -2112,7 +2122,9 @@ status = "draft — needs native review" "shapeBuilder.remove" = "Quitar regiones" "stroke.add" = "Añadir trazo" "stroke.brush" = "Pincel" +"stroke.copy" = "Copiar trazo" "stroke.move" = "Mover trazo" +"stroke.paste" = "Pegar trazo" "stroke.remove" = "Quitar trazo" "stroke.set" = "Establecer trazo" "stroke.widthProfile" = "Perfil de ancho" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 94c91942..befa9685 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -300,7 +300,10 @@ status = "draft — needs native review" "Copy as PNG" = "复制为 PNG" "Copy as SVG" = "复制为 SVG" "Copy code" = "复制代码" +"Copy effect" = "复制效果" +"Copy fill" = "复制填充" "Copy link" = "复制链接" +"Copy stroke" = "复制描边" "Copy the {label}" = "复制{label}" "Copy {value}" = "复制 {value}" "Corner radius" = "圆角半径" @@ -910,7 +913,10 @@ status = "draft — needs native review" "Pass through" = "穿透" "Paste a link (https://…)" = "粘贴链接(https://…)" "Paste a team library link (a view link)" = "粘贴团队库链接(查看链接)" +"Paste effect" = "粘贴效果" +"Paste fill" = "粘贴填充" "Paste here" = "粘贴到此处" +"Paste stroke" = "粘贴描边" "Paste to replace" = "粘贴以替换" "Pasted design layers; {n} kinds of content were left out" = { other = "已粘贴设计图层;有 {n} 类内容未包含" } "Pattern" = "图案" @@ -1777,7 +1783,9 @@ status = "draft — needs native review" "edit.setDefaultProperties" = "设为默认属性" "edit.undo" = "撤销" "effect.add" = "添加效果" +"effect.copy" = "复制效果" "effect.move" = "移动效果" +"effect.paste" = "粘贴效果" "effect.remove" = "移除效果" "effect.set" = "设置效果" "export.add" = "添加导出设置" @@ -1804,7 +1812,9 @@ status = "draft — needs native review" "file.saveVersion" = "保存到版本历史" "file.versionHistory" = "显示版本历史" "fill.add" = "添加填充" +"fill.copy" = "复制填充" "fill.move" = "移动填充" +"fill.paste" = "粘贴填充" "fill.pattern" = "图案填充" "fill.remove" = "移除填充" "fill.set" = "设置填充" @@ -1989,7 +1999,9 @@ status = "draft — needs native review" "shapeBuilder.remove" = "移除区域" "stroke.add" = "添加描边" "stroke.brush" = "画笔" +"stroke.copy" = "复制描边" "stroke.move" = "移动描边" +"stroke.paste" = "粘贴描边" "stroke.remove" = "移除描边" "stroke.set" = "设置描边" "stroke.widthProfile" = "宽度配置" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..6dac06a6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -187,6 +187,8 @@ mod present_tests; #[cfg(test)] mod present_view_tests; #[cfg(test)] +mod property_menu_tests; +#[cfg(test)] mod proto_editor_tests; #[cfg(test)] mod proto_reorder_tests; diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index 3805dca3..bb74ee72 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -10,6 +10,7 @@ use serde_json::json; use crate::colorpicker::PickerTarget; use crate::layers_drag_tests::Ui; use crate::list_drag::grip_id; +use crate::live_tests::{flat, text_center}; fn id(v: serde_json::Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) @@ -151,6 +152,24 @@ fn adding_a_fill_or_stroke_opens_the_color_picker() { assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: false, index: 0, ids: vec![r] })); } +#[test] +fn an_empty_sections_header_adds_its_item_but_a_full_one_only_adds_from_the_plus() { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + // Nothing is stroked yet: clicking anywhere on the Stroke header adds one. + let shapes = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&shapes, "Stroke")); + assert_eq!(u.app.session.doc().unwrap().node(r).unwrap().strokes.len(), 1); + // It has a stroke now: the header is no longer a button of its own. + u.app.ui.picker = None; + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&shapes, "Stroke")); + assert_eq!(u.app.session.doc().unwrap().node(r).unwrap().strokes.len(), 1); +} + #[test] fn adding_a_fill_to_mixed_fills_opens_no_picker() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/property_menu_tests.rs b/crates/ui-egui/src/property_menu_tests.rs new file mode 100644 index 00000000..177c7720 --- /dev/null +++ b/crates/ui-egui/src/property_menu_tests.rs @@ -0,0 +1,101 @@ +//! The Design panel's property row menus: a secondary click on a fill, stroke or effect row +//! copies that one paint or effect, and on another layer's row pastes it there. + +use egui::{Event, Modifiers, PointerButton, Pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; + +fn id(v: &serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn rect(s: &mut Session) -> NodeId { + id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()) +} + +/// A click with `button` at `p`, and the frames that follow; what the last frame drew. +fn click(u: &mut Ui, p: Pos2, button: PointerButton) -> Vec { + for _ in 0..5 { + u.frame(vec![Event::PointerMoved(p)]); + } + let mut shapes = Vec::new(); + for pressed in [Some(true), Some(false)].into_iter().chain(std::iter::repeat_n(None, 20)) { + let events = pressed.map(|pressed| Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + shapes = flat(&u.frame_output(events.into_iter().collect()).shapes); + } + shapes +} + +/// The text a row menu offers, clicked. +fn pick(u: &mut Ui, menu: &[egui::Shape], label: &str) { + click(u, text_center(menu, label), PointerButton::Primary); +} + +#[test] +fn a_fill_row_copies_and_pastes_a_fill() { + let mut s = Session::new(); + let a = rect(&mut s); + let b = rect(&mut s); + s.execute("fill.set", &json!({"ids": [a], "color": "#FF0000"})).unwrap(); + s.execute("fill.set", &json!({"ids": [b], "color": "#0000FF"})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + let shapes = flat(&u.frame_output(vec![]).shapes); + let menu = click(&mut u, text_center(&shapes, "FF0000"), PointerButton::Secondary); + pick(&mut u, &menu, crate::i18n::tr("Copy fill")); + let copied = u.app.session.clipboard.paint.clone().expect("the fill is on the clipboard"); + assert_eq!(copied.1, u.app.session.doc().unwrap().node(a).unwrap().fills[0]); + // Onto b's fill row: it replaces that fill. + u.app.run("select.set", json!({"ids": [b]})); + let shapes = flat(&u.frame_output(vec![]).shapes); + let menu = click(&mut u, text_center(&shapes, "0000FF"), PointerButton::Secondary); + pick(&mut u, &menu, crate::i18n::tr("Paste fill")); + assert_eq!(u.app.session.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000"); +} + +#[test] +fn a_stroke_row_copies_and_pastes_a_stroke() { + let mut s = Session::new(); + let a = rect(&mut s); + let b = rect(&mut s); + s.execute("stroke.add", &json!({"ids": [a], "paint": {"type": "solid", "color": "#FF0000"}})).unwrap(); + s.execute("node.set", &json!({"ids": [a], "props": {"strokeWeight": 3.0}})).unwrap(); + s.execute("stroke.add", &json!({"ids": [b]})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + let shapes = flat(&u.frame_output(vec![]).shapes); + let menu = click(&mut u, text_center(&shapes, "FF0000"), PointerButton::Secondary); + pick(&mut u, &menu, crate::i18n::tr("Copy stroke")); + u.app.run("select.set", json!({"ids": [b]})); + let shapes = flat(&u.frame_output(vec![]).shapes); + let menu = click(&mut u, text_center(&shapes, "000000"), PointerButton::Secondary); + pick(&mut u, &menu, crate::i18n::tr("Paste stroke")); + let strokes = u.app.session.doc().unwrap().node(b).unwrap().strokes.clone(); + assert_eq!(strokes.len(), 1, "it replaced b's stroke"); + assert_eq!(strokes[0].solid_color().unwrap().to_hex(), "FF0000"); + assert_eq!(u.app.session.doc().unwrap().node(b).unwrap().stroke.weight, 3.0, "the weight came with it"); +} + +#[test] +fn an_effect_row_copies_and_pastes_an_effect() { + let mut s = Session::new(); + let a = rect(&mut s); + let b = rect(&mut s); + s.execute("effect.add", &json!({"ids": [a], "type": "layerBlur"})).unwrap(); + s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + let shapes = flat(&u.frame_output(vec![]).shapes); + let menu = click(&mut u, text_center(&shapes, "Layer blur"), PointerButton::Secondary); + pick(&mut u, &menu, crate::i18n::tr("Copy effect")); + u.app.run("select.set", json!({"ids": [b]})); + let shapes = flat(&u.frame_output(vec![]).shapes); + let menu = click(&mut u, text_center(&shapes, "Drop shadow"), PointerButton::Secondary); + pick(&mut u, &menu, crate::i18n::tr("Paste effect")); + let effects = u.app.session.doc().unwrap().node(b).unwrap().effects.clone(); + assert_eq!(effects, u.app.session.doc().unwrap().node(a).unwrap().effects); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index afa884a8..b2babc56 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -253,6 +253,15 @@ pub fn context_popup(resp: &egui::Response) -> egui::Popup<'static> { egui::Popup::context_menu(resp).width(MENU_MIN_W).style(menu_style) } +/// A context menu for a property row: a secondary click anywhere over `rect` opens it, even over +/// the row's fields (which would otherwise take the click), like the incumbent's row menus. +fn row_context_menu(ui: &Ui, rect: Rect, id: Id) -> egui::Popup<'static> { + let resp = ui.interact(rect, id, Sense::hover()); + let open = ui.rect_contains_pointer(rect) && ui.input(|i| i.pointer.secondary_clicked()); + let at_pointer = egui::Popup::menu(&resp).width(MENU_MIN_W).style(menu_style).at_pointer_fixed(); + at_pointer.open_memory(open.then_some(egui::SetOpenCommand::Bool(true))) +} + pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool { menu_row(ui, label, shortcut, 0.0, |_, _, _| {}).clicked() } @@ -1098,12 +1107,15 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: let title = if fills { tr("Fill") } else { tr("Stroke") }; let empty = list.as_ref().is_none_or(|l| l.is_empty()); let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke }; - if let Some(i) = widgets::section_header( + // The section's right-click menu adds the copied paint (`fill.paste` / `stroke.paste`). + let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") }; + let (clicked, header) = widgets::section_header_resp( ui, title, &[(Icon::Styles, tr("Styles")), (Icon::Plus, if fills { tr("Add fill") } else { tr("Add stroke") })], empty, - ) { + ); + if let Some(i) = clicked { if i == 1 { // The new paint goes on top; open its picker. A mixed selection has no one index to edit. if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some() @@ -1115,6 +1127,12 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: style_picker::open(app, ui, &ids, slot); } } + context_popup(&header).show(|ui| { + if app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills) && menu_item(ui, paste, "") { + app.run(if fills { "fill.paste" } else { "stroke.paste" }, json!({"ids": ids})); + ui.close(); + } + }); style_picker::chip(app, ui, doc, &ids, slot); match &list { None => widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary), @@ -1536,7 +1554,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, let t = Tokens::get(ui.ctx()); let ids = ids.to_vec(); let cmd = if fills { "fill.set" } else { "stroke.set" }; - row_rect(ui, 32.0, |ui| { + let r = row_rect(ui, 32.0, |ui| { let w = ui.available_width() - 56.0; let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover()); ui.painter().rect_filled(r, CornerRadius::same(5), t.field); @@ -1619,7 +1637,21 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, None => app.ui.picker = None, } } - }) + }); + let copy = if fills { tr("Copy fill") } else { tr("Copy stroke") }; + let paste = if fills { tr("Paste fill") } else { tr("Paste stroke") }; + let can_paste = app.session.clipboard.paint.as_ref().is_some_and(|(f, _)| *f == fills); + row_context_menu(ui, r, Id::new(("paint_menu", fills, i))).show(|ui| { + if menu_item(ui, copy, "") { + app.run(if fills { "fill.copy" } else { "stroke.copy" }, json!({"ids": ids, "index": i})); + ui.close(); + } + if can_paste && menu_item(ui, paste, "") { + app.run(if fills { "fill.paste" } else { "stroke.paste" }, json!({"ids": ids, "index": i})); + ui.close(); + } + }); + r } /// A paint row's label (also what a drag of it carries): its colour, gradient kind or "Image". @@ -1637,13 +1669,20 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let list = common(doc, sel, |n| n.effects.clone()); let empty = list.as_ref().is_none_or(|l| l.is_empty()); - if let Some(i) = widgets::section_header(ui, tr("Effects"), &[(Icon::Styles, tr("Styles")), (Icon::Plus, tr("Add effect"))], empty) { + let (clicked, header) = widgets::section_header_resp(ui, tr("Effects"), &[(Icon::Styles, tr("Styles")), (Icon::Plus, tr("Add effect"))], empty); + if let Some(i) = clicked { if i == 1 { app.run("effect.add", json!({"ids": ids, "type": "dropShadow"})); } else { style_picker::open(app, ui, &ids, StyleSlot::Effect); } } + context_popup(&header).show(|ui| { + if app.session.clipboard.effect.is_some() && menu_item(ui, tr("Paste effect"), "") { + app.run("effect.paste", json!({"ids": ids})); + ui.close(); + } + }); style_picker::chip(app, ui, doc, &ids, StyleSlot::Effect); let Some(list) = list else { return }; let mut drag = ListDrag::new(ui, "effects"); @@ -1678,6 +1717,17 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { removed = Some(i); } }); + let can_paste = app.session.clipboard.effect.is_some(); + row_context_menu(ui, header, Id::new(("effect_menu", i))).show(|ui| { + if menu_item(ui, tr("Copy effect"), "") { + app.run("effect.copy", json!({"ids": ids, "index": i})); + ui.close(); + } + if can_paste && menu_item(ui, tr("Paste effect"), "") { + app.run("effect.paste", json!({"ids": ids, "index": i})); + ui.close(); + } + }); if open { effect_settings(app, ui, &ids, i, e); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 845dca9d..db83cbe9 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -826,9 +826,19 @@ pub fn disclosure(ui: &mut Ui, label: &str, open: bool) -> bool { /// A section header ("Fill", "Stroke"…) with optional right-side icon buttons; returns which /// button was clicked. `dim` greys the title (empty sections). pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: bool) -> Option { + section_header_resp(ui, title, buttons, dim).0 +} + +/// [`section_header`], with the header's own response for a right-click menu. +pub fn section_header_resp(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: bool) -> (Option, egui::Response) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); - let (rect, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); + let (rect, resp) = ui.allocate_exact_size(vec2(w, 40.0), Sense::click()); + // Only an empty section's header acts as its `+` button; elsewhere it is a right-click target + // alone, so it keeps the normal cursor. + if resp.hovered() && !dim { + ui.ctx().set_cursor_icon(egui::CursorIcon::Default); + } ui.painter().text( pos2(rect.min.x + PAD, rect.center().y), Align2::LEFT_CENTER, @@ -850,7 +860,11 @@ pub fn section_header(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], dim: b } x -= 24.0; } - out + // An empty section's whole header adds its item, not only its `+` button. + if out.is_none() && dim && resp.clicked() { + out = buttons.iter().position(|(icon, _)| *icon == Icon::Plus); + } + (out, resp) } /// An icon button at `rect` (hover highlight, tooltip); true when clicked. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 775a4fcb..13fda27c 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,8 +1,8 @@ # Parity checklist -> **Generated:** 2026-10-17 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-21 by `cargo xtask parity`. Do not edit by hand. -FigCraft commands registered for the incumbent's main menu and toolbar (561 commands in the engine). +FigCraft commands registered for the incumbent's main menu and toolbar (575 commands in the engine). **212 / 212 menu items (100.0%)** From 2a3c5b46892a6c930b7947f5bf91d83173826517 Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 00:16:44 +0800 Subject: [PATCH 022/208] =?UTF-8?q?feat:=20=E6=94=B9=E8=BF=9B=E5=8F=8C?= =?UTF-8?q?=E5=87=BB=E8=A1=8C=E4=B8=BA=EF=BC=8C=E7=BC=96=E8=BE=91=E5=BD=A2?= =?UTF-8?q?=E7=8A=B6=E7=82=B9=E6=97=B6=E6=9B=B4=E7=AC=A6=E5=90=88=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E4=B9=A0=E6=83=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .trae/rules/git-commit-message.md | 6 ++ .../engine/src/selection_behaviour_tests.rs | 24 +++++ crates/engine/src/tools.rs | 19 +++- crates/ui-egui/src/canvas.rs | 13 ++- .../ui-egui/src/canvas_double_click_tests.rs | 96 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/panel_width_tests.rs | 5 +- 7 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 .trae/rules/git-commit-message.md diff --git a/.trae/rules/git-commit-message.md b/.trae/rules/git-commit-message.md new file mode 100644 index 00000000..99f24731 --- /dev/null +++ b/.trae/rules/git-commit-message.md @@ -0,0 +1,6 @@ +--- +alwaysApply: true +scene: git_message +--- + +在此处编写规则,自定义 AI 生成提交信息的风格。 diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index bc961a4a..a54daf23 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -175,6 +175,30 @@ fn double_click_enters_one_level() { assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(k.r), "double-click on a shape edits its points"); } +#[test] +fn a_double_click_anywhere_on_the_selected_shape_edits_its_points() { + let mut s = Session::new(); + let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}})); + ex(&mut s, "stroke.add", json!({"ids": [r]})); + // Selected by its outline; the empty middle is no hit at all (the shape has no fill). + click(&mut s, 0.0, 50.0, NONE); + assert_eq!(s.selection(), vec![r]); + s.pointer(&ev(PointerKind::DoubleClick, 50.0, 50.0, NONE)).unwrap(); + assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(r), "a double-click on the shape edits its points"); +} + +#[test] +fn a_double_click_on_an_unselected_shape_edits_it_without_a_selecting_click() { + let mut s = Session::new(); + let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "select.none", json!({})); + // The press and release of the click that selects it, and the double-click, came in one frame: + // only the double-click reached the tool, with nothing selected. + s.pointer(&ev(PointerKind::DoubleClick, 50.0, 50.0, NONE)).unwrap(); + assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(r)); +} + #[test] fn marquee_selects_what_it_touches() { let mut s = Session::new(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f0115e1f..187e28d4 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1892,7 +1892,17 @@ impl Session { self.tool.overlay.marquee = None; self.cancel_interaction()?; } - let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) else { return Ok(()) }; + let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + // A double-click anywhere on the one selected shape edits its points, like Enter: also + // inside a hollow (unfilled) shape, where the hit test finds nothing under the pointer. + if let [id] = sel[..] + && doc.node(id).is_some_and(|n| n.kind.is_shape()) + && doc.world_bounds(id).is_some_and(|b| b.contains(ev.p())) + && hit_here.is_none_or(|h| h == id || doc.is_ancestor(id, h)) + { + return crate::vectoredit::begin_many(self, &[id]); + } + let Some(leaf) = hit_here else { return Ok(()) }; let target = select_target(&doc, leaf, &sel, true); // Text: edit at the click. if doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) @@ -1910,8 +1920,11 @@ impl Session { return Ok(()); } // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). - if doc.node(target).is_some_and(|n| n.kind.is_shape()) && sel.contains(&target) { - let mut ids = crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default(); + // A shape clicked on its own edits even when the click that selects it and the double-click + // arrived in one frame (a press and a release the canvas took together), so no selection was + // made yet; another layer's shapes are reached one level at a time, as below. + if doc.node(target).is_some_and(|n| n.kind.is_shape()) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) { + let mut ids = if sel.contains(&target) { crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default() } else { vec![target] }; ids.retain(|i| *i != target); ids.insert(0, target); return crate::vectoredit::begin_many(self, &ids); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 46451ae0..8126e6f3 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -604,7 +604,11 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { open_context_menu(app, pos, mods.command); return true; } - if resp.double_clicked() { + // A third quick click is counted as a triple, not a double (a click elsewhere in between still + // leaves its time in the sequence): it means the same here — edit, enter, open — unless a + // vector or text is being edited, where a triple already has its own meaning. + let editing = app.session.ds().is_ok_and(|d| d.vector_edit.is_some() || d.text_edit.is_some()); + if resp.double_clicked() || (resp.triple_clicked() && !editing) { let ev = pointer_event(app, PointerKind::DoubleClick, pos, &mods); if let Err(e) = app.session.pointer(&ev) { app.toast(e.to_string()); @@ -654,6 +658,13 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } if let Some(k) = kind { send(app, k, pos, &mods); + // The pointer also let go within this frame (a quick click, or a slow frame): the tools get + // the release too, so the press's gesture ends — a quick click with a shape tool still + // draws and returns to Move, and a double-click right after drawing finds the Move tool. + if matches!(k, PointerKind::Down) && primary_released { + app.canvas.dragging = false; + send(app, PointerKind::Up, pos, &mods); + } } true } diff --git a/crates/ui-egui/src/canvas_double_click_tests.rs b/crates/ui-egui/src/canvas_double_click_tests.rs index 1c89757f..c47faa74 100644 --- a/crates/ui-egui/src/canvas_double_click_tests.rs +++ b/crates/ui-egui/src/canvas_double_click_tests.rs @@ -2,11 +2,107 @@ //! began is dropped, so the tool is idle once the button is up. use egui::{Event, Modifiers, PointerButton, Pos2}; +use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; use crate::layers_drag_tests::Ui; +/// One press and release at `p`. +fn click(u: &mut Ui, p: Pos2) { + u.frame(vec![Event::PointerMoved(p)]); + for pressed in [true, false] { + u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } +} + +/// A double-click at `p`. +fn double_click_at(u: &mut Ui, p: Pos2) { + u.frame(vec![Event::PointerMoved(p)]); + for _ in 0..2 { + for pressed in [true, false] { + u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + } + u.frame(vec![]); +} + +#[test] +fn double_clicking_a_selected_shape_enters_vector_edit_like_enter() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let p = u.app.to_screen(250.0, 200.0); + // A click selects it; a double-click right after (three quick clicks) still edits its points. + click(&mut u, p); + assert_eq!(u.app.session.selection(), vec![r]); + double_click_at(&mut u, p); + assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a double-click enters vector edit"); + // Enter does the same. + u.app.session.execute("vector.edit.end", &json!({})).unwrap(); + u.frame(vec![Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(u.app.session.ds().unwrap().vector_edit.is_some()); +} + +#[test] +fn a_slower_double_click_still_edits_the_points() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + let p = u.app.to_screen(200.0, 200.0); + u.frame(vec![Event::PointerMoved(p)]); + let press = |u: &mut Ui, pressed| { + u.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + }; + // The first click, then 0.4 s: inside the platform's 0.5 s double-click window, past egui's + // default 0.3 s. + press(&mut u, true); + press(&mut u, false); + for _ in 0..24 { + u.frame(vec![]); + } + press(&mut u, true); + press(&mut u, false); + assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a double-click the platform takes edits its points"); +} + +#[test] +fn double_clicking_an_unselected_shape_edits_its_points() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let p = u.app.to_screen(250.0, 200.0); + // Nothing is selected: the double-click selects the shape and edits its points, like Figma. + double_click_at(&mut u, p); + let ve = u.app.session.ds().unwrap().vector_edit.clone(); + assert_eq!(ve.map(|v| v.id), Some(r)); +} + +#[test] +fn a_click_that_leaves_vector_edit_does_not_keep_the_next_double_click_out() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + let shape = u.app.to_screen(250.0, 200.0); + let empty = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0); + double_click_at(&mut u, shape); + assert!(u.app.session.ds().unwrap().vector_edit.is_some()); + // One click on empty canvas leaves the edit mode. + click(&mut u, empty); + assert!(u.app.session.ds().unwrap().vector_edit.is_none(), "a click outside leaves vector edit"); + // The next double-click edits the shape again. + double_click_at(&mut u, shape); + assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a double-click edits it again"); +} + /// Two presses and releases at the bottom left of the canvas, with `modifiers` held. fn double_click(u: &mut Ui, modifiers: Modifiers) { let pos = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6dac06a6..e24bbf24 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1374,6 +1374,9 @@ impl FigApp { // Cmd/Ctrl+= / - / 0 zoom the canvas (view.zoomIn, view.zoomOut, view.zoom100): egui's // own keyboard zoom would scale the whole interface on the same keys. ctx.options_mut(|o| o.zoom_with_keyboard = false); + // A double-click's window is the platform's (0.5 s, Windows' default), not egui's + // stricter 0.3 s: a double-click the OS takes must enter a shape's points too. + ctx.memory_mut(|m| m.options.input_options.max_double_click_delay = 0.5); theme::install_fonts(ctx, false, 0); self.fonts_installed = true; } else if figcraft_text::system_fonts_scanned() { diff --git a/crates/ui-egui/src/panel_width_tests.rs b/crates/ui-egui/src/panel_width_tests.rs index a5a84d04..8c61a3b1 100644 --- a/crates/ui-egui/src/panel_width_tests.rs +++ b/crates/ui-egui/src/panel_width_tests.rs @@ -51,8 +51,9 @@ fn drag(u: &mut Ui, from: Pos2, to: Pos2) { } fn double_click(u: &mut Ui, p: Pos2) { - // Well after any earlier click (a third quick click isn't a double-click). - for _ in 0..40 { + // Well after any earlier click (a third quick click, within twice the platform's 0.5 s + // double-click window, isn't a double-click). + for _ in 0..70 { u.frame(vec![]); } for _ in 0..2 { From e211bf5476c44512bf3238e3698c085267f5da9b Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 18:22:16 +0200 Subject: [PATCH 023/208] M1: large .fig files keep their layers and their library links (#981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/format/src/fig/layer_cap_tests.rs | 117 +++++++++++++++++++++++ crates/format/src/fig/map.rs | 32 ++++++- crates/format/src/fig/mod.rs | 17 +++- crates/format/src/rest/mod.rs | 33 +++++-- crates/ui-egui/locales/en.toml | 2 + crates/ui-egui/locales/es.toml | 2 + 6 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 crates/format/src/fig/layer_cap_tests.rs diff --git a/crates/format/src/fig/layer_cap_tests.rs b/crates/format/src/fig/layer_cap_tests.rs new file mode 100644 index 00000000..6eeb15f4 --- /dev/null +++ b/crates/format/src/fig/layer_cap_tests.rs @@ -0,0 +1,117 @@ +//! Large `.fig` files at the layer cap (#981): the budget a design-system file gets, the library +//! copies its instances need kept ahead of the pages, honest notes for what the cap kept out, and +//! one note for the cap itself. + +use figcraft_doc::{Document, NodeKind}; + +use super::kiwi::{Kv, Schema}; +use super::*; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn o(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn g(id: u64) -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))]) +} + +fn xy(x: f64, y: f64) -> Kv<'static> { + o(vec![("x", Kv::Float(x as f32)), ("y", Kv::Float(y as f32))]) +} + +/// A layer under `parent`; siblings come in id order. +fn node(id: u64, parent: Option, ty: &'static str, name: &str, more: Fields) -> Kv<'static> { + let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(format!("{id:08}")))]))); + } + fields.extend(more); + o(fields) +} + +fn instance(id: u64, page: u64, main: u64) -> Kv<'static> { + node(id, Some(page), "INSTANCE", "Instance", vec![("symbolData", o(vec![("symbolID", g(main))]))]) +} + +/// A main `id` of `layers` rectangles under `parent`. +fn main(id: u64, parent: u64, name: &str, layers: u64) -> Vec> { + let mut out = vec![node(id, Some(parent), "SYMBOL", name, vec![("size", xy(10.0, 10.0))])]; + out.extend((1..=layers).map(|i| node(id * 10_000 + i, Some(id), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + out +} + +/// A page (2) of `count` instances of a main kept on the file's hidden library canvas (3), as a +/// design system's file carries the library components it uses. +fn library_message(count: u64, main_layers: u64) -> Kv<'static> { + let mut nodes = vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Screens", vec![]), + node(3, Some(1), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + ]; + nodes.extend(main(20, 3, "Library main", main_layers)); + nodes.extend((0..count).map(|i| instance(1_000 + i, 2, 20))); + o(vec![("nodeChanges", Kv::Array(nodes))]) +} + +/// The `.fig` import's two stages at layer cap `cap`. +fn import_at(msg: &Kv, cap: usize) -> (Document, Vec) { + let (json, mut warnings, networks) = map::to_rest(msg, "Capped", cap); + let (doc, more) = crate::rest::import_with_networks(&json, &|_: &str| None, networks, cap).unwrap(); + warnings.extend(more); + (doc, warnings) +} + +fn linked_to(doc: &Document, main_name: &str) -> usize { + doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component, .. } if doc.node(component).is_some_and(|c| c.name == main_name))).count() +} + +#[test] +fn a_design_system_sized_canvas_gets_room() { + // The file in #981 stopped at 244 248 layers: a 7.8 MB canvas at 32 bytes a layer. + let reported = 244_248 * 32; + assert!(layer_budget(reported) >= 4 * 244_248, "{}", layer_budget(reported)); + // Small files still can't claim more than the REST import's cap, nor any file the ceiling. + assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); + assert_eq!(layer_budget(usize::MAX), MAX_FIG_NODES); +} + +#[test] +fn library_mains_come_before_the_pages_at_the_cap() { + // 40 instances of an 11-layer library main on a page, with room for 100 layers: mapped after + // the pages, the main found no room and every instance became a frame of "a component that + // isn't in the file". + let (doc, warnings) = import_at(&library_message(40, 10), 100); + assert!(warnings.iter().any(|w| w.contains("more than 100 layers")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); + let linked = linked_to(&doc, "Library main"); + assert!(linked >= 5, "{linked} instances linked to the library main: {warnings:?}"); + assert!(doc.nodes().any(|n| n.name == "Library main" && doc.is_remote(n.id))); +} + +#[test] +fn instances_of_a_main_the_cap_kept_out_say_so() { + // The page's instances come before their main: at the cap, the main is skipped though the + // file has it. + let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])]; + nodes.extend((0..20).map(|i| instance(100 + i, 2, 900))); + nodes.extend(main(900, 2, "Local main", 10)); + let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 60); + assert!(warnings.iter().any(|w| w.starts_with("instances whose component was past the layer limit were imported as frames")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); + assert!(!doc.nodes().any(|n| n.name == "Local main")); +} + +#[test] +fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() { + // A 1 001-layer library main used 210 times: about 210 000 layers in a file far too small to + // lift the cap above the REST import's 200 000. + let file = super::tests::canvas(&Schema::parse(export::SCHEMA).unwrap(), &library_message(210, 1_000)); + assert_eq!(layer_budget(file.len()), crate::rest::MAX_NODES); + let (doc, warnings) = import(&file, "Design system.fig").unwrap(); + let cap: Vec<_> = warnings.iter().filter(|w| w.contains("layers; the rest were skipped")).collect(); + assert_eq!(cap.len(), 1, "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); + assert!(linked_to(&doc, "Library main") >= 190, "{warnings:?}"); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 8e467151..7eb974da 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -42,6 +42,14 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec= m.max_nodes { + let capped: Vec = m.instance_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect(); + if !capped.is_empty() { + out[crate::rest::FIG_CAPPED_MAINS_FIELD] = Value::Array(capped); + } + } if !m.styles.is_empty() { out["styles"] = Value::Object(std::mem::take(&mut m.styles)); } @@ -389,6 +397,10 @@ struct Mapper<'a> { library_scan_count: usize, warnings: Vec, count: usize, + /// The mains in the file that instances name, and the mains mapped as layers: the difference + /// is what the layer cap kept out. + instance_mains: BTreeSet, + mapped_mains: BTreeSet, /// Most layers produced or scanned, instance expansion included; the one place the cap comes /// from (see [`super::layer_budget`]). max_nodes: usize, @@ -466,6 +478,8 @@ impl<'a> Mapper<'a> { library_scan_count: 0, warnings: Vec::new(), count: 0, + instance_mains: BTreeSet::new(), + mapped_mains: BTreeSet::new(), max_nodes, networks: Vec::new(), network_ids: HashMap::new(), @@ -556,9 +570,8 @@ impl<'a> Mapper<'a> { Some(d) => self.children(d), None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(), }; - let mut out = Vec::new(); let mut internal = Vec::new(); - let mut visible_pages = Vec::new(); + let mut visible = Vec::new(); for p in pages { let Some(&n) = self.nodes.get(&p) else { continue }; if n.get("type").and_then(Kv::str) != Some("CANVAS") { @@ -568,17 +581,20 @@ impl<'a> Mapper<'a> { // The hidden page that holds styles, variables and library copies. if n.get("internalOnly").and_then(Kv::bool) == Some(true) { internal.push((p, n)); - continue; + } else { + visible.push((p, n)); } - visible_pages.push(p.clone()); - out.push(self.page(&p, n)); } + // The library copies the pages use come first: in a file past the layer cap, the pages' + // last layers are skipped rather than the mains every instance of them needs (#981). + let visible_pages: Vec = visible.iter().map(|(p, _)| p.clone()).collect(); let required_mains = self.referenced_component_mains(&visible_pages); for (id, canvas) in internal { if let Some(context) = self.library_context(&id, canvas, &required_mains) { self.library_contexts.push(context); } } + let out: Vec = visible.into_iter().map(|(p, n)| self.page(&p, n)).collect(); if out.is_empty() { self.warn("the file has no pages"); } @@ -879,6 +895,9 @@ impl<'a> Mapper<'a> { self.warn("connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained"); } self.count += 1; + if own == "SYMBOL" && inst.is_none() { + self.mapped_mains.insert(id.to_string()); + } let mut o = Obj::new(); o.insert("id".into(), json!(rest_id)); // Private provenance for the .fig importer; ordinary REST import ignores it. @@ -1994,6 +2013,9 @@ impl<'a> Mapper<'a> { self.warn("instances of components that aren't in the file show no layers"); return None; } + if !self.instance_mains.contains(&sym) { + self.instance_mains.insert(sym.clone()); + } if outer.is_some_and(|i| i.symbols.contains(&sym)) { return None; } diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 4c103d08..c04043ae 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -23,6 +23,8 @@ mod export_tests; #[cfg(test)] mod interop_tests; pub mod kiwi; +#[cfg(test)] +mod layer_cap_tests; mod map; mod network; #[cfg(test)] @@ -64,8 +66,11 @@ const MAX_CHUNKS: usize = 16; /// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances /// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the /// budget follows what the file takes, not what it unpacks to: a small file can't claim a large -/// count, however it's built; a large one gets room. -const PACKED_BYTES_PER_LAYER: usize = 32; +/// count, however it's built; a large one gets room. Design systems are mostly instances, which +/// pack tightly: a 7.8 MB canvas stopped at 244 248 layers at 32 bytes a layer (#981). At 8, files +/// under about 1.6 MB keep the floor and the ceiling comes at a 16 MB canvas (about 11 GB and 40 s +/// to import 2 million synthetic layers; memory grows with the count, about 5.4 KB a layer). +const PACKED_BYTES_PER_LAYER: usize = 8; const MAX_FIG_NODES: usize = 2_000_000; /// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes. @@ -108,7 +113,13 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma drop(msg); let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned()); let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; - warnings.extend(more.into_iter().filter_map(map::adapt_warning)); + // Both stages stop at the same cap and say so in the same words: one note (#981). + for w in more.into_iter().filter_map(map::adapt_warning) { + let base = w.rsplit_once(" (×").map_or(w.as_str(), |(b, _)| b); + if !warnings.iter().any(|seen| seen == base) { + warnings.push(w); + } + } Ok((doc, warnings)) } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 78279e91..40c091c6 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -65,6 +65,8 @@ const MAX_DEPTH: usize = 256; pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource"; /// Hidden .fig library context; accepted only by the `.fig` import ([`import_with_networks`]). pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts"; +/// Ids of mains in a `.fig` file that its layer cap kept out; read only by the `.fig` import. +pub(crate) const FIG_CAPPED_MAINS_FIELD: &str = "_figCappedMains"; /// Longest SVG path string read from one geometry entry. const MAX_PATH_BYTES: usize = 4 << 20; /// Most path elements in one vector. @@ -215,6 +217,14 @@ fn import_parts( cx.style_definitions(v); cx.networks = networks; cx.max_nodes = max_nodes; + // The `.fig` mapper already kept the layers, library copies included, within the cap; each + // library copy's wrapper frame is the one layer here it doesn't count (#981). + let contexts = if fig_sources { v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() } else { &[] }; + if fig_sources { + cx.capped_mains = + v.get(FIG_CAPPED_MAINS_FIELD).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + cx.max_nodes = max_nodes.saturating_add(contexts.len()); + } for e in entries { let Some(root) = e.get("document") else { continue }; match text(root, "type") { @@ -236,11 +246,9 @@ fn import_parts( None => cx.warn("entries without a node type were skipped"), } } - if fig_sources { - for context in v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).into_iter().flatten() { - let store = cx.doc.ensure_library_store(); - cx.build(context, store, Space::default(), None, 0, false); - } + for context in contexts { + let store = cx.doc.ensure_library_store(); + cx.build(context, store, Space::default(), None, 0, false); } // Drop the empty page the new document started with once real pages exist. if let Some(p) = blank @@ -404,6 +412,8 @@ pub(crate) struct Ctx<'a> { networks: Vec>, /// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap. max_nodes: usize, + /// Mains the `.fig` file has but its layer cap kept out ([`FIG_CAPPED_MAINS_FIELD`]). + capped_mains: BTreeSet, } impl<'a> Ctx<'a> { @@ -432,6 +442,7 @@ impl<'a> Ctx<'a> { var_modes: BTreeMap::new(), networks: Vec::new(), max_nodes: MAX_NODES, + capped_mains: BTreeSet::new(), } } @@ -998,7 +1009,11 @@ impl<'a> Ctx<'a> { fn resolve_swap_defaults(&mut self) { for (owner, name, rest_id) in std::mem::take(&mut self.swap_defaults) { let Some(c) = self.component(&rest_id) else { - self.warn("instance swap properties whose default component isn't in the file were skipped"); + if self.capped_mains.contains(&rest_id) { + self.warn("instance swap properties whose default component was past the layer limit were skipped"); + } else { + self.warn("instance swap properties whose default component isn't in the file were skipped"); + } continue; }; let def = PropDef::InstanceSwap { default: c, preferred: Vec::new() }; @@ -1017,7 +1032,11 @@ impl<'a> Ctx<'a> { let frames: BTreeSet = self.instances.iter().map(|(i, _)| *i).collect(); let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new(); for (inst, j) in std::mem::take(&mut self.instances) { - match text(j, "componentId").and_then(|r| self.component(r)) { + let main = text(j, "componentId"); + match main.and_then(|r| self.component(r)) { + None if main.is_some_and(|r| self.capped_mains.contains(r)) => { + self.warn("instances whose component was past the layer limit were imported as frames"); + } None => self.warn("instances of components that aren't in the file were imported as frames"), Some(c) if self.doc.is_ancestor(c, inst) => self.warn("instances inside their own main component were imported as frames"), Some(c) => links.push((inst, c, j)), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..32045d4e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1595,10 +1595,12 @@ name = "English" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "image filters are ambiguous because both filter containers are present; filter conversion was skipped" "image filters use FigCraft's approximate renderer" = "image filters use FigCraft's approximate renderer" "instance swap properties whose default component isn't in the file were skipped" = "instance swap properties whose default component isn't in the file were skipped" +"instance swap properties whose default component was past the layer limit were skipped" = "instance swap properties whose default component was past the layer limit were skipped" "instances inside their own main component were imported as frames" = "instances inside their own main component were imported as frames" "instances of components that aren't in the file show no layers" = "instances of components that aren't in the file show no layers" "instances of components that aren't in the file were imported as frames" = "instances of components that aren't in the file were imported as frames" "instances that would nest a component inside itself were imported as frames" = "instances that would nest a component inside itself were imported as frames" +"instances whose component was past the layer limit were imported as frames" = "instances whose component was past the layer limit were imported as frames" "instances without a main component were imported as empty frames" = "instances without a main component were imported as empty frames" "invalid internal .fig text path data was ignored" = "invalid internal .fig text path data was ignored" "invalid or oversized vector networks import as outlines" = "invalid or oversized vector networks import as outlines" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..c6e77866 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1596,10 +1596,12 @@ status = "draft — needs native review" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "los filtros de imagen son ambiguos porque están los dos contenedores de filtros; se omitió la conversión de filtros" "image filters use FigCraft's approximate renderer" = "los filtros de imagen usan el renderizado aproximado de FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado no está en el archivo" +"instance swap properties whose default component was past the layer limit were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado quedó fuera del límite de capas" "instances inside their own main component were imported as frames" = "las instancias dentro de su propio componente principal se importaron como marcos" "instances of components that aren't in the file show no layers" = "las instancias de componentes que no están en el archivo no muestran capas" "instances of components that aren't in the file were imported as frames" = "las instancias de componentes que no están en el archivo se importaron como marcos" "instances that would nest a component inside itself were imported as frames" = "las instancias que anidarían un componente dentro de sí mismo se importaron como marcos" +"instances whose component was past the layer limit were imported as frames" = "las instancias cuyo componente quedó fuera del límite de capas se importaron como marcos" "instances without a main component were imported as empty frames" = "las instancias sin componente principal se importaron como marcos vacíos" "invalid internal .fig text path data was ignored" = "se ignoraron datos internos no válidos de texto en trazado del .fig" "invalid or oversized vector networks import as outlines" = "las redes vectoriales no válidas o demasiado grandes se importan como contornos" From 42bdfcce3494fe78a5a21e986a5f0af37f42f3e4 Mon Sep 17 00:00:00 2001 From: midasdf <205497066+midasdf@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:37:55 +0900 Subject: [PATCH 024/208] M4: CJK kinsoku completed to JIS X 4051 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JIS X 4051's line-start (行頭禁則) and line-end (行末禁則) sets were mostly there — closing punctuation and brackets, small kana, the prolonged sound mark, iteration marks and opening brackets already never start or end a line — but a few categories fell through: - the halfwidth small kana ァ–ッ (U+FF67..U+FF6F) and the halfwidth voicing marks ゙゚ (U+FF9E/U+FF9F) were not line-start prohibited, so a wrapped line could begin with them; - the spacing (semi-)voiced sound marks ゛゜ (U+309B/U+309C) and the kana iteration marks 〱〲〳〴〵 (U+3031..U+3035) likewise; - the halfwidth parentheses ⦅⦆ were missing from the line-end/line-start sets. Add them (ranges in `class`, the two parentheses in the OPEN and CLOSE strings), and pin every JIS category in the behaviour suite: the existing 22 cases stay and 9 join them (halfwidth small kana and small tsu, halfwidth and spacing voicing marks, kana and vertical kana iteration marks, both halfwidth parentheses), plus a wrap case where a halfwidth small kana moves down with its base (キャ) and a case showing a manual newline may still start a line with a prohibited character (禁則 is for automatic wrapping only). `break_opportunities` fails on the old sets at the first new halfwidth small-kana case. Latin wrapping, ideograph breaks and the rest of the behaviour suite are unchanged, and the headline numbers stay as they are. Verified: cargo test -p figcraft-text --lib 92 passed; cargo clippy -p figcraft-text --all-targets -- -D warnings clean; rustfmt clean on the touched files. Docs: docs/target-app-parity.md text behaviour row and ROADMAP progress log, revision history and header. --- ROADMAP.md | 4 +++- crates/text/src/behaviour_tests.rs | 26 ++++++++++++++++++++++++-- crates/text/src/breaks.rs | 15 ++++++++------- docs/target-app-parity.md | 7 ++++--- 4 files changed, 39 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..9ac090df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; previous: minor, community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. @@ -316,6 +317,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index b3cb4884..3312e69e 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -268,7 +268,7 @@ fn hangul_comes_from_a_korean_font_when_one_is_installed() { #[test] fn break_opportunities() { // (before, after, may break between, derivation) - let cases: [(char, char, bool, &str); 22] = [ + let cases: [(char, char, bool, &str); 32] = [ ('a', 'b', false, "inside a Latin word: never"), (' ', 'b', true, "after a space"), ('a', ' ', false, "before a space: the space hangs at the line's end"), @@ -291,6 +291,16 @@ fn break_opportunities() { ('。', '日', true, "after a full stop"), ('日', '「', true, "before an opening bracket"), ('\u{3000}', '日', true, "after an ideographic space"), + ('キ', 'ャ', false, "kinsoku: nor a halfwidth small kana"), + ('本', 'ッ', false, "kinsoku: nor the halfwidth small tsu"), + ('キ', '゙', false, "kinsoku: nor the halfwidth voiced sound mark"), + ('キ', '゚', false, "kinsoku: nor the halfwidth semi-voiced sound mark"), + ('本', '゛', false, "kinsoku: nor the spacing voiced sound mark"), + ('本', '゜', false, "kinsoku: nor the spacing semi-voiced sound mark"), + ('本', '〱', false, "kinsoku: nor a kana iteration mark"), + ('本', '〳', false, "kinsoku: nor a vertical kana repeat mark"), + ('本', '⦆', false, "kinsoku: nor a halfwidth closing parenthesis"), + ('⦅', '日', false, "kinsoku: a halfwidth opening parenthesis never ends a line"), ]; for (a, b, want, why) in cases { assert_eq!(break_between(a, b), want, "{a}{b}: {why}"); @@ -304,13 +314,14 @@ fn cjk_lines_wrap_between_ideographs_with_kinsoku() { } // Box width cut right after char `fit` (chars 0..fit fit, the next one doesn't): the first line // and how the second starts. (text, fit, first line, second line starts with, derivation) - let cases: [(&str, usize, &str, &str, &str); 6] = [ + let cases: [(&str, usize, &str, &str, &str); 7] = [ ("一二三四五六", 3, "一二三", "四", "ideographs break anywhere"), ("一二三。四五", 3, "一二", "三。", "。 can't start a line: 三 moves down with it"), ("一二「三」", 3, "一二", "「三", "「 can't end a line: it moves down"), ("日本語abc def", 5, "日本語", "abc", "no break inside a Latin word: before it"), ("abc 日本語です", 6, "abc 日本", "語", "a space or an ideograph is a break"), ("한국어 텍스트입니다", 5, "한국어 텍", "스", "Korean breaks between syllables (documentation silent)"), + ("一二キャッド", 3, "一二", "キャ", "a halfwidth small kana stays off the line start: キ moves down with it"), ]; for (s, fit, first, second, why) in cases { let t = text(s, TextAutoResize::Height); @@ -324,6 +335,17 @@ fn cjk_lines_wrap_between_ideographs_with_kinsoku() { } } +#[test] +fn a_manual_newline_may_start_a_line_with_a_prohibited_character() { + if face().is_none() { + return; + } + // The author's own line breaks are kept: 禁則 only applies to automatic wrapping. + let t = text("き\nっく", TextAutoResize::Height); + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["き\n", "っく"], "a manual break is not pushed out by kinsoku"); +} + #[test] fn full_width_space_hangs_and_breaks() { if face().is_none() { diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 3f5458ea..be6db8df 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -21,12 +21,13 @@ enum Class { Other, } -/// Opening brackets and quotes (line-end prohibited). -const OPEN: &str = "([{‘“〈《「『【〔〖〘〚〝([{「"; -/// Closing brackets, quotes and punctuation, iteration marks, small kana and the prolonged sound -/// mark (line-start prohibited; the small katakana extensions U+31F0..U+31FF are a range below). -const CLOSE: &str = - ")]},.:;!?’”‥…‼⁇⁈⁉、。々〉》」』】〕〗〙〛〜〞〟〻ぁぃぅぇぉっゃゅょゎゕゖゝゞ゠ァィゥェォッャュョヮヵヶ・ーヽヾ!),.:;?]}。」、・ー"; +/// Opening brackets and quotes (line-end prohibited, JIS X 4051's 行末禁則). +const OPEN: &str = "([{‘“〈《「『【〔〖〘〚〝([{「⦅"; +/// Closing brackets, quotes and punctuation, iteration marks, small kana, the prolonged sound mark +/// and the (semi-)voiced sound marks (line-start prohibited, JIS X 4051's 行頭禁則). Two blocks +/// are ranges in [`class`] instead: the small katakana extensions U+31F0..U+31FF and the halfwidth +/// small kana U+FF67..U+FF6F plus the halfwidth voicing marks U+FF9E/U+FF9F. +const CLOSE: &str = ")]},.:;!?’”‥…‼⁇⁈⁉、。々〉》」』】〕〗〙〛〜〞〟〻ぁぃぅぇぉっゃゅょゎゕゖゝゞ゠ァィゥェォッャュョヮヵヶ・ーヽヾ!),.:;?]}。」、・ー⦆゛゜〱〲〳〴〵"; fn class(c: char) -> Class { if c.is_ascii_alphanumeric() { @@ -35,7 +36,7 @@ fn class(c: char) -> Class { if OPEN.contains(c) { return Class::Open; } - if CLOSE.contains(c) || ('\u{31F0}'..='\u{31FF}').contains(&c) { + if CLOSE.contains(c) || ('\u{31F0}'..='\u{31FF}').contains(&c) || ('\u{FF67}'..='\u{FF6F}').contains(&c) || matches!(c, '\u{FF9E}' | '\u{FF9F}') { return Class::Close; } match c { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a1a5bf12..e08da2b1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; previous: minor, rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -289,8 +289,9 @@ Hours in this narrative are the builders' estimates; the tables above supersede Auto / % / px line heights with the leading split above and below every line, letter spacing in % and px, paragraph spacing and indent (start-aligned text only), resizing and truncation, decorations from the font's metrics; CJK line breaking (between ideographs, kana and Hangul, no - break inside Latin words, kinsoku for closing and opening punctuation, small kana and the - prolonged sound mark); a style's metrics from its own font, not a fallback; carets, Delete and + break inside Latin words, kinsoku to JIS X 4051 for closing and opening punctuation, small kana + (halfwidth too), the prolonged sound mark, the iteration marks, the (semi-)voiced sound marks + and the halfwidth parentheses); a style's metrics from its own font, not a fallback; carets, Delete and Backspace by grapheme cluster; double-click and word moves by script run; IME composition with CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`, `crates/engine/src/text_tail_tests.rs`): lists per paragraph, nested up to 8 levels with Tab and From 5e2957a9011d71ff8fc3469830eb28ca17a2d3ad Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 19:15:58 +0200 Subject: [PATCH 025/208] M1: Save, Save as and Export arrive as downloads in the browser (#969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause: in the web build no file has a path, so Save goes to Save as, and Save as and every Export go through Services::pick_save / pick_folder. WebServices keeps the trait's defaults there, which return no path, so the callback never ran: no download, no message. Only Variables ▸ Export used the platform's downloads (#368). Fix: where services.downloads() is true, those routes run their command without a path and hand the bytes to services.download(): - engine: file.saveAs takes `data: true` and returns the .figcraft file as base64 `data` with its `name`, marking it saved (no path, nothing remembered). Programmatic Save as without either still asks for a path. - ui: FigApp::download decodes a result's `data` and says when nothing arrived; Save as, one-off export (PNG/JPG/WebP/SVG/PDF), .fig export, frames to PDF and multi-layer export (one download per file) use it. Tests: engine save_as_data_hands_back_the_file_and_marks_it_saved; ui-egui web_save_tests (save, export, refused download) with the fake browser Services from variables_export_ui_tests. The UI tests fail without the fix. --- crates/engine/src/cmd/file.rs | 58 +++++++++++++++---- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/dialogs.rs | 15 ++++- crates/ui-egui/src/export_panel.rs | 17 ++++++ crates/ui-egui/src/lib.rs | 22 +++++++ crates/ui-egui/src/menus.rs | 9 +++ .../ui-egui/src/variables_export_ui_tests.rs | 8 +-- crates/ui-egui/src/web_save_tests.rs | 55 ++++++++++++++++++ 9 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/web_save_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7380711f..8a75efb7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -51,7 +51,7 @@ pub fn specs() -> Vec { spec!( "file.saveAs", "Save as", - "{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + "{path | data: true (no path: returns the file as base64 `data` with its `name`, for a download)} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", false, save_as ), @@ -843,37 +843,53 @@ fn color_profile(s: &mut Session, p: &Value) -> Result { pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { let path = string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); - path.is_some_and(|path| ext(&path) != "fig") + path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p)) +} + +/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download). +fn wants_data(p: &Value) -> bool { + p.get("data").and_then(Value::as_bool) == Some(true) } /// Save the file of the active tab (the main file for a branch tab, with its branches), or export /// the active document as an experimental `.fig` copy without changing its native save state. -fn save_to(s: &mut Session, path: String) -> Result { - if ext(&path) == "fig" { +/// Without a path the file comes back as base64 `data` for the caller to deliver (#969). +fn save_to(s: &mut Session, path: Option) -> Result { + if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") { return export_fig(s, &json!({"path": path})); } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; let mut snapshot = FileSnapshot::of(ds); let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); - if follows_path && ds.path.as_deref() != Some(path.as_str()) { - Arc::make_mut(&mut snapshot.doc).name = file_stem(&path); + if let Some(path) = path.as_deref() + && follows_path + && ds.path.as_deref() != Some(path) + { + Arc::make_mut(&mut snapshot.doc).name = file_stem(path); } let bytes = snapshot.encode()?; - write(&path, &bytes)?; + if let Some(path) = &path { + write(path, &bytes)?; + } ds.doc = snapshot.doc; - ds.path = Some(path.clone()); + if path.is_some() { + ds.path.clone_from(&path); + } ds.mark_saved(); ds.revision += 1; - let uid = ds.uid; + let (uid, name) = (ds.uid, format!("{}.figcraft", ds.doc.name)); branch_tabs_saved(s, uid); + let Some(path) = path else { + return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); + }; remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } fn save(s: &mut Session, p: &Value) -> Result { if let Some(path) = string(p, "path") { - return save_to(s, path); + return save_to(s, Some(path)); } match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. @@ -881,7 +897,7 @@ fn save(s: &mut Session, p: &Value) -> Result { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) } - Some(path) => save_to(s, path), + Some(path) => save_to(s, Some(path)), None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) @@ -895,8 +911,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result { if ext(&path).is_empty() { path.push_str(".figcraft"); } - save_to(s, path) + save_to(s, Some(path)) } + None if wants_data(p) => save_to(s, None), None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into())) @@ -1563,6 +1580,23 @@ mod rest_import_tests { assert_eq!(s.docs.len(), open, "a refused open adds nothing"); } + #[test] + fn save_as_data_hands_back_the_file_and_marks_it_saved() { + // The browser has no paths: Save as returns the file for a download (#969). + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path"); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); + assert_eq!(r["name"], "Untitled.figcraft"); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + let mut t = Session::new(); + t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap(); + assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen"); + } + #[test] fn open_and_import_rest_json() { let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..3ce0659d 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -319,6 +319,7 @@ name = "English" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" "Couldn't download the variables" = "Couldn't download the variables" +"Couldn't download {name}" = "Couldn't download {name}" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." "Couldn't read {name}." = "Couldn't read {name}." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..bd572dbe 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -10,6 +10,7 @@ status = "draft — needs native review" [ui] "Compatibility notes" = "Notas de compatibilidad" "Couldn't download the variables" = "No se pudieron descargar las variables" +"Couldn't download {name}" = "No se pudo descargar {name}" "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bf9f1b29..9695faee 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -665,7 +665,11 @@ pub(crate) fn file_saved(app: &mut FigApp, result: &Value) { } fn result_file_name(result: &Value) -> String { - result["path"].as_str().and_then(|p| Path::new(p).file_name()).map(|f| f.to_string_lossy().into_owned()).unwrap_or_default() + match result["path"].as_str() { + Some(p) => Path::new(p).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(), + // A download (#969). + None => result["name"].as_str().unwrap_or_default().to_owned(), + } } /// The `.fig` result stays an export: native save state is unchanged, and omissions are reported. @@ -681,6 +685,15 @@ pub(crate) fn fig_exported(app: &mut FigApp, result: &Value) { /// Save the whole file as `.fig`; the toast says how much was left out. fn save_fig(app: &mut FigApp, name: &str) { let filename = format!("{name}.fig"); + if app.services.downloads() { + if let Some(mut r) = app.run("file.export", json!({"format": "fig"})) + && app.download(&filename, &r) + { + r["name"] = json!(filename); + fig_exported(app, &r); + } + return; + } app.pick_save(tr("Export"), &filename, &["fig"], |app, p| { if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { fig_exported(app, &r); diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 8617c35a..6a4ca8ee 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -320,6 +320,15 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { [] => {} // The suggested name keeps only the file's own name (a layer's folders apply to folders). [one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)), + // No folders in the browser: each file arrives as its own download (#969). + _ if app.services.downloads() => { + let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets})) else { return }; + let files = r["files"].as_array().map_or(&[][..], Vec::as_slice); + let n = files.iter().filter(|f| app.download(f["name"].as_str().unwrap_or_default(), f)).count(); + if n > 0 { + app.toast(trn("Exported {n} files", n, &[])); + } + } _ => { let ids = ids.to_vec(); app.pick_folder(tr("Export"), move |app, dir| { @@ -337,6 +346,14 @@ pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting let ext = setting.format.ext(); let mut params = setting_params(setting); params["ids"] = json!(ids); + if app.services.downloads() { + if let Some(r) = app.run("file.export", params) + && app.download(file, &r) + { + app.toast(trf("Exported {name}", &[("name", &file)])); + } + return; + } app.pick_save(tr("Export"), file, &[ext], move |app, p| { params["path"] = json!(p.to_string_lossy()); if app.run("file.export", params).is_some() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..788bbf4f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -223,6 +223,8 @@ mod vector_tail_ui_tests; #[cfg(test)] mod web_clipboard_tests; #[cfg(test)] +mod web_save_tests; +#[cfg(test)] mod windows_qa_tests; #[cfg(test)] mod windows_tests; @@ -1140,6 +1142,14 @@ impl FigApp { } pub fn save_as_dialog(&mut self) { + if self.services.downloads() { + if let Some(r) = self.run("file.saveAs", json!({"data": true})) + && self.download(r["name"].as_str().unwrap_or_default(), &r) + { + dialogs::file_saved(self, &r); + } + return; + } let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return }; self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| { if p.extension().is_none_or(|e| e.is_empty()) { @@ -1244,6 +1254,18 @@ impl FigApp { self.start_dialog(|services| services.pick_save(title, name, exts), true, done); } + /// Hand a command result's base64 `data` over as a download named `name`, where the platform + /// saves by downloads rather than save dialogs (the browser, #969). Says so when nothing arrived. + pub(crate) fn download(&mut self, name: &str, result: &Value) -> bool { + use base64::Engine as _; + let bytes = result["data"].as_str().and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok()); + if bytes.is_some_and(|b| self.services.download(name, &b)) { + return true; + } + self.toast(i18n::trf("Couldn't download {name}", &[("name", &name)])); + false + } + /// Pick a folder; `done` runs as [`FigApp::pick_open`]'s does. pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { self.start_dialog(|services| services.pick_folder(title), true, done); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9659c6b3..afa34873 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -248,6 +248,15 @@ pub fn invoke(app: &mut FigApp, id: &str) { /// Every visible top-level frame of the page as one PDF page each, through a save dialog. fn export_frames_pdf(app: &mut FigApp) { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); + if app.services.downloads() { + let file = format!("{name}.pdf"); + if let Some(r) = app.run("file.exportFramesPdf", json!({})) + && app.download(&file, &r) + { + app.toast(trf("Exported {name}", &[("name", &file)])); + } + return; + } app.pick_save(tr("Export"), &format!("{name}.pdf"), &["pdf"], |app, p| { if app.run("file.exportFramesPdf", json!({"path": p.to_string_lossy()})).is_some() { app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); diff --git a/crates/ui-egui/src/variables_export_ui_tests.rs b/crates/ui-egui/src/variables_export_ui_tests.rs index 40a84393..ce51a096 100644 --- a/crates/ui-egui/src/variables_export_ui_tests.rs +++ b/crates/ui-egui/src/variables_export_ui_tests.rs @@ -9,12 +9,12 @@ use serde_json::{Value, json}; use crate::variables_panel::export_dialog; use crate::{FigApp, NoServices, Services}; -type Downloads = Arc)>>>; +pub(crate) type Downloads = Arc)>>>; /// Services that save by downloads and keep them (`deliver` false: the browser refused). -struct Browser { - got: Downloads, - deliver: bool, +pub(crate) struct Browser { + pub(crate) got: Downloads, + pub(crate) deliver: bool, } impl Services for Browser { diff --git a/crates/ui-egui/src/web_save_tests.rs b/crates/ui-egui/src/web_save_tests.rs new file mode 100644 index 00000000..7d1d75d2 --- /dev/null +++ b/crates/ui-egui/src/web_save_tests.rs @@ -0,0 +1,55 @@ +//! Save, Save as and Export where the platform saves by downloads (the browser, which has no save +//! dialogs or paths): the file arrives as a download instead of nothing happening (#969). + +use base64::Engine as _; +use figcraft_doc::{ExportSetting, NodeId}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::FigApp; +use crate::variables_export_ui_tests::{Browser, Downloads}; + +fn browser(deliver: bool) -> (FigApp, Downloads, NodeId) { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let id = serde_json::from_value(r["id"].clone()).unwrap(); + let got = Downloads::default(); + (FigApp::new(s, Box::new(Browser { got: got.clone(), deliver })), got, id) +} + +fn toast(app: &FigApp) -> String { + app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +#[test] +fn save_downloads_the_figcraft_file() { + let (mut app, got, _) = browser(true); + app.save(); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert_eq!(name, "Untitled.figcraft"); + let mut reopened = Session::new(); + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + reopened.execute("file.open", &json!({"data": data, "name": name})).unwrap(); + assert!(!app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), "Saved Untitled.figcraft"); +} + +#[test] +fn export_downloads_the_exported_file() { + let (mut app, got, id) = browser(true); + crate::export_panel::save_one(&mut app, &[id], &ExportSetting::default(), "Rectangle.png"); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert_eq!(name, "Rectangle.png"); + assert!(bytes.starts_with(b"\x89PNG")); + assert_eq!(toast(&app), "Exported Rectangle.png"); +} + +#[test] +fn a_refused_download_says_so() { + let (mut app, got, _) = browser(false); + app.save_as_dialog(); + assert_eq!(got.lock().unwrap().len(), 1); + assert_eq!(toast(&app), "Couldn't download Untitled.figcraft"); +} From 9315e5e014705e5866fa1d74fa8e9b2075a00b1c Mon Sep 17 00:00:00 2001 From: Kameron Date: Wed, 21 Oct 2026 11:19:47 -0700 Subject: [PATCH 026/208] command.batch: named steps ($ref) and slash paths in field A batch step can name itself with "as" and later steps refer to it with {"$ref": "name"}, alongside positional {"$step": n}. "field" now takes a slash path into the result as well as a key, so {"$ref": "copy", "field": "ids/0"} reaches the first id edit.duplicate returned (the convention variable.bind uses for fills/0/color; a leading "/" as in JSON Pointer also works, and a key holding "/" still matches first). Resolution moves to crates/engine/src/batch.rs (BatchStep, BatchScope), shared by command.batch, the CLI script runner, the control channel and the MCP batch tool, which now passes "as" through. Duplicate names fail before any step runs; unknown, same-step or later names, bad paths and out-of-range indexes fail the batch with the step, name and full path. $ref is reserved like $step; nested batches keep their own names. Fixes #994. Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-cli/src/files.rs | 65 ++++++-- crates/engine/src/batch.rs | 192 ++++++++++++++++++++++ crates/engine/src/batch_step_ref_tests.rs | 148 +++++++++++++++++ crates/engine/src/cmd/edit.rs | 9 +- crates/engine/src/collab_live_tests.rs | 6 +- crates/engine/src/lib.rs | 79 +++------ crates/engine/src/tests.rs | 5 + crates/mcp/src/tools.rs | 10 +- crates/mcp/tests/agent_tasks.rs | 29 ++++ docs/cli.md | 14 +- docs/control-protocol.md | 14 +- docs/mcp.md | 52 ++++-- 12 files changed, 516 insertions(+), 107 deletions(-) create mode 100644 crates/engine/src/batch.rs diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 46215599..d18e071a 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -4,7 +4,7 @@ use std::path::Path; use std::process::ExitCode; use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings}; -use figcraft_engine::Session; +use figcraft_engine::{BatchScope, BatchStep, Session}; use serde_json::{Value, json}; use crate::{CliError, CliResult}; @@ -309,7 +309,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul } /// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines. -pub fn parse_script(text: &str) -> Result, String> { +pub fn parse_script(text: &str) -> Result, String> { let steps: Vec = match serde_json::from_str::(text) { Ok(Value::Array(a)) => a, Ok(Value::Object(o)) if o.contains_key("commands") => { @@ -334,7 +334,8 @@ pub fn parse_script(text: &str) -> Result, String> { Some(p @ Value::Object(_)) => p.clone(), Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")), }; - Ok((cmd.to_string(), params)) + let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?; + Ok(BatchStep { name, ..BatchStep::new(cmd, params) }) }) .collect() } @@ -352,31 +353,30 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe None => Session::new(), }; // One undo step and all-or-nothing, the way the same commands behave through `command.batch` - // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and - // a step that fails takes the whole script back instead of leaving the earlier steps applied. - // The result lines are held back until the script is through, so none of them reports work that - // was rolled back. - let mut results: Vec = Vec::with_capacity(steps.len()); + // (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the + // steps before it, and a step that fails takes the whole script back instead of leaving the + // earlier steps applied. The result lines are held back until the script is through, so none of + // them reports work that was rolled back. + let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let mut failed_at = 0; let outcome = s.grouped(&format!("run {script}"), |s| { - for (i, (cmd, params)) in steps.iter().enumerate() { + for (i, step) in steps.iter().enumerate() { failed_at = i; - let v = s.batch_step(&results, i, cmd, params)?; + s.batch_step(&mut scope, &step.command, &step.params)?; s.ui_requests.clear(); - results.push(v); } Ok(()) }); if let Err(e) = outcome { - let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str()); + let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str()); let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); eprintln!("error: step {i} ({cmd}) failed: {e}"); eprintln!("the script's document changes were rolled back"); return Ok(ExitCode::from(1)); } - for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() { - println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})); + for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() { + println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v})); } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; @@ -494,6 +494,43 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// A script names its steps with `as` and reads paths into their results, like `command.batch`. + #[test] + fn a_script_refers_to_named_steps_and_paths() { + let dir = temp_dir("run-ref"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap(); + let doc = s.doc().unwrap(); + let top = doc.node(s.page().unwrap()).unwrap().children.clone(); + let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect(); + assert_eq!(names, ["Card", "Copy"]); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Two steps with one name fail before anything runs. + #[test] + fn a_script_with_a_name_twice_runs_nothing() { + let dir = temp_dir("run-dup-name"); + let script = dir.join("script.json"); + std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap(); + let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") }; + assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}"); + std::fs::remove_dir_all(dir).unwrap(); + } + /// A step that fails takes the whole script back: nothing is written, and the failure says so. #[test] fn a_failed_step_leaves_no_output() { diff --git a/crates/engine/src/batch.rs b/crates/engine/src/batch.rs new file mode 100644 index 00000000..5c4550ec --- /dev/null +++ b/crates/engine/src/batch.rs @@ -0,0 +1,192 @@ +//! Step references in `command.batch`: a step's params can use what an earlier step returned. +//! +//! `{"$step": n}` stands for the `id` in the result of step `n` (zero-based), and `{"$ref": "name"}` +//! for the `id` in the result of the step that called itself `name` with `"as": "name"`. Either +//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the +//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way. + +use std::collections::HashMap; + +use serde_json::{Map, Value}; + +use crate::{Result, bad}; + +/// Deepest params nesting the resolver walks before giving up. +const MAX_DEPTH: usize = 64; + +/// One step of a batch: a command, its params and the name later steps can call it by. +#[derive(Clone, Debug, PartialEq)] +pub struct BatchStep { + pub command: String, + pub params: Value, + /// The step's `as`, for `{"$ref": name}` in later steps. + pub name: Option, +} + +impl BatchStep { + /// A step without a name. + pub fn new(command: impl Into, params: Value) -> Self { + Self { command: command.into(), params, name: None } + } +} + +/// A step's `as` (`step` is its index, for the error): `None` when it has none. +pub fn step_name(step: usize, json: &Value) -> Result> { + match json.get("as") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(name)) if !name.is_empty() => Ok(Some(name.clone())), + Some(_) => Err(bad("command.batch", format!("step {step}: `as` must be a name (a non-empty string)"))), + } +} + +/// The steps of one batch run so far: their results, and which step each `as` name belongs to. +/// A nested `command.batch` step gets its own scope, so its references are to its own steps. +#[derive(Clone, Debug, Default)] +pub struct BatchScope { + names: HashMap, + results: Vec, +} + +impl BatchScope { + /// The scope for running `steps`. Two steps with the same name fail here, before any step runs. + pub fn new(steps: &[BatchStep]) -> Result { + let mut names = HashMap::new(); + for (i, step) in steps.iter().enumerate() { + let Some(name) = &step.name else { continue }; + if let Some(first) = names.insert(name.clone(), i) { + return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name"))); + } + } + Ok(Self { names, results: Vec::with_capacity(steps.len()) }) + } + + /// The results of the steps run so far, in order. + pub fn results(&self) -> &[Value] { + &self.results + } + + pub fn into_results(self) -> Vec { + self.results + } + + /// The index of the step that runs next. + pub fn next_step(&self) -> usize { + self.results.len() + } + + pub(crate) fn push(&mut self, result: Value) { + self.results.push(result); + } + + /// `params` of the next step with every reference in it replaced. + pub(crate) fn resolve(&self, params: &Value) -> Result { + self.resolve_in(params, 0) + } + + fn resolve_in(&self, v: &Value, depth: usize) -> Result { + let step = self.next_step(); + if depth > MAX_DEPTH { + return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels"))); + } + match v { + Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o), + Value::Object(o) => { + let mut out = Map::with_capacity(o.len()); + for (k, x) in o { + out.insert(k.clone(), self.resolve_in(x, depth + 1)?); + } + Ok(Value::Object(out)) + } + Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::>>().map(Value::Array), + _ => Ok(v.clone()), + } + } + + /// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for. + fn reference(&self, o: &Map) -> Result { + let step = self.next_step(); + let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}")); + let key = if o.contains_key("$step") { "$step" } else { "$ref" }; + if let Some(k) = o.keys().find(|k| *k != key && *k != "field") { + return Err(fail(format!("a `{key}` reference takes only `field`, not `{k}`"))); + } + let (n, who) = if key == "$step" { + let n = o.get("$step").and_then(Value::as_u64).ok_or_else(|| fail("`$step` must be a step index (0, 1, …)".into()))?; + let n = usize::try_from(n).ok().filter(|n| *n < step).ok_or_else(|| { + let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; + fail(format!("`$step` {n} must be an earlier step ({range})")) + })?; + (n, format!("step {n}")) + } else { + let name = o.get("$ref").and_then(Value::as_str).ok_or_else(|| fail("`$ref` must be a step name (a string)".into()))?; + let n = *self.names.get(name).ok_or_else(|| fail(format!("`$ref` \"{name}\" names no step ({})", self.known_names())))?; + if n >= step { + return Err(fail(format!("`$ref` \"{name}\" is step {n}, not an earlier step"))); + } + (n, format!("step {n} (\"{name}\")")) + }; + let path = match o.get("field") { + None => "id", + Some(Value::String(f)) => f.as_str(), + Some(_) => return Err(fail("`field` must be a string".into())), + }; + let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?; + lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}"))) + } + + fn known_names(&self) -> String { + let mut names: Vec<_> = self.names.iter().collect(); + names.sort_by_key(|(_, i)| **i); + if names.is_empty() { + return "no step has an `as` name".into(); + } + let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect(); + list.join(", ") + } +} + +/// `path` in `v`: a key as written, else a slash path (`ids/0`, `bounds/x`; a leading `/` as in +/// JSON Pointer is allowed). The error is the detail after "has no `path`". +fn lookup<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> { + // A key as written first, so a key holding `/` still works as it did before paths. + if let Some(x) = v.get(path) { + return Ok(x); + } + let hint = if path.contains('.') && !path.contains('/') { " (paths use `/`, as in `ids/0`)" } else { "" }; + walk(v, path.strip_prefix('/').unwrap_or(path)).map_err(|why| format!("{why}{hint}")) +} + +fn walk<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> { + let mut cur = v; + let mut walked = String::new(); + for seg in path.split('/') { + let here = if walked.is_empty() { "the result".to_string() } else { format!("`{walked}`") }; + cur = match cur { + Value::Object(o) => o.get(seg).ok_or_else(|| if walked.is_empty() { String::new() } else { format!(": {here} has no `{seg}`") })?, + Value::Array(a) => match seg.parse::() { + Ok(i) => a.get(i).ok_or_else(|| { + let items = if a.len() == 1 { "1 item".to_string() } else { format!("{} items", a.len()) }; + format!(": {here} has {items}") + })?, + Err(_) => return Err(format!(": {here} is a list, so `{seg}` must be an index (0, 1, …)")), + }, + _ => return Err(format!(": {here} is {}", kind(cur))), + }; + if !walked.is_empty() { + walked.push('/'); + } + walked.push_str(seg); + } + Ok(cur) +} + +fn kind(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "a boolean", + Value::Number(_) => "a number", + Value::String(_) => "a string", + Value::Array(_) => "a list", + Value::Object(_) => "an object", + } +} diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs index ac69118e..451a6300 100644 --- a/crates/engine/src/batch_step_ref_tests.rs +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -110,3 +110,151 @@ fn a_nested_batch_refers_to_its_own_steps() { let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one"); } + +/// Runs `commands` as a batch and returns the error message, which must be `BadParams`. +fn batch_error(s: &mut Session, commands: serde_json::Value) -> String { + match s.execute("command.batch", &json!({"commands": commands})) { + Err(EngineError::BadParams { msg, .. }) => msg, + other => panic!("expected BadParams, got {other:?}"), + } +} + +#[test] +fn a_named_step_is_referred_to_by_its_name() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "as": "card", "params": {"type": "frame", "width": 200, "height": 100}}, + {"command": "node.create", "as": "swatch", "params": {"type": "rectangle", "parent": {"$ref": "card"}, "width": 20, "height": 20}}, + {"command": "node.set", "params": {"ids": [{"$ref": "swatch", "field": "id"}], "props": {"name": "Swatch"}}}, + {"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0}}} + ]}), + ) + .unwrap(); + let (frame, rect, text) = (id(&results[0]), id(&results[1]), id(&results[3])); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(rect).unwrap().parent, Some(frame)); + assert_eq!(doc.node(rect).unwrap().name, "Swatch"); + assert_eq!(doc.node(text).unwrap().parent, Some(frame), "`$step` and `$ref` mix in one batch"); + assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step"); +} + +/// `edit.duplicate` answers `{"ids": [...]}`: a slash path in `field` takes one of them. +#[test] +fn a_field_path_indexes_into_a_list_in_the_result() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "as": "a", "params": {"type": "frame", "name": "A", "width": 10, "height": 10}}, + {"command": "node.create", "as": "b", "params": {"type": "frame", "name": "B", "width": 10, "height": 10}}, + {"command": "edit.duplicate", "as": "copies", "params": {"ids": [{"$ref": "a"}, {"$ref": "b"}]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copies", "field": "ids/1"}], "props": {"name": "B copy"}}}, + {"command": "node.set", "params": {"ids": [{"$step": 2, "field": "/ids/0"}], "props": {"name": "A copy"}}} + ]}), + ) + .unwrap(); + let copies = results[2]["ids"].as_array().unwrap().clone(); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(crate::params::id_of(&copies[0]).unwrap()).unwrap().name, "A copy", "a leading `/` works too"); + assert_eq!(doc.node(crate::params::id_of(&copies[1]).unwrap()).unwrap().name, "B copy"); +} + +#[test] +fn a_field_path_walks_nested_objects_and_takes_whole_values() { + let mut scope = crate::BatchScope::default(); + scope.push(json!({"ids": [7, 8], "bounds": {"x": 1.5, "corners": [{"r": 4}]}, "a/b": "slash key"})); + let resolved = scope + .resolve(&json!({ + "x": {"$step": 0, "field": "bounds/x"}, + "r": {"$step": 0, "field": "bounds/corners/0/r"}, + "all": {"$step": 0, "field": "ids"}, + "key": {"$step": 0, "field": "a/b"}, + "pointer": {"$step": 0, "field": "/ids"} + })) + .unwrap(); + assert_eq!(resolved, json!({"x": 1.5, "r": 4, "all": [7, 8], "key": "slash key", "pointer": [7, 8]}), "a key holding `/` is still read as a key"); +} + +#[test] +fn bad_names_and_paths_fail_and_roll_back() { + let mut s = Session::new(); + let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + let frame = |name: &str| json!({"command": "node.create", "as": name, "params": {"type": "frame", "width": 10, "height": 10}}); + let dup = json!({"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}}); + let set = |r: serde_json::Value| json!({"command": "node.set", "params": {"ids": [r], "props": {"name": "x"}}}); + let later = json!({"command": "node.create", "as": "later", "params": {"type": "frame", "width": 10, "height": 10}}); + for (commands, needle) in [ + (json!([frame("card"), frame("card")]), "step 1: `as` \"card\" is already step 0's name"), + (json!([frame("card"), set(json!({"$ref": "nope"}))]), "step 1: `$ref` \"nope\" names no step (\"card\" is step 0)"), + (json!([{"command": "node.create", "params": {"type": "frame"}}, set(json!({"$ref": "nope"}))]), "no step has an `as` name"), + (json!([frame("card"), set(json!({"$ref": "later"})), later]), "step 1: `$ref` \"later\" is step 2, not an earlier step"), + ( + json!([frame("card"), {"command": "node.set", "as": "self", "params": {"ids": [{"$ref": "self"}], "props": {}}}]), + "is step 1, not an earlier step", + ), + (json!([frame("card"), set(json!({"$ref": 0}))]), "`$ref` must be a step name"), + (json!([frame("card"), set(json!({"$ref": "card", "id": 3}))]), "a `$ref` reference takes only `field`, not `id`"), + (json!([frame("card"), set(json!({"$ref": "card", "$step": 0}))]), "a `$step` reference takes only `field`, not `$ref`"), + (json!([frame("card"), {"command": "node.create", "as": "", "params": {}}]), "step 1: `as` must be a name"), + (json!([frame("card"), {"command": "node.create", "as": 3, "params": {}}]), "step 1: `as` must be a name"), + ( + json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/1"}))]), + "step 2: step 1 (\"copy\")'s result has no `ids/1`: `ids` has 1 item", + ), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/first"}))]), "`ids` is a list, so `first` must be an index"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/0/x"}))]), "has no `ids/0/x`: `ids/0` is a number"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "map/0"}))]), "step 1 (\"copy\")'s result has no `map/0`"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids.0"}))]), "has no `ids.0` (paths use `/`, as in `ids/0`)"), + (json!([frame("card"), set(json!({"$step": 0, "field": "id/0"}))]), "has no `id/0`: `id` is a number"), + ] { + let msg = batch_error(&mut s, commands); + assert!(msg.contains(needle), "{msg:?} should contain {needle:?}"); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back"); + } +} + +#[test] +fn text_that_looks_like_a_named_reference_is_left_alone() { + let mut s = Session::new(); + let text = "{\"$ref\": \"card\"} $ref"; + let results = s.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": text}}]})).unwrap(); + let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone(); + assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == text)); +} + +/// A nested batch has its own names: it can't see the outer batch's, may reuse them, and the +/// outer batch can't see its. +#[test] +fn a_nested_batch_has_its_own_names() { + let mut s = Session::new(); + let frame = |name: &str| json!({"command": "node.create", "as": "frame", "params": {"type": "frame", "name": name, "width": 100, "height": 100}}); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + frame("Outer"), + {"command": "command.batch", "as": "inner", "params": {"commands": [ + frame("Inner"), + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}} + ]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "1/id"}], "props": {"name": "In inner"}}} + ]}), + ) + .unwrap(); + let inner = &results[1]; + let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); + assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own frame, not the outer one"); + assert_eq!(rect.name, "In inner", "a path indexes into a nested batch's result list"); + + let msg = batch_error( + &mut s, + json!([frame("Outer"), {"command": "command.batch", "params": {"commands": [ + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}} + ]}}]), + ); + assert!(msg.contains("`$ref` \"frame\" names no step"), "the outer names aren't visible inside: {msg}"); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..c033c2cd 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::*; -use crate::{EngineError, Result, Session, bad, spec}; +use crate::{BatchStep, EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { vec![ @@ -336,15 +336,16 @@ pub fn specs() -> Vec { } ok() }), - spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| { + spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| { let label = string(p, "label").unwrap_or_else(|| "Batch".into()); - let cmds: Vec<(String, Value)> = match get(p, "commands") { + let cmds: Vec = match get(p, "commands") { Some(Value::Array(a)) => { let mut out = Vec::new(); for (i, c) in a.iter().enumerate() { let cmd = c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?; - out.push((cmd.to_string(), c.get("params").cloned().unwrap_or(json!({})))); + let params = c.get("params").cloned().unwrap_or(json!({})); + out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) }); } out } diff --git a/crates/engine/src/collab_live_tests.rs b/crates/engine/src/collab_live_tests.rs index 5b759391..d53eeff2 100644 --- a/crates/engine/src/collab_live_tests.rs +++ b/crates/engine/src/collab_live_tests.rs @@ -246,9 +246,9 @@ fn a_group_of_commands_syncs_as_one_step() { let (f, r) = frame_and_rect(room.server.document()); let start = room.server.replica().clone(); let cmds = vec![ - ("node.set".to_string(), json!({"id": r, "props": {"name": "A"}})), - ("node.set".to_string(), json!({"id": f, "props": {"name": "B"}})), - ("shape.rectangle".to_string(), json!({"x": 0, "y": 0, "width": 5, "height": 5})), + crate::BatchStep::new("node.set", json!({"id": r, "props": {"name": "A"}})), + crate::BatchStep::new("node.set", json!({"id": f, "props": {"name": "B"}})), + crate::BatchStep::new("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5})), ]; room.s(0).batch("Three", &cmds).unwrap(); room.pump(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..048c0722 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod al_keys; pub mod annotations; pub mod arc_handles; pub mod background; +pub mod batch; pub mod booleans; pub mod branches; pub mod catalog; @@ -64,6 +65,7 @@ pub mod tools; pub mod vectoredit; pub mod widgets; +pub use batch::{BatchScope, BatchStep}; pub use cmd::{CommandSpec, all_commands, find_command}; pub use figcraft_collab; pub use figcraft_doc as doc; @@ -115,47 +117,6 @@ pub fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() } } -/// Deepest params nesting `resolve_step_refs` walks before giving up. -const STEP_REF_MAX_DEPTH: usize = 64; - -/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default -/// `id`) of `results[n]`, the result of an earlier batch step. -fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result { - if depth > STEP_REF_MAX_DEPTH { - return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels"))); - } - match v { - Value::Object(o) if o.contains_key("$step") => { - if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") { - return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`"))); - } - let n = o - .get("$step") - .and_then(Value::as_u64) - .ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?; - let field = match o.get("field") { - None => "id", - Some(Value::String(f)) => f.as_str(), - Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))), - }; - let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else { - let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; - return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})"))); - }; - result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`"))) - } - Value::Object(o) => { - let mut out = serde_json::Map::with_capacity(o.len()); - for (k, x) in o { - out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?); - } - Ok(Value::Object(out)) - } - Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::>>().map(Value::Array), - _ => Ok(v.clone()), - } -} - /// One undo step. #[derive(Clone, Debug)] pub struct HistoryEntry { @@ -1245,28 +1206,30 @@ impl Session { } /// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id` - /// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field), - /// so a step can target a layer that an earlier step of the same batch created. Any object - /// with a `$step` key in a step's params is such a reference, except inside a nested - /// `command.batch` step: its references are to its own steps. - pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result> { + /// in the result of the earlier step `n`, and `{"$ref": "name"}` by the `id` in the result of + /// the earlier step whose `as` is `name`; either takes a `field`, a key or slash path (`ids/0`) + /// into that result. So a step can target a layer that an earlier step of the same batch + /// created. Any object with a `$step` or `$ref` key in a step's params is such a reference, + /// except inside a nested `command.batch` step: its references are to its own steps. + pub fn batch(&mut self, label: &str, steps: &[BatchStep]) -> Result> { + let mut scope = BatchScope::new(steps)?; self.grouped(label, |s| { - let mut results: Vec = Vec::with_capacity(cmds.len()); - for (step, (id, p)) in cmds.iter().enumerate() { - let v = s.batch_step(&results, step, id, p)?; - results.push(v); + for step in steps { + s.batch_step(&mut scope, &step.command, &step.params)?; } - Ok(results) + Ok(scope.into_results()) }) } - /// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of - /// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one - /// undo step; a caller with its own list — the CLI's script runner — uses this so its steps - /// resolve `$step` the same way, inside its own [`Session::grouped`]. - pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result { - let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? }; - self.execute(cmd, &p) + /// The next step of a batch: the references in `params` resolve against `scope` (the steps + /// before it), the command runs and its result joins `scope`. [`Session::batch`] runs a list + /// of these as one undo step; a caller with its own list — the CLI's script runner — uses this + /// so its steps resolve references the same way, inside its own [`Session::grouped`]. + pub fn batch_step(&mut self, scope: &mut BatchScope, cmd: &str, params: &Value) -> Result<()> { + let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? }; + let v = self.execute(cmd, &p)?; + scope.push(v); + Ok(()) } /// Run `f` as one undo step called `label`: the commands it executes push no steps of their diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 646c7c4b..4b811673 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -425,6 +425,11 @@ fn every_command_survives_junk_params() { // Rotation origins and placing with a tool as junk. json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}), json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}), + // Batch step names and reference paths as junk. + json!({"commands": [{"command": "select.none", "as": "a"}, {"command": "select.none", "as": [1]}, {"command": "select.none", "as": "a"}]}), + json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [ + {"$ref": "\u{0}", "field": "ids/18446744073709551616/x"}, {"$ref": {"x": 1}}, {"$step": 0, "field": "//"}, {"$ref": "\u{0}", "field": "/"}], + "props": {"name": {"$ref": "nope", "field": 5}}}}]}), ]; for spec in all_commands() { if matches!( diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 7714da5a..1e4c887f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -128,10 +128,10 @@ pub fn tool_definitions() -> Vec { tool( "batch", "Run commands as one step", - "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use a layer an earlier step created: anywhere in its params, {\"$step\": n} stands for the `id` in step n's result (zero-based; {\"$step\": n, \"field\": \"…\"} picks another field). Example: [{\"command\":\"node.create\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$step\":0}}}] builds a frame and a text inside it in one call.", + "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned. Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.", obj( json!({ - "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}}), &["command"])}, + "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}, "as": string("Name for this step, unique in the batch; later steps use its result with {\"$ref\": name}")}), &["command"])}, "label": string("Undo history label (default \"Batch\")"), }), &["commands"], @@ -497,7 +497,11 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Result Date: Wed, 21 Oct 2026 20:35:13 +0200 Subject: [PATCH 027/208] M1: several exported layers arrive as one zip in the browser (#969) Cause: in the browser, exporting several layers started one download per file. Chrome holds back every download after the first until the site is allowed automatic downloads, so the other files didn't arrive. Fix: file.exportLayers takes `zip: true` (without `dir`) and returns the files together as one base64 zip `data`, written by a new figcraft_format::zip_files (the workspace's existing zip dependency). The UI asks for it when the platform saves by downloads and hands over one `.zip`. Test: ui-egui several_layers_download_as_one_zip (one download, a zip holding both layers). It fails on the previous commit (two downloads). --- crates/engine/src/cmd/file.rs | 13 +++++++++++-- crates/format/src/lib.rs | 14 ++++++++++++++ crates/ui-egui/src/export_panel.rs | 13 +++++++------ crates/ui-egui/src/web_save_tests.rs | 16 ++++++++++++++++ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8a75efb7..d5023372 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -65,7 +65,7 @@ pub fn specs() -> Vec { spec!( "file.exportLayers", "Export layers", - "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}", + "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}", false, export_layers ), @@ -1209,6 +1209,9 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); } let dir = string(p, "dir"); + // The browser saves one download at a time: several files go as one zip (#969). + let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false); + let mut members = Vec::new(); // Print options passed here stand in for every PDF setting's own. let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; let mut files = Vec::with_capacity(jobs.len()); @@ -1228,12 +1231,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { write(&path, &bytes)?; f["path"] = json!(path); } + None if zip => members.push((job.file.as_str(), bytes)), None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), } files.push(f); } let with_settings: std::collections::BTreeSet = jobs.iter().map(|j| j.id).collect(); - Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())})) + let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}); + if zip { + let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?; + r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive)); + } + Ok(r) } /// A PDF with one page per group of layers (an sRGB document), titled after the file; `area` diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9f7e51bc..d0775323 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -343,6 +343,20 @@ pub(crate) fn put( z.write_all(bytes).map_err(io_err) } +/// `files` (name, bytes; a `/` in a name makes a folder) as one zip archive: several exports +/// delivered as a single download (#969). +pub fn zip_files<'a>(files: impl IntoIterator) -> Result, FormatError> { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + for (name, bytes) in files { + put(&mut z, name, zip::write::SimpleFileOptions::default(), bytes)?; + } + z.finish().map_err(io_err)?; + } + Ok(buf.into_inner()) +} + /// Add `doc` as a `document.json`-style member, serialized straight into the compressor. fn put_json(z: &mut zip::ZipWriter, name: &str, doc: &Document) -> Result<(), FormatError> { z.start_file(name, json_options()).map_err(io_err)?; diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 6a4ca8ee..39fc4bd1 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -320,13 +320,14 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { [] => {} // The suggested name keeps only the file's own name (a layer's folders apply to folders). [one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)), - // No folders in the browser: each file arrives as its own download (#969). + // No folders in the browser, and it lets a page start one download at a time: the files + // arrive together as one zip (#969). _ if app.services.downloads() => { - let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets})) else { return }; - let files = r["files"].as_array().map_or(&[][..], Vec::as_slice); - let n = files.iter().filter(|f| app.download(f["name"].as_str().unwrap_or_default(), f)).count(); - if n > 0 { - app.toast(trn("Exported {n} files", n, &[])); + let name = format!("{}.zip", app.session.doc().map_or("Export", |d| d.name.as_str())); + if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets, "zip": true})) + && app.download(&name, &r) + { + app.toast(trn("Exported {n} files", r["files"].as_array().map_or(0, Vec::len), &[])); } } _ => { diff --git a/crates/ui-egui/src/web_save_tests.rs b/crates/ui-egui/src/web_save_tests.rs index 7d1d75d2..5fbb2524 100644 --- a/crates/ui-egui/src/web_save_tests.rs +++ b/crates/ui-egui/src/web_save_tests.rs @@ -53,3 +53,19 @@ fn a_refused_download_says_so() { assert_eq!(got.lock().unwrap().len(), 1); assert_eq!(toast(&app), "Couldn't download Untitled.figcraft"); } + +#[test] +fn several_layers_download_as_one_zip() { + // The browser lets a page start one download at a time: two downloads lost the second. + let (mut app, got, a) = browser(true); + let r = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + let b: NodeId = serde_json::from_value(r["id"].clone()).unwrap(); + app.session.execute("export.add", &json!({"ids": [a, b]})).unwrap(); + crate::export_panel::export_layers(&mut app, &[a, b], false); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert_eq!(name, "Untitled.zip"); + assert!(bytes.starts_with(b"PK\x03\x04")); + assert!(bytes.windows(15).any(|w| w == b"Rectangle 2.png"), "both layers are in the zip"); + assert_eq!(toast(&app), "Exported 2 files"); +} From 2118480e7af3c50334bf0b4c599ecb6a3ee010bd Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Wed, 21 Oct 2026 20:03:46 +0100 Subject: [PATCH 028/208] Dev Mode: React + Tailwind page code and Tailwind theme export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inspect.code {language: "tailwind"}: a layer tree as a React component styled with Tailwind CSS v4 utilities, no stylesheet. Auto layout maps to flex utilities on the spacing scale; fixed-width auto layout frames, text and wide layers become fluid (w-full max-w-[…]); instances are inlined with their overrides, except those connected to code (codeConnect), which are their snippet with one import each; bound variables are theme utilities (bg-accent, rounded-card). - mobile: (same layer names): mobile-first code, desktop classes that differ behind lg:, resets for desktop defaults, padding compared per side; layers only on desktop are hidden below lg, those only on mobile are listed in the header. - variable.export {format: "tailwind"}: @import "tailwindcss" and an @theme block (--color-*, --radius-*, --text-*, --spacing-*, --font-*), names unique per file and shared with page code. Builds on the token roles of the CSS export px fix (#1008). --- crates/engine/src/cmd/variables_io.rs | 24 +- crates/engine/src/codegen.rs | 87 ++++- crates/engine/src/lib.rs | 3 + crates/engine/src/tailwind.rs | 490 ++++++++++++++++++++++++++ crates/engine/src/tailwind_tests.rs | 335 ++++++++++++++++++ docs/mcp.md | 16 + 6 files changed, 934 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/tailwind.rs create mode 100644 crates/engine/src/tailwind_tests.rs diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 0f55ea2c..c74c46fe 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "variable.export", "Export variables", - "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight), path? (write it there)} → {text}, or {path} when written", + "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written", false, export ), @@ -46,8 +46,9 @@ fn export(s: &mut Session, p: &Value) -> Result { let text = match string(p, "format").as_deref().unwrap_or("json") { "json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls)) .map_err(|e| EngineError::Other(e.to_string()))?, - "css" => css(doc, &colls), - _ => return Err(bad(cmd, "format: json|css")), + "css" => css(doc, &colls, false), + "tailwind" => css(doc, &colls, true), + _ => return Err(bad(cmd, "format: json|css|tailwind")), }; match string(p, "path") { Some(path) => { @@ -66,16 +67,23 @@ fn css_safe(text: &str) -> String { /// Each collection's variables as custom properties (named as generated code names them): the /// default mode's values on `:root`, every other mode's on `[data-=""]`; /// aliases are `var(--name)`. -fn css(doc: &Document, colls: &[&Collection]) -> String { +fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { let vars = &doc.variables; + // Tailwind theme names, unique per file and the same ones page code reads. + let theme = crate::tailwind::Theme::new(doc); + let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) }; // Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and // font weight don't. let roles = crate::token_roles::Roles::new(doc); - let mut out = String::new(); + let mut out = if tailwind { "@import \"tailwindcss\";\n\n".to_string() } else { String::new() }; for c in colls { let attr = css_var_name(&c.name); for (i, m) in c.modes.iter().enumerate() { - let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) }; + let sel = match i { + 0 if tailwind => "@theme".to_string(), + 0 => ":root".to_string(), + _ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)), + }; out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name))); for v in vars.variables.iter().filter(|v| v.collection == c.id) { let value = match v.values.get(&m.id) { @@ -85,12 +93,12 @@ fn css(doc: &Document, colls: &[&Collection]) -> String { Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)), Some(VariableValue::Boolean(b)) => b.to_string(), Some(VariableValue::Alias(a)) => match vars.get(a) { - Some(t) => format!("var({})", css_var_of(t)), + Some(t) => format!("var({})", name(t)), None => continue, }, None => continue, }; - out.push_str(&format!(" {}: {value};\n", css_var_of(v))); + out.push_str(&format!(" {}: {value};\n", name(v))); } out.push_str("}\n"); } diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 0871f886..8997587c 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", - "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", + "{id?, language?: css|html|react|leptos|tailwind|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", false, |s, p| { let cmd = "inspect.code"; @@ -40,7 +40,14 @@ pub fn specs() -> Vec { return Err(bad(cmd, "no such layer")); } let connected = crate::code_connect::link(doc, id, lang).is_some(); - let code = code_for(doc, id, lang); + let mobile = match get(p, "mobile") { + None | Some(serde_json::Value::Null) => None, + Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?), + }; + let code = match (lang, mobile) { + (CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)), + _ => code_for(doc, id, lang), + }; let code = match rem { Some(root) if lang.is_web() => px_to_rem(&code, root), _ => code, @@ -61,18 +68,22 @@ pub enum CodeLang { React, /// A Leptos component (`view!` markup) with its stylesheet. Leptos, + /// A React function component styled with Tailwind CSS v4 utilities (no stylesheet). + Tailwind, SwiftUi, Compose, } impl CodeLang { - pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 7] = + [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", CodeLang::Html => "html", CodeLang::React => "react", CodeLang::Leptos => "leptos", + CodeLang::Tailwind => "tailwind", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", } @@ -83,13 +94,14 @@ impl CodeLang { CodeLang::Html => "HTML + CSS", CodeLang::React => "React", CodeLang::Leptos => "Leptos", + CodeLang::Tailwind => "React + Tailwind", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", } } /// Languages whose lengths are CSS pixels (they can be shown in rem). pub fn is_web(self) -> bool { - matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos) + matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Tailwind) } pub fn from_id(s: &str) -> Option { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { @@ -194,6 +206,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), + CodeLang::Tailwind => crate::tailwind::code(doc, id, None), CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), } } @@ -316,25 +329,32 @@ pub(crate) fn indent(depth: usize) -> String { /// The CSS custom properties generated code uses: name → fallback value, in first-use order. #[derive(Default)] -pub(crate) struct Tokens(pub Vec<(String, String)>); +pub(crate) struct Tokens { + vars: Vec<(String, String)>, + /// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name. + pub tailwind: bool, + /// The file's theme names (computed on first use). + theme: Option, +} impl Tokens { /// `var(--name, value)`, remembering the variable. - fn var(&mut self, variable: &Variable, value: String) -> String { - let name = css_var_of(variable); + fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String { + let name = + if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) }; let out = format!("var({name}, {value})"); - if !self.0.iter().any(|(n, _)| *n == name) { - self.0.push((name, value)); + if !self.vars.iter().any(|(n, _)| *n == name) { + self.vars.push((name, value)); } out } /// A `:root` rule declaring the variables (empty without any). pub fn root_rule(&self) -> String { - if self.0.is_empty() { + if self.vars.is_empty() { return String::new(); } - let decls: String = self.0.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect(); + let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect(); format!(":root {{\n{decls}}}\n") } } @@ -365,7 +385,7 @@ pub fn css_var_of(v: &Variable) -> String { /// `value`, or a reference to the variable bound to the first of `fields` that has one. fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String { match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) { - Some(v) => t.var(v, value), + Some(v) => t.var(doc, v, value), None => value, } } @@ -659,6 +679,14 @@ pub(crate) struct Markup<'c, 'a> { /// Each layer's path in the tree (names with sibling counts), class and declarations, so /// variants can be compared layer by layer. pub rules: Vec<(String, String, String)>, + /// Instances as their own (overridden) layers instead of component calls, except those + /// connected to code (page code). + pub inline: bool, + /// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages. + pub fluid: bool, + /// With `fluid`, other layers at least this share of the root's width are fluid too (a + /// divider, a banner image); smaller ones (icons) keep their size. Default: none. + pub fluid_leaves: f64, } impl<'c, 'a> Markup<'c, 'a> { @@ -673,6 +701,9 @@ impl<'c, 'a> Markup<'c, 'a> { root_class: None, root_attrs: String::new(), rules: Vec::new(), + inline: false, + fluid: false, + fluid_leaves: f64::INFINITY, } } @@ -754,7 +785,10 @@ impl<'c, 'a> Markup<'c, 'a> { } } // Nested instances are calls (component code), in a wrapper that places them when needed. - if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) { + // Page code inlines instances, except those connected to code (codeConnect.set): their + // snippet, e.g. a design-system `"}), + ); + let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 1440, "height": 400, "name": "Landing"}))); + ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"})); + for text in ["Agendar diagnóstico", "Ver exemplos"] { + let i = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": p}))); + ex(&mut s, "instance.setProperties", json!({"id": i, "properties": {"Label": text}})); + } + let out = code(&mut s, p, json!({})); + assert_eq!(out.matches("import { Button } from \"@/components/ui/button\";").count(), 1, "{out}"); + assert!(out.contains("`, with each import once at the top. A design + system mapped to shadcn/ui this way exports pages that use the project's own components. Bound variables become theme + utilities (`bg-accent`, `rounded-card`, `text-muted`) that `variable.export {format: "tailwind"}` + declares: `@import "tailwindcss";` and an `@theme` block named by namespace (`--color-*`, + `--radius-*`, `--text-*`, `--spacing-*`, `--font-*`; a number's namespace and unit come from what + layers bind it to), other modes on `[data-=""]`. With `mobile: ` (the + same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the + desktop frame's that differ behind `lg:`, and layers only on desktop `hidden` plus `lg:flex` + (auto layout frames) or `lg:block` (other layers). - **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component, a component set or a variant writes the component as a reusable unit: React (TSX) with a props interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI From f0e75e183b86b31c16334aea39be2fd9e98768dd Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 21:06:59 +0200 Subject: [PATCH 029/208] M1: swap defaults past the layer cap say so (#981) Instance swap defaults that name an in-file main skipped by the layer cap were reported as "isn't in the file": only instance targets fed the capped-mains list. Defaults now feed it too. --- crates/format/src/fig/layer_cap_tests.rs | 21 +++++++++++++++++++++ crates/format/src/fig/map.rs | 21 +++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/format/src/fig/layer_cap_tests.rs b/crates/format/src/fig/layer_cap_tests.rs index 6eeb15f4..2c562ba7 100644 --- a/crates/format/src/fig/layer_cap_tests.rs +++ b/crates/format/src/fig/layer_cap_tests.rs @@ -103,6 +103,27 @@ fn instances_of_a_main_the_cap_kept_out_say_so() { assert!(!doc.nodes().any(|n| n.name == "Local main")); } +#[test] +fn swap_defaults_the_cap_kept_out_say_so() { + // A kept main's instance swap default names a main with no instances, which comes after + // enough layers to be skipped at the cap. + let swap = o(vec![ + ("id", g(70)), + ("name", Kv::Str("Icon".into())), + ("type", Kv::Enum("INSTANCE_SWAP")), + ("initialValue", o(vec![("guidValue", g(900))])), + ]); + let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])]; + nodes.push(node(50, Some(2), "SYMBOL", "Card", vec![("size", xy(10.0, 10.0)), ("componentPropDefs", Kv::Array(vec![swap]))])); + nodes.extend(main(60, 2, "Filler", 40)); + nodes.extend(main(900, 2, "Icon main", 10)); + let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 30); + assert!(doc.nodes().any(|n| n.name == "Card")); + assert!(!doc.nodes().any(|n| n.name == "Icon main")); + assert!(warnings.iter().any(|w| w.starts_with("instance swap properties whose default component was past the layer limit")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + #[test] fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() { // A 1 001-layer library main used 210 times: about 210 000 layers in a file far too small to diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 7eb974da..b18f8a2b 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -45,7 +45,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec= m.max_nodes { - let capped: Vec = m.instance_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect(); + let capped: Vec = m.referenced_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect(); if !capped.is_empty() { out[crate::rest::FIG_CAPPED_MAINS_FIELD] = Value::Array(capped); } @@ -397,9 +397,9 @@ struct Mapper<'a> { library_scan_count: usize, warnings: Vec, count: usize, - /// The mains in the file that instances name, and the mains mapped as layers: the difference - /// is what the layer cap kept out. - instance_mains: BTreeSet, + /// The mains in the file that instances or instance swap defaults name, and the mains mapped + /// as layers: the difference is what the layer cap kept out. + referenced_mains: BTreeSet, mapped_mains: BTreeSet, /// Most layers produced or scanned, instance expansion included; the one place the cap comes /// from (see [`super::layer_budget`]). @@ -478,7 +478,7 @@ impl<'a> Mapper<'a> { library_scan_count: 0, warnings: Vec::new(), count: 0, - instance_mains: BTreeSet::new(), + referenced_mains: BTreeSet::new(), mapped_mains: BTreeSet::new(), max_nodes, networks: Vec::new(), @@ -1869,7 +1869,12 @@ impl<'a> Mapper<'a> { "BOOL" => json!({"type": "BOOLEAN", "defaultValue": init.and_then(|i| i.get("boolValue")).and_then(Kv::bool).unwrap_or(true)}), "TEXT" => json!({"type": "TEXT", "defaultValue": init.and_then(prop_text).unwrap_or("")}), "INSTANCE_SWAP" => match init.and_then(|i| i.get("guidValue")).and_then(guid) { - Some(g) => json!({"type": "INSTANCE_SWAP", "defaultValue": g}), + Some(g) => { + if self.nodes.contains_key(&g) { + self.referenced_mains.insert(g.clone()); + } + json!({"type": "INSTANCE_SWAP", "defaultValue": g}) + } None => continue, }, "VARIANT" => json!({"type": "VARIANT"}), @@ -2013,8 +2018,8 @@ impl<'a> Mapper<'a> { self.warn("instances of components that aren't in the file show no layers"); return None; } - if !self.instance_mains.contains(&sym) { - self.instance_mains.insert(sym.clone()); + if !self.referenced_mains.contains(&sym) { + self.referenced_mains.insert(sym.clone()); } if outer.is_some_and(|i| i.symbols.contains(&sym)) { return None; From 9f2470b5bd132ac0fb889b21fbf8cdb4726b5f2e Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Wed, 21 Oct 2026 21:00:02 +0100 Subject: [PATCH 030/208] Tailwind theme names: resolve collisions in linear time Clash detection compared every variable with every other, and suffixes rescanned the taken list, so a file with very many variables made the export quadratic. Counts in a HashMap, taken names in a HashSet and a next suffix per base keep it linear. --- crates/engine/src/tailwind.rs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/tailwind.rs b/crates/engine/src/tailwind.rs index 7f54c8aa..2a6453e4 100644 --- a/crates/engine/src/tailwind.rs +++ b/crates/engine/src/tailwind.rs @@ -32,14 +32,30 @@ impl Theme { let mut t = Theme { roles, names: HashMap::new() }; // Short names ("accent") where they are unique, the full path ("palette-accent") where two // variables would share one, and a numeric suffix as a last resort. - let short: Vec<(VariableId, String)> = vars.iter().map(|v| (v.id.clone(), t.candidate(v, false))).collect(); - let mut taken: Vec = Vec::new(); - for (v, (id, s)) in vars.iter().zip(&short) { - let clash = short.iter().any(|(o, n)| o != id && n == s); - let base = if clash { t.candidate(v, true) } else { s.clone() }; - let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !taken.contains(n)).unwrap_or(base); - taken.push(name.clone()); - t.names.insert(id.clone(), name); + let short: Vec = vars.iter().map(|v| t.candidate(v, false)).collect(); + // Linear in the number of variables: counts for clashes, a set of names taken and the next + // suffix per base, so a file with very many variables can't make this quadratic. + let mut count: HashMap<&str, usize> = HashMap::new(); + for n in &short { + *count.entry(n.as_str()).or_default() += 1; + } + let mut taken: std::collections::HashSet = std::collections::HashSet::new(); + let mut next: HashMap = HashMap::new(); + for (v, s) in vars.iter().zip(&short) { + let base = if count.get(s.as_str()).copied().unwrap_or(0) > 1 { t.candidate(v, true) } else { s.clone() }; + let mut name = base.clone(); + if taken.contains(&name) { + let k = next.entry(base.clone()).or_insert(1); + loop { + *k += 1; + name = format!("{base}-{k}"); + if !taken.contains(&name) { + break; + } + } + } + taken.insert(name.clone()); + t.names.insert(v.id.clone(), name); } t } From 82b05803b18f6ebfd38e423aabf90b95cc4995f1 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 22:48:51 +0200 Subject: [PATCH 031/208] M1: a refused browser download keeps the unsaved changes (#969) `file.saveAs {data: true}` marked the file saved before the UI handed the bytes to `Services::download`. When the download was refused, the toast said "Couldn't download" but the document was clean, so closing it no longer warned and the edits could be lost. The data result now leaves the save state alone. A new `file.markSaved` (the bookkeeping a native save already did: the file and its branch tabs) runs from `save_as_dialog` only once the download was delivered. It ends a version preview like the other commands that act on the document itself. Tests: the engine save test checks the file stays dirty until `file.markSaved`; `a_refused_download_says_so` checks the document is still dirty. --- crates/engine/src/cmd/file.rs | 39 +++++++++++++++++++--------- crates/engine/src/lib.rs | 3 ++- crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/web_save_tests.rs | 1 + 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d5023372..aa2ea340 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -55,6 +55,13 @@ pub fn specs() -> Vec { false, save_as ), + spec!( + "file.markSaved", + "Mark saved", + "{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)", + false, + mark_saved + ), spec!( "file.export", "Export", @@ -869,24 +876,30 @@ fn save_to(s: &mut Session, path: Option) -> Result { Arc::make_mut(&mut snapshot.doc).name = file_stem(path); } let bytes = snapshot.encode()?; - if let Some(path) = &path { - write(path, &bytes)?; - } - ds.doc = snapshot.doc; - if path.is_some() { - ds.path.clone_from(&path); - } - ds.mark_saved(); - ds.revision += 1; - let (uid, name) = (ds.uid, format!("{}.figcraft", ds.doc.name)); - branch_tabs_saved(s, uid); let Some(path) = path else { + // The download can still be refused: the caller marks the file saved once it was delivered. + let name = format!("{}.figcraft", ds.doc.name); return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); }; + write(&path, &bytes)?; + ds.doc = snapshot.doc; + ds.path = Some(path.clone()); + mark_saved(s, &json!({}))?; remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } +/// The file of the active tab (with its branch tabs) now matches what was written or delivered. +fn mark_saved(s: &mut Session, _: &Value) -> Result { + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + ds.mark_saved(); + ds.revision += 1; + let uid = ds.uid; + branch_tabs_saved(s, uid); + Ok(json!({"saved": true})) +} + fn save(s: &mut Session, p: &Value) -> Result { if let Some(path) = string(p, "path") { return save_to(s, Some(path)); @@ -1590,7 +1603,7 @@ mod rest_import_tests { } #[test] - fn save_as_data_hands_back_the_file_and_marks_it_saved() { + fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { // The browser has no paths: Save as returns the file for a download (#969). let mut s = Session::new(); s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); @@ -1598,6 +1611,8 @@ mod rest_import_tests { let recent = s.prefs.recent_files.clone(); let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); assert_eq!(r["name"], "Untitled.figcraft"); + assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); + s.execute("file.markSaved", &json!({})).unwrap(); assert!(!s.ds().unwrap().is_dirty()); assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..e32d7543 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -402,7 +402,8 @@ pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore /// Commands that first end a version preview: they act on the document itself. Saving does so /// only with a native destination; export copies and pending format choices keep the preview. -const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; +const ENDS_PREVIEW: &[&str] = + &["file.save", "file.saveAs", "file.markSaved", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index bd572dbe..25f3f258 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1920,6 +1920,7 @@ status = "draft — needs native review" "file.revert" = "Volver a lo guardado" "file.save" = "Guardar" "file.saveAs" = "Guardar como" +"file.markSaved" = "Marcar como guardado" "file.saveVersion" = "Guardar en el historial de versiones" "file.versionHistory" = "Mostrar historial de versiones" "fill.add" = "Añadir relleno" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 788bbf4f..0707723d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1146,6 +1146,7 @@ impl FigApp { if let Some(r) = self.run("file.saveAs", json!({"data": true})) && self.download(r["name"].as_str().unwrap_or_default(), &r) { + self.run("file.markSaved", json!({})); dialogs::file_saved(self, &r); } return; diff --git a/crates/ui-egui/src/web_save_tests.rs b/crates/ui-egui/src/web_save_tests.rs index 5fbb2524..4879add1 100644 --- a/crates/ui-egui/src/web_save_tests.rs +++ b/crates/ui-egui/src/web_save_tests.rs @@ -52,6 +52,7 @@ fn a_refused_download_says_so() { app.save_as_dialog(); assert_eq!(got.lock().unwrap().len(), 1); assert_eq!(toast(&app), "Couldn't download Untitled.figcraft"); + assert!(app.session.ds().unwrap().is_dirty(), "no file arrived: closing must still warn about the edits"); } #[test] From 6c0ffe66770fc46e45a91e19de89263a08500f79 Mon Sep 17 00:00:00 2001 From: F3R9 Date: Thu, 22 Oct 2026 00:42:48 +0300 Subject: [PATCH 032/208] M9: make graphics-startup assertions independent of UI language --- ROADMAP.md | 3 ++- apps/figcraft/src/startup_tests.rs | 15 +++++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..f19cc014 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Startup graphics-error tests assert translated hints rather than English substrings, so desktop recovery tests selecting a different UI language do not make them fail. Runtime behavior and readiness estimates unchanged. | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | diff --git a/apps/figcraft/src/startup_tests.rs b/apps/figcraft/src/startup_tests.rs index 0c92bd19..20910309 100644 --- a/apps/figcraft/src/startup_tests.rs +++ b/apps/figcraft/src/startup_tests.rs @@ -2,8 +2,15 @@ //! report it, instead of the app quitting silently (#261). use figcraft_ui_egui::dialogs::window_failed as message; +use figcraft_ui_egui::i18n::tr; const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}"; +const BACKEND_HINT: &str = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose."; +const DRIVER_HINT: &str = if cfg!(windows) { + "Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" +} else { + "Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." +}; #[test] fn the_message_carries_the_error_and_where_to_report_it() { @@ -15,9 +22,9 @@ fn the_message_carries_the_error_and_where_to_report_it() { #[test] fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() { let text = message(ERROR, false); - assert!(text.contains("graphics driver"), "{text}"); + assert!(text.contains(tr(DRIVER_HINT)), "{text}"); assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}"); - assert!(!text.contains("unsetting"), "{text}"); + assert!(!text.contains(tr(BACKEND_HINT)), "{text}"); } #[cfg(windows)] @@ -30,6 +37,6 @@ fn on_windows_it_shows_how_to_start_with_opengl() { #[test] fn with_wgpu_backend_set_it_suggests_unsetting_it() { let text = message(ERROR, true); - assert!(text.contains("unsetting"), "{text}"); - assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}"); + assert!(text.contains(tr(BACKEND_HINT)), "{text}"); + assert!(!text.contains(tr(DRIVER_HINT)), "the chosen backend is the likelier cause: {text}"); } From a30f846ee3e08e7b67f020ff048667c88c14d414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 15:13:22 +0200 Subject: [PATCH 033/208] M6: open Sketch files (48+): detection, pages, version cut, wiring .sketch archives (document.json, meta.json, pages/*.json) open through file.open, file.import, drag and drop, the CLI and MCP; files saved by a Sketch older than 48 are refused with a message saying how to convert them. Layers come in the next commits. Co-Authored-By: Claude Opus 5.5 --- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/file.rs | 14 ++- crates/engine/src/lib.rs | 2 + crates/engine/src/sketch_import_tests.rs | 89 +++++++++++++ crates/engine/src/tests.rs | 3 + crates/format/src/lib.rs | 1 + crates/format/src/rest/mod.rs | 11 ++ crates/format/src/sketch/fixture.rs | 121 ++++++++++++++++++ crates/format/src/sketch/mod.rs | 153 +++++++++++++++++++++++ crates/format/src/sketch/tests.rs | 111 ++++++++++++++++ crates/format/src/sketch/tree.rs | 81 ++++++++++++ crates/ui-egui/src/home_tests.rs | 7 ++ crates/ui-egui/src/lib.rs | 8 +- 13 files changed, 597 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/sketch_import_tests.rs create mode 100644 crates/format/src/sketch/fixture.rs create mode 100644 crates/format/src/sketch/mod.rs create mode 100644 crates/format/src/sketch/tests.rs create mode 100644 crates/format/src/sketch/tree.rs diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 545d872c..f55aae78 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -31,5 +31,6 @@ regex = { workspace = true } web-time = "1.1.0" [dev-dependencies] +zip = { workspace = true } wat = { workspace = true } image = { workspace = true } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7380711f..4f8802b7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -536,6 +536,9 @@ fn load_with( if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { return Ok((r?, true)); } + if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); + } if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; link_instances(&mut doc); @@ -551,6 +554,15 @@ fn load_with( } } +/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try. +fn load_sketch(bytes: &[u8], name: &str) -> Option> { + let r = figcraft_format::sketch::try_import(bytes, name)?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + link_instances(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + /// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { let r = figcraft_format::fig::try_import(bytes, name)?; @@ -1293,7 +1305,7 @@ fn import(s: &mut Session, p: &Value) -> Result { } Ok(out) } - "figcraft" | "json" | "fig" => { + "figcraft" | "json" | "fig" | "sketch" => { let resolver = path.as_deref().map(rest_images); let images: figcraft_format::rest::ImageResolver = match &resolver { Some(r) => r, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..edb04bf4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1839,6 +1839,8 @@ mod shapes_behaviour_tests; #[cfg(test)] mod shortcut_parity_tests; #[cfg(test)] +mod sketch_import_tests; +#[cfg(test)] mod slot_tests; #[cfg(test)] mod stroke_style_tests; diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs new file mode 100644 index 00000000..cb3bf2f1 --- /dev/null +++ b/crates/engine/src/sketch_import_tests.rs @@ -0,0 +1,89 @@ +//! `.sketch` files through the file commands. The importer's own tests (fixtures built in code) +//! are in `figcraft_format::sketch`. + +use std::io::Write; + +use base64::Engine; +use serde_json::{Value, json}; + +use crate::Session; + +/// A minimal `.sketch` zip with one page per `(id, name)`, saved by `app_version`. +fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec { + let refs: Vec = pages.iter().map(|(id, _)| json!({"_class": "MSJSONFileReference", "_ref": format!("pages/{id}")})).collect(); + let mut out = Vec::new(); + { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); + let o = zip::write::SimpleFileOptions::default(); + z.start_file("document.json", o).unwrap(); + z.write_all(json!({"_class": "document", "pages": refs}).to_string().as_bytes()).unwrap(); + z.start_file("meta.json", o).unwrap(); + z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": 103}).to_string().as_bytes()).unwrap(); + for (id, name) in pages { + z.start_file(format!("pages/{id}.json"), o).unwrap(); + z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": []}).to_string().as_bytes()).unwrap(); + } + z.finish().unwrap(); + } + out +} + +fn temp(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("figcraft-sketch-test-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + (dir, path) +} + +fn page_names(s: &Session) -> Vec { + let doc = s.doc().unwrap(); + doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect() +} + +#[test] +fn opened_sketch_files_have_their_pages_and_no_path() { + let (dir, path) = temp("design.sketch", &sketch("50.2", &[("A", "Screens"), ("B", "Symbols")])); + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + assert!(r.get("summary").is_some()); + assert_eq!(page_names(&s), ["Screens", "Symbols"]); + // Never saved over: the document has no path, so Save asks for one. + assert!(s.docs[s.active].path.is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn sketch_import_appends_pages() { + let (dir, path) = temp("more.sketch", &sketch("83.2", &[("A", "Imported")])); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let before = page_names(&s).len(); + s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap(); + let names = page_names(&s); + assert_eq!(names.len(), before + 1); + assert_eq!(names.last().unwrap(), "Imported"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn sketch_data_opens_with_a_summary() { + let data = base64::engine::general_purpose::STANDARD.encode(sketch("50.2", &[("A", "Page")])); + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + assert!(r.get("summary").is_some()); + assert_eq!(page_names(&s), ["Page"]); +} + +#[test] +fn old_and_damaged_sketch_files_fail_cleanly() { + let mut s = Session::new(); + let (dir, path) = temp("old.sketch", &sketch("47.1", &[("A", "P")])); + let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("older than 48"), "{e}"); + let mut broken = sketch("50.2", &[("A", "P")]); + broken.truncate(broken.len() / 2); + std::fs::write(&path, &broken).unwrap(); + assert!(s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 646c7c4b..eeec9f04 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -416,6 +416,9 @@ fn every_command_survives_junk_params() { // Online files: ids, servers, refreshes, actions and names as junk. json!({"file": [1], "server": 7, "refresh": "maybe", "action": "share", "name": {"x": 1}}), json!({"file": "../x", "server": "wss://\u{0}", "refresh": true, "action": "unshare", "name": 5}), + // Files as base64 data: a damaged `.sketch` (zip magic, then junk) and an old one (SQLite). + json!({"data": "UEsDBBQAAAAIAP////8=", "name": "x.sketch"}), + json!({"data": "U1FMaXRlIGZvcm1hdCAzAA==", "name": "x.sketch", "path": null}), // Selection color slots: malformed indexes, ranges and paints. json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}), json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}), diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9f7e51bc..c61af183 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -20,6 +20,7 @@ pub mod fig; pub mod library; pub mod rest; +pub mod sketch; pub mod thumbnail; pub use library::{is_valid_library_id, read_library, read_library_within}; diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 78279e91..faaf7baf 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -185,6 +185,17 @@ pub(crate) fn import_with_networks( import_parts(v, None, images, networks, max_nodes, true) } +/// [`import_with_networks`] for imports other than `.fig` (no `.fig` source identities): the +/// `.sketch` import. +pub(crate) fn import_with_networks_plain( + v: &Value, + images: ImageResolver, + networks: Vec>, + max_nodes: usize, +) -> Result<(Document, Vec), FormatError> { + import_parts(v, None, images, networks, max_nodes, false) +} + fn import_parts( v: &Value, variables: Option<&Value>, diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs new file mode 100644 index 00000000..fd3b37ce --- /dev/null +++ b/crates/format/src/sketch/fixture.rs @@ -0,0 +1,121 @@ +//! Synthetic `.sketch` files for the tests: the JSON of a document, its pages and `meta.json`, +//! zipped the way Sketch writes them. Layers are built with small helpers and adjusted with +//! [`with`]. + +use std::io::Write; + +use serde_json::{Value, json}; + +/// A `.sketch` zip: `document.json`, `meta.json` saying `app_version` / `version`, one +/// `pages/.json` per page and any other `files` (images, fonts). +pub(crate) fn sketch_zip(app_version: &str, version: u64, document: Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec { + let meta = json!({ + "app": "com.bohemiancoding.sketch3", + "appVersion": app_version, + "version": version, + "compatibilityVersion": 99, + "pagesAndArtboards": {}, + }); + raw_zip(&document, &meta, pages, files) +} + +/// A `.sketch` zip with the given `meta.json` as is. +pub(crate) fn raw_zip(document: &Value, meta: &Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec { + let mut out = Vec::new(); + { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("document.json", opts).unwrap(); + z.write_all(document.to_string().as_bytes()).unwrap(); + z.start_file("meta.json", opts).unwrap(); + z.write_all(meta.to_string().as_bytes()).unwrap(); + for p in pages { + let id = p["do_objectID"].as_str().unwrap_or("page"); + z.start_file(format!("pages/{id}.json"), opts).unwrap(); + z.write_all(p.to_string().as_bytes()).unwrap(); + } + for (name, bytes) in files { + z.start_file(*name, opts).unwrap(); + z.write_all(bytes).unwrap(); + } + z.finish().unwrap(); + } + out +} + +/// `document.json` listing `pages` (ids) in order, with empty shared-style containers. +pub(crate) fn document(pages: &[&str]) -> Value { + json!({ + "_class": "document", + "do_objectID": "DOC", + "colorSpace": 1, + "currentPageIndex": 0, + "foreignLayerStyles": [], + "foreignSymbols": [], + "foreignTextStyles": [], + "layerStyles": {"_class": "sharedStyleContainer", "objects": []}, + "layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": []}, + "layerSymbols": {"_class": "symbolContainer", "objects": []}, + "pages": pages.iter().map(|p| json!({ + "_class": "MSJSONFileReference", + "_ref_class": "MSImmutablePage", + "_ref": format!("pages/{p}"), + })).collect::>(), + }) +} + +fn frame(x: f64, y: f64, w: f64, h: f64) -> Value { + json!({"_class": "rect", "constrainProportions": false, "x": x, "y": y, "width": w, "height": h}) +} + +fn layer(class: &str, id: &str, name: &str, x: f64, y: f64, w: f64, h: f64) -> Value { + json!({ + "_class": class, + "do_objectID": id, + "name": name, + "frame": frame(x, y, w, h), + "isVisible": true, + "isLocked": false, + "rotation": 0, + "isFlippedHorizontal": false, + "isFlippedVertical": false, + "resizingConstraint": 63, + "style": {"_class": "style", "fills": [], "borders": [], "shadows": [], "innerShadows": []}, + }) +} + +pub(crate) fn page(id: &str, name: &str, layers: Vec) -> Value { + with(layer("page", id, name, 0.0, 0.0, 0.0, 0.0), "layers", Value::Array(layers)) +} + +#[allow(dead_code)] // used by the layer tree tests +pub(crate) fn artboard(id: &str, name: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + with(layer("artboard", id, name, x, y, w, h), "layers", Value::Array(layers)) +} + +pub(crate) fn group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + with(layer("group", id, id, x, y, w, h), "layers", Value::Array(layers)) +} + +/// A rectangle with its four generated corner points. +pub(crate) fn rect(id: &str, x: f64, y: f64, w: f64, h: f64) -> Value { + let pts = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)].map(|(px, py)| point(px, py)); + let mut l = with(layer("rectangle", id, id, x, y, w, h), "points", Value::Array(pts.to_vec())); + l["isClosed"] = json!(true); + l["fixedRadius"] = json!(0); + l +} + +/// A straight curve point at `(x, y)` in 0–1 of the layer's frame. +pub(crate) fn point(x: f64, y: f64) -> Value { + let p = format!("{{{x}, {y}}}"); + json!({"_class": "curvePoint", "cornerRadius": 0, "curveFrom": p, "curveTo": p, "curveMode": 1, "hasCurveFrom": false, "hasCurveTo": false, "point": p}) +} + +/// `l` with `key` set to `value`. +pub(crate) fn with(mut l: Value, key: &str, value: Value) -> Value { + if let Some(m) = l.as_object_mut() { + m.insert(key.to_string(), value); + } + l +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs new file mode 100644 index 00000000..7334606b --- /dev/null +++ b/crates/format/src/sketch/mod.rs @@ -0,0 +1,153 @@ +//! Import of `.sketch` files saved by Sketch 48 or newer (best effort), written from the public +//! description of the format only. +//! +//! A `.sketch` file is a zip archive holding `document.json` (the document: its pages in order, +//! shared styles, swatches and library symbols copied into it), `meta.json` (the app version and +//! format version that saved it), one `pages/.json` per page (the layer tree), `images/` and, +//! in newer files, `fonts/`. Every object carries its kind in `_class`. [`tree`] maps the layers +//! onto the REST API file JSON shape and the REST importer builds the document, so this import +//! shares its mapping, budgets and import report with `.fig` and file JSON imports. Files older +//! than Sketch 48 (a SQLite database before 43, text as binary property lists until 47) are +//! refused with a message saying how to convert them. Anything not understood is skipped with a +//! warning; malformed input never panics. + +#[cfg(test)] +mod fixture; +#[cfg(test)] +mod tests; +mod tree; + +use std::collections::BTreeSet; + +use figcraft_doc::Document; +use serde_json::Value; + +use crate::FormatError; + +/// The oldest Sketch whose files open: from 48 on, text is plain JSON too. +pub const MIN_SKETCH: u32 = 48; +/// The newest `meta.json` format version checked against real files (Sketch 83); newer files +/// open with a warning. +pub const NEWEST_TESTED_VERSION: u64 = 139; +/// What opening a file saved by an older Sketch says. +pub const TOO_OLD: &str = "saved by a Sketch older than 48; open it in a newer Sketch and save it again"; +/// Largest JSON member read (a real page of a few thousand layers takes 33 MB). +const MAX_JSON_MEMBER: u64 = 512 * 1024 * 1024; +/// Largest image member read. +const MAX_IMAGE: u64 = 256 * 1024 * 1024; +/// Most pages read from one file. +const MAX_PAGES: usize = 1000; + +/// Whether `bytes` look like a `.sketch` file: a zip holding `document.json` and `meta.json`, or +/// the SQLite database Sketch wrote before version 43 (so that it gets the "too old" message). +pub fn is_sketch(bytes: &[u8]) -> bool { + bytes.starts_with(b"SQLite format 3\0") + || (bytes.starts_with(b"PK") + && zip::ZipArchive::new(std::io::Cursor::new(bytes)).is_ok_and(|z| { + let names: BTreeSet<&str> = z.file_names().collect(); + names.contains("document.json") && names.contains("meta.json") + })) +} + +/// Import `bytes` when they are a `.sketch` file; `None` when they are something else. +pub fn try_import(bytes: &[u8], name: &str) -> Option), FormatError>> { + is_sketch(bytes).then(|| import(bytes, name)) +} + +/// Build a document from a `.sketch` file. Returns it with the warnings about what was skipped or +/// approximated. `name`: the document name. +pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), FormatError> { + import_within(bytes, name, MAX_JSON_MEMBER) +} + +/// [`import`] reading pages of at most `max_member` bytes of JSON each. +pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result<(Document, Vec), FormatError> { + if bytes.starts_with(b"SQLite format 3\0") { + return Err(FormatError::Io(TOO_OLD.into())); + } + let archive = Archive::open(bytes, max_member)?; + let max_nodes = crate::fig::layer_budget(bytes.len()); + let map = tree::to_rest(&archive, name); + if map.too_old { + return Err(FormatError::Io(TOO_OLD.into())); + } + let mut warnings = archive.warnings.clone(); + warnings.extend(map.warnings); + let images = |r: &str| archive.image(r); + let (doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?; + warnings.extend(more); + Ok((doc, warnings)) +} + +fn corrupt(msg: impl Into) -> FormatError { + FormatError::Corrupt(format!("Sketch file: {}", msg.into())) +} + +/// The parts of a `.sketch` archive: the pages in document order and the images. +pub(crate) struct Archive<'b> { + /// `(id, page JSON)` in the document's order. + pub pages: Vec<(String, Value)>, + /// Problems met while reading (pages skipped). + pub warnings: Vec, + zip: zip::ZipArchive>, +} + +impl<'b> Archive<'b> { + fn open(bytes: &'b [u8], max_member: u64) -> Result { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?; + let json = |z: &mut zip::ZipArchive>, member: &str, limit: u64| -> Result { + let data = crate::read_member(z, member, limit)?; + serde_json::from_slice(&data).map_err(|e| corrupt(format!("{member}: {e}"))) + }; + let meta = json(&mut z, "meta.json", MAX_JSON_MEMBER)?; + if !meta.is_object() { + return Err(corrupt("meta.json is not an object")); + } + if app_major(&meta).is_some_and(|v| v < MIN_SKETCH) { + return Err(FormatError::Io(TOO_OLD.into())); + } + let document = json(&mut z, "document.json", MAX_JSON_MEMBER)?; + let mut warnings = Vec::new(); + if meta.get("version").and_then(Value::as_u64).is_some_and(|v| v > NEWEST_TESTED_VERSION) { + warnings.push("the file was saved by a newer Sketch than the import was checked with; some layers may differ".to_string()); + } + let refs = document.get("pages").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut pages = Vec::new(); + for r in refs.iter().take(MAX_PAGES) { + let Some(id) = r.get("_ref").and_then(Value::as_str).and_then(|s| s.strip_prefix("pages/")).filter(|id| valid_id(id)) else { + warnings.push("pages the document lists without a valid reference were skipped".into()); + continue; + }; + match json(&mut z, &format!("pages/{id}.json"), max_member) { + Ok(page) if page.is_object() => pages.push((id.to_string(), page)), + Ok(_) => warnings.push("pages that aren't JSON objects were skipped".into()), + Err(e) => warnings.push(format!("a page was skipped: {}", e.to_string().trim_start_matches("not a valid FigCraft file: "))), + } + } + if refs.len() > MAX_PAGES { + warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped")); + } + Ok(Archive { pages, warnings, zip: z }) + } + + /// The bytes of the image a layer references (`images/`). + pub(crate) fn image(&self, r: &str) -> Option> { + let name = r.strip_prefix("images/").unwrap_or(r); + if !valid_id(name.split('.').next().unwrap_or("")) || name.split('.').count() > 2 { + return None; + } + // A clone shares the parsed directory and the bytes; the resolver only has shared access. + let mut z = self.zip.clone(); + crate::read_member(&mut z, &format!("images/{name}"), MAX_IMAGE).ok() + } +} + +/// The major version of the Sketch that saved the file (`meta.json` `appVersion`, "50.2" → 50). +fn app_major(meta: &Value) -> Option { + meta.get("appVersion").and_then(Value::as_str)?.split('.').next()?.trim().parse().ok() +} + +/// Ids name archive members: letters, digits, `-` and `_` only, never a path. +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 128 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} diff --git a/crates/format/src/sketch/tests.rs b/crates/format/src/sketch/tests.rs new file mode 100644 index 00000000..4fb24400 --- /dev/null +++ b/crates/format/src/sketch/tests.rs @@ -0,0 +1,111 @@ +//! `.sketch` import: detection, the archive, the version cut and pages, on fixtures built in code. + +use serde_json::json; + +use super::fixture::*; +use super::*; + +fn page_names(doc: &Document) -> Vec { + doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect() +} + +#[test] +fn detects_sketch_and_not_others() { + let z = sketch_zip("50.2", 103, document(&["P1"]), &[page("P1", "Page 1", vec![])], &[]); + assert!(is_sketch(&z)); + assert!(!is_sketch(b"fig-kiwi....")); + assert!(!is_sketch(b"{}")); + assert!(!is_sketch(b"")); + // A zip that isn't a Sketch file. + let mut other = Vec::new(); + { + use std::io::Write; + let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut other)); + w.start_file("canvas.fig", zip::write::SimpleFileOptions::default()).unwrap(); + w.write_all(b"x").unwrap(); + w.finish().unwrap(); + } + assert!(!is_sketch(&other)); +} + +#[test] +fn pages_become_pages_in_document_order() { + let z = sketch_zip("50.2", 103, document(&["B", "A"]), &[page("A", "Alpha", vec![]), page("B", "Beta", vec![])], &[]); + let (doc, _) = import(&z, "f").unwrap(); + assert_eq!(page_names(&doc), ["Beta", "Alpha"]); +} + +#[test] +fn older_than_48_is_refused() { + let z = sketch_zip("47.1", 95, document(&["P"]), &[page("P", "P", vec![])], &[]); + let e = import(&z, "f").unwrap_err().to_string(); + assert!(e.contains(TOO_OLD), "{e}"); + let mut sqlite = b"SQLite format 3\0".to_vec(); + sqlite.resize(512, 0); + assert!(is_sketch(&sqlite)); + assert!(import(&sqlite, "f").unwrap_err().to_string().contains(TOO_OLD)); +} + +#[test] +fn archived_text_without_meta_version_is_refused() { + let text = json!({ + "_class": "text", "do_objectID": "T", "name": "T", + "frame": {"_class": "rect", "x": 0, "y": 0, "width": 10, "height": 10}, + "attributedString": {"_class": "MSAttributedString", "archivedAttributedString": {"_archive": "YnBsaXN0MDA="}}, + }); + let z = raw_zip(&document(&["P"]), &json!({"app": "com.bohemiancoding.sketch3"}), &[page("P", "P", vec![text])], &[]); + let e = import(&z, "f").unwrap_err().to_string(); + assert!(e.contains(TOO_OLD), "{e}"); +} + +#[test] +fn newer_than_tested_imports_with_one_warning() { + let z = sketch_zip("2099.1", NEWEST_TESTED_VERSION + 1, document(&["P"]), &[page("P", "P", vec![])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert_eq!(warnings.iter().filter(|w| w.contains("newer Sketch")).count(), 1, "{warnings:?}"); +} + +#[test] +fn junk_never_panics() { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "P", vec![rect("R", 0.0, 0.0, 10.0, 10.0)])], &[]); + for cut in (0..z.len()).step_by(7) { + let _ = import(&z[..cut], "f"); + } + let mut seed = 0x2545_f491_u32; + for _ in 0..200 { + let mut b = z.clone(); + for _ in 0..8 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = seed as usize % b.len(); + b[i] = (seed >> 8) as u8; + } + let _ = import(&b, "f"); + } + for doc in [json!([]), json!(null), json!({"pages": 5}), json!({"pages": [{"_ref": 7}, {"_ref": "pages/../../x"}]})] { + let _ = import(&sketch_zip("50.2", 103, doc, &[json!(5), json!({"do_objectID": "Q", "layers": "x"})], &[]), "f"); + } + let _ = import(&raw_zip(&document(&["P"]), &json!("x"), &[], &[]), "f"); +} + +#[test] +fn deep_nesting_does_not_fail_the_file() { + let mut deep = rect("R", 0.0, 0.0, 1.0, 1.0); + for i in 0..300 { + deep = group(&format!("G{i}"), 0.0, 0.0, 1.0, 1.0, vec![deep]); + } + let z = sketch_zip("50.2", 103, document(&["A", "B"]), &[page("A", "Deep", vec![deep]), page("B", "Normal", vec![])], &[]); + let (doc, warnings) = import(&z, "f").unwrap(); + assert!(page_names(&doc).contains(&"Normal".to_string())); + assert!(!warnings.is_empty()); +} + +#[test] +fn members_over_budget_are_skipped() { + let z = sketch_zip("50.2", 103, document(&["A", "B"]), &[page("A", "Big", vec![]), page("B", "Small", vec![])], &[]); + let (doc, warnings) = import_within(&z, "f", 200).unwrap(); + // Each page JSON is larger than 200 bytes: both are skipped, with a warning; nothing panics. + assert!(warnings.iter().any(|w| w.contains("too large")), "{warnings:?}"); + assert!(!page_names(&doc).contains(&"Big".to_string())); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs new file mode 100644 index 00000000..8b5fc0c8 --- /dev/null +++ b/crates/format/src/sketch/tree.rs @@ -0,0 +1,81 @@ +//! Sketch pages and layers → REST API file JSON nodes. + +use figcraft_doc::VectorNetwork; +use serde_json::{Map as JsonMap, Value, json}; + +use super::Archive; + +/// Deepest layer nesting followed (deeper layers are skipped with a warning). +const MAX_DEPTH: usize = 200; + +/// The REST JSON of a Sketch file, with what the mapping noticed. +pub(crate) struct Map { + pub json: Value, + pub warnings: Vec, + /// Vector networks the JSON references by `vectorNetworkRef`. + pub networks: Vec>, + /// A text layer stored the pre-48 way was met: the file is refused. + pub too_old: bool, +} + +/// The mapping's state while walking the file. +pub(crate) struct Cx { + pub warnings: Vec, + pub networks: Vec>, + pub too_old: bool, +} + +impl Cx { + pub(crate) fn warn(&mut self, msg: impl Into) { + let msg = msg.into(); + if !self.warnings.contains(&msg) { + self.warnings.push(msg); + } + } +} + +/// Map the pages of `archive` to a REST file JSON named `name`. +pub(crate) fn to_rest(archive: &Archive, name: &str) -> Map { + let mut cx = Cx { warnings: Vec::new(), networks: Vec::new(), too_old: false }; + let mut canvases = Vec::new(); + for (id, page) in &archive.pages { + let mut node = JsonMap::new(); + node.insert("type".into(), json!("CANVAS")); + node.insert("id".into(), json!(id)); + node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page"))); + let kids: Vec = layers(page).iter().filter_map(|l| layer(&mut cx, l, 0)).collect(); + node.insert("children".into(), Value::Array(kids)); + canvases.push(Value::Object(node)); + } + let json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}}); + Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old } +} + +/// One Sketch layer as a REST node (with its children), or `None` when it is skipped. +pub(crate) fn layer(cx: &mut Cx, l: &Value, depth: usize) -> Option { + if depth > MAX_DEPTH { + cx.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); + return None; + } + if is_archived_text(l) { + cx.too_old = true; + return None; + } + let class = str_field(l, "_class")?; + cx.warn(format!("Sketch {class} layers are not supported yet and were skipped")); + None +} + +/// A text layer whose string is a binary property list (Sketch 43–47). +fn is_archived_text(l: &Value) -> bool { + l.get("attributedString").is_some_and(|a| a.get("archivedAttributedString").is_some()) +} + +/// The child layers of a page or layer. +pub(crate) fn layers(l: &Value) -> &[Value] { + l.get("layers").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() +} + +pub(crate) fn str_field<'v>(v: &'v Value, key: &str) -> Option<&'v str> { + v.get(key).and_then(Value::as_str) +} diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index ebbf6d95..fc51bfb1 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -900,3 +900,10 @@ fn dropping_an_unsupported_file_on_the_home_screen_opens_nothing() { assert!(u.app.ui.home); assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .json, .svg or image file.")); } + +#[test] +fn sketch_files_open_and_drop_as_documents() { + let p = std::path::Path::new("/tmp/Design.SKETCH"); + assert!(crate::opens(p)); + assert!(crate::is_document(p)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..75096ffb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1201,7 +1201,7 @@ impl FigApp { let exts: &[&str] = if place { &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"] } else { - &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"] + &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig", "sketch"] }; let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; self.pick_file(title, exts, true, |app, file| { @@ -1823,17 +1823,17 @@ impl FigApp { } /// The file types File ▸ Open and a drop on the home screen open. -pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; +pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "sketch", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; /// Whether `path` names a file type that opens as a document (the extension, in any case). pub(crate) fn opens(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } -/// Whether `path` is a whole document (`.figcraft`, `.fig`, file JSON) rather than an image or SVG: +/// Whether `path` is a whole document (`.figcraft`, `.fig`, `.sketch`, file JSON) rather than an image or SVG: /// dropped on the canvas it opens as a file of its own instead of adding its pages to the open one. pub(crate) fn is_document(path: &Path) -> bool { - path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) + path.extension().and_then(|e| e.to_str()).is_some_and(|e| ["figcraft", "fig", "sketch", "json"].iter().any(|x| x.eq_ignore_ascii_case(e))) } /// Reads the contents of dropped file `name` that has no file on disk to read by path. From 5805cea9b4527201dd1cf5d27833337ed0bbf72d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 15:55:59 +0200 Subject: [PATCH 034/208] M6: Sketch import: layer tree, transforms, constraints, basic shapes Artboards (and Sketch 2025 frames) become frames with their background (white by default, as Sketch shows them), groups and graphics groups, rectangles with their corner radii, ovals ellipses, bitmaps rectangles with the image, slices slices. Positions accumulate through groups, rotation and flips keep their direction, resizingConstraint bits become constraints. Not yet mapped layer kinds are listed in the import report. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/fixture.rs | 1 - crates/format/src/sketch/mod.rs | 4 +- crates/format/src/sketch/tree.rs | 149 +++++++++++++++++++- crates/format/src/sketch/tree_tests.rs | 181 +++++++++++++++++++++++++ 4 files changed, 326 insertions(+), 9 deletions(-) create mode 100644 crates/format/src/sketch/tree_tests.rs diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index fd3b37ce..231225c9 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -88,7 +88,6 @@ pub(crate) fn page(id: &str, name: &str, layers: Vec) -> Value { with(layer("page", id, name, 0.0, 0.0, 0.0, 0.0), "layers", Value::Array(layers)) } -#[allow(dead_code)] // used by the layer tree tests pub(crate) fn artboard(id: &str, name: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { with(layer("artboard", id, name, x, y, w, h), "layers", Value::Array(layers)) } diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 7334606b..a46706db 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -16,6 +16,8 @@ mod fixture; #[cfg(test)] mod tests; mod tree; +#[cfg(test)] +mod tree_tests; use std::collections::BTreeSet; @@ -67,7 +69,7 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result } let archive = Archive::open(bytes, max_member)?; let max_nodes = crate::fig::layer_budget(bytes.len()); - let map = tree::to_rest(&archive, name); + let map = tree::to_rest(&archive, name, max_nodes); if map.too_old { return Err(FormatError::Io(TOO_OLD.into())); } diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 8b5fc0c8..59e3d637 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -1,12 +1,16 @@ //! Sketch pages and layers → REST API file JSON nodes. use figcraft_doc::VectorNetwork; +use figcraft_doc::geom::{Affine, Rect}; use serde_json::{Map as JsonMap, Value, json}; use super::Archive; /// Deepest layer nesting followed (deeper layers are skipped with a warning). const MAX_DEPTH: usize = 200; +/// Largest size and farthest position kept (the REST importer's own bounds). +const MAX_SIZE: f64 = 1e6; +const MAX_COORD: f64 = 1e7; /// The REST JSON of a Sketch file, with what the mapping noticed. pub(crate) struct Map { @@ -23,6 +27,9 @@ pub(crate) struct Cx { pub warnings: Vec, pub networks: Vec>, pub too_old: bool, + /// Layers mapped so far, and the most the file may give. + pub nodes: usize, + pub max_nodes: usize, } impl Cx { @@ -35,15 +42,15 @@ impl Cx { } /// Map the pages of `archive` to a REST file JSON named `name`. -pub(crate) fn to_rest(archive: &Archive, name: &str) -> Map { - let mut cx = Cx { warnings: Vec::new(), networks: Vec::new(), too_old: false }; +pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { + let mut cx = Cx { warnings: Vec::new(), networks: Vec::new(), too_old: false, nodes: 0, max_nodes }; let mut canvases = Vec::new(); for (id, page) in &archive.pages { let mut node = JsonMap::new(); node.insert("type".into(), json!("CANVAS")); node.insert("id".into(), json!(id)); node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page"))); - let kids: Vec = layers(page).iter().filter_map(|l| layer(&mut cx, l, 0)).collect(); + let kids: Vec = layers(page).iter().filter_map(|l| layer(&mut cx, l, Affine::IDENTITY, 0, true)).collect(); node.insert("children".into(), Value::Array(kids)); canvases.push(Value::Object(node)); } @@ -51,8 +58,9 @@ pub(crate) fn to_rest(archive: &Archive, name: &str) -> Map { Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old } } -/// One Sketch layer as a REST node (with its children), or `None` when it is skipped. -pub(crate) fn layer(cx: &mut Cx, l: &Value, depth: usize) -> Option { +/// One Sketch layer as a REST node (with its children), or `None` when it is skipped. `parent`: +/// the parent layer's transform to the page; `root`: the layer sits on the page itself. +pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { if depth > MAX_DEPTH { cx.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; @@ -62,8 +70,135 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, depth: usize) -> Option { return None; } let class = str_field(l, "_class")?; - cx.warn(format!("Sketch {class} layers are not supported yet and were skipped")); - None + let ty = match class { + "artboard" | "frame" => "FRAME", + "group" | "graphic" => "GROUP", + "rectangle" | "bitmap" => "RECTANGLE", + "oval" => "ELLIPSE", + "slice" => "SLICE", + other => { + cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other))); + return None; + } + }; + if cx.nodes >= cx.max_nodes { + cx.warn(format!("the file has more than {} layers; the rest were skipped", cx.max_nodes)); + return None; + } + cx.nodes += 1; + let (w, h) = size(l); + let world = parent * local(l, w, h); + let mut node = JsonMap::new(); + node.insert("type".into(), json!(ty)); + if let Some(id) = str_field(l, "do_objectID") { + node.insert("id".into(), json!(id)); + } + node.insert("name".into(), json!(str_field(l, "name").unwrap_or(class))); + node.insert("visible".into(), json!(bool_field(l, "isVisible").unwrap_or(true))); + node.insert("locked".into(), json!(bool_field(l, "isLocked").unwrap_or(false))); + node.insert("size".into(), json!({"x": w, "y": h})); + let rel = parent.inverse() * world; + let [a, b, c, d, e, f] = rel.as_coeffs(); + node.insert("relativeTransform".into(), json!([[a, c, e], [b, d, f]])); + // The page places top-level layers by their transform; deeper ones also say where they end up, + // which tells the importer their transform is relative to their group. + if !root { + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); + } + node.insert("constraints".into(), constraints(l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63))); + match class { + "artboard" | "frame" => { + let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) }; + node.insert("clipsContent".into(), json!(clips)); + let background = (bool_field(l, "hasBackgroundColor") == Some(true)).then(|| l.get("backgroundColor").and_then(color)).flatten(); + if class == "artboard" || background.is_some() { + let c = background.unwrap_or_else(|| json!({"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0})); + node.insert("fills".into(), json!([{"type": "SOLID", "color": c}])); + } + } + "rectangle" => corner_radii(l, &mut node), + "bitmap" => match l.get("image").and_then(|i| str_field(i, "_ref")) { + Some(r) => { + let paint = json!({"type": "IMAGE", "scaleMode": "STRETCH", "imageRef": r, "imageTransform": [[1, 0, 0], [0, 1, 0]]}); + node.insert("fills".into(), json!([paint])); + } + None => cx.warn("images stored inside the JSON were skipped"), + }, + _ => {} + } + if matches!(ty, "FRAME" | "GROUP") { + let kids: Vec = layers(l).iter().filter_map(|c| layer(cx, c, world, depth + 1, false)).collect(); + node.insert("children".into(), Value::Array(kids)); + } + Some(Value::Object(node)) +} + +/// A layer's size from its `frame` (finite, not negative, at most [`MAX_SIZE`]). +fn size(l: &Value) -> (f64, f64) { + let f = l.get("frame"); + let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_SIZE); + (n("width"), n("height")) +} + +/// A layer's transform within its parent: its frame's position, then its rotation (degrees, +/// counter-clockwise on screen) and flips about its centre. +fn local(l: &Value, w: f64, h: f64) -> Affine { + let f = l.get("frame"); + let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-MAX_COORD, MAX_COORD); + let rot = l.get("rotation").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) % 360.0; + let sx = if bool_field(l, "isFlippedHorizontal") == Some(true) { -1.0 } else { 1.0 }; + let sy = if bool_field(l, "isFlippedVertical") == Some(true) { -1.0 } else { 1.0 }; + Affine::translate((n("x") + w / 2.0, n("y") + h / 2.0)) + * Affine::rotate(-rot.to_radians()) + * Affine::scale_non_uniform(sx, sy) + * Affine::translate((-w / 2.0, -h / 2.0)) +} + +/// REST constraints from Sketch's `resizingConstraint`: six bits, a cleared bit pins that side or +/// fixes that size (right 1, width 2, left 4, bottom 8, height 16, top 32). A layer pinned on +/// neither side keeps its place in proportion (scale) unless its size is fixed (centre). +fn constraints(mask: i64) -> Value { + let pinned = |bit: i64| mask & bit == 0; + let axis = |low: i64, size: i64, high: i64, low_name: &str, high_name: &str, both: &str| match (pinned(low), pinned(high)) { + (true, true) => both.to_string(), + (true, false) => low_name.to_string(), + (false, true) => high_name.to_string(), + (false, false) if pinned(size) => "CENTER".into(), + (false, false) => "SCALE".into(), + }; + json!({ + "horizontal": axis(4, 2, 1, "LEFT", "RIGHT", "LEFT_RIGHT"), + "vertical": axis(32, 16, 8, "TOP", "BOTTOM", "TOP_BOTTOM"), + }) +} + +/// A rectangle's corner radii: per corner from its points (top left, top right, bottom right, +/// bottom left), else its `fixedRadius`. +fn corner_radii(l: &Value, node: &mut JsonMap) { + let radius = |v: Option<&Value>| v.and_then(Value::as_f64).filter(|r| r.is_finite()).map_or(0.0, |r| r.clamp(0.0, MAX_SIZE)); + let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let each: Vec = pts.iter().map(|p| radius(p.get("cornerRadius"))).collect(); + if each.len() == 4 && each.iter().any(|r| *r > 0.0) { + node.insert("rectangleCornerRadii".into(), json!(each)); + } else if radius(l.get("fixedRadius")) > 0.0 { + node.insert("cornerRadius".into(), json!(radius(l.get("fixedRadius")))); + } +} + +/// A Sketch colour (`red`, `green`, `blue`, `alpha` in 0–1) as a REST colour. +pub(crate) fn color(c: &Value) -> Option { + let n = |k: &str| c.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0)); + Some(json!({"r": n("red")?, "g": n("green")?, "b": n("blue")?, "a": n("alpha").unwrap_or(1.0)})) +} + +pub(crate) fn bool_field(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_bool) +} + +/// At most 64 characters of a name read from the file, for warnings. +fn clip(s: &str) -> String { + s.chars().take(64).collect() } /// A text layer whose string is a binary property list (Sketch 43–47). diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs new file mode 100644 index 00000000..c9f99558 --- /dev/null +++ b/crates/format/src/sketch/tree_tests.rs @@ -0,0 +1,181 @@ +//! Sketch layers → document layers: kinds, positions, transforms, constraints, visibility. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, HConstraint, NodeKind, VConstraint}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +/// Import one page holding `layers`, with extra archive `files`. +fn open_with(layers: Vec, files: &[(&str, &[u8])]) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", layers)], files); + import(&z, "f").unwrap() +} + +fn open(layers: Vec) -> (Document, Vec) { + open_with(layers, &[]) +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}")) +} + +fn solid(doc: &Document, name: &str) -> Option<[f32; 4]> { + node(doc, name).fills.iter().find_map(|p| match &p.kind { + PaintKind::Solid { color } => Some([color.r, color.g, color.b, p.opacity]), + _ => None, + }) +} + +#[test] +fn artboard_is_frame_with_white_fill_unless_background() { + let plain = artboard("A", "Plain", 0.0, 0.0, 100.0, 50.0, vec![]); + let red = with(artboard("B", "Red", 200.0, 0.0, 100.0, 50.0, vec![]), "hasBackgroundColor", json!(true)); + let red = with(red, "backgroundColor", json!({"_class": "color", "red": 1, "green": 0, "blue": 0, "alpha": 1})); + let (doc, _) = open(vec![plain, red]); + assert!(matches!(node(&doc, "Plain").kind, NodeKind::Frame)); + assert!(node(&doc, "Plain").clip); + assert_eq!(solid(&doc, "Plain"), Some([1.0, 1.0, 1.0, 1.0])); + assert_eq!(solid(&doc, "Red"), Some([1.0, 0.0, 0.0, 1.0])); + let b = doc.world_bounds(node(&doc, "Red").id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (200.0, 0.0, 100.0, 50.0)); +} + +#[test] +fn group_children_positions_accumulate() { + let r = rect("R", 5.0, 5.0, 10.0, 10.0); + let g = group("G", 10.0, 20.0, 15.0, 15.0, vec![r]); + let (doc, _) = open(vec![artboard("A", "Board", 100.0, 100.0, 300.0, 300.0, vec![g])]); + assert!(matches!(node(&doc, "G").kind, NodeKind::Group)); + let b = doc.world_bounds(node(&doc, "R").id).unwrap(); + assert!((b.x0 - 115.0).abs() < 1e-6 && (b.y0 - 125.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn rotation_and_flip() { + let rotated = with(rect("Turned", 0.0, 0.0, 40.0, 20.0), "rotation", json!(90)); + let flipped = with(rect("Flipped", 100.0, 0.0, 40.0, 20.0), "isFlippedHorizontal", json!(true)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![rotated, flipped])]); + let b = doc.world_bounds(node(&doc, "Turned").id).unwrap(); + assert!((b.width() - 20.0).abs() < 1e-6 && (b.height() - 40.0).abs() < 1e-6, "{b:?}"); + assert!((b.center().x - 20.0).abs() < 1e-6 && (b.center().y - 10.0).abs() < 1e-6, "{b:?}"); + // Counter-clockwise on screen: the box's x axis points up. + let t = doc.world_transform(node(&doc, "Turned").id).as_coeffs(); + assert!(t[1] < -0.99, "{t:?}"); + let t = doc.world_transform(node(&doc, "Flipped").id).as_coeffs(); + assert!(t[0] < -0.99, "{t:?}"); + let b = doc.world_bounds(node(&doc, "Flipped").id).unwrap(); + assert!((b.x0 - 100.0).abs() < 1e-6 && (b.width() - 40.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn resizing_constraint_bits() { + use HConstraint as H; + use VConstraint as V; + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + let cases = [ + (63, H::Scale, V::Scale), + (63 - 4 - 32, H::Left, V::Top), + (63 - 1 - 8, H::Right, V::Bottom), + (63 - 1 - 4, H::LeftRight, V::Scale), + (63 - 8 - 32, H::Scale, V::TopBottom), + (63 - 2 - 16, H::Center, V::Center), + (63 - 1 - 2, H::Right, V::Scale), + (0, H::LeftRight, V::TopBottom), + ]; + let layers: Vec = cases + .iter() + .enumerate() + .map(|(i, (mask, _, _))| with(rect(&format!("R{i}"), 0.0, 0.0, 10.0, 10.0), "resizingConstraint", json!(mask))) + .collect(); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, layers)]); + for (i, (mask, h, v)) in cases.iter().enumerate() { + let c = node(&doc, &format!("R{i}")).constraints; + assert_eq!((c.horizontal, c.vertical), (*h, *v), "mask {mask}"); + } +} + +#[test] +fn hidden_locked_named() { + let r = with(with(rect("Hidden", 0.0, 0.0, 10.0, 10.0), "isVisible", json!(false)), "isLocked", json!(true)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![r])]); + let n = node(&doc, "Hidden"); + assert!(!n.visible && n.locked); +} + +#[test] +fn bad_numbers_are_sane() { + let mut nan = rect("Junk", 0.0, 0.0, 10.0, 10.0); + nan["frame"]["width"] = json!("NaN"); + nan["frame"]["x"] = json!(1e300); + let mut neg = rect("Negative", 0.0, 0.0, 10.0, 10.0); + neg["frame"]["height"] = json!(-5); + neg["rotation"] = json!("x"); + let mut none = rect("Frameless", 0.0, 0.0, 10.0, 10.0); + none.as_object_mut().unwrap().remove("frame"); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![nan, neg, none])]); + for n in doc.nodes() { + assert!(n.width.is_finite() && n.height.is_finite() && n.width >= 0.0 && n.height >= 0.0, "{}", n.name); + assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{}", n.name); + } +} + +#[test] +fn rectangle_corner_radii() { + let mut each = rect("Each", 0.0, 0.0, 40.0, 40.0); + for (i, r) in [1, 2, 3, 4].iter().enumerate() { + each["points"][i]["cornerRadius"] = json!(r); + } + let fixed = with(rect("Fixed", 50.0, 0.0, 40.0, 40.0), "fixedRadius", json!(8)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![each, fixed])]); + assert!(matches!(node(&doc, "Each").kind, NodeKind::Rectangle)); + assert_eq!(node(&doc, "Each").corners.radii, [1.0, 2.0, 3.0, 4.0]); + assert_eq!(node(&doc, "Fixed").corners.radii, [8.0; 4]); +} + +#[test] +fn oval_slice_and_bitmap() { + let mut oval = rect("Round", 0.0, 0.0, 30.0, 20.0); + oval["_class"] = json!("oval"); + let mut slice = rect("Cut", 0.0, 0.0, 30.0, 20.0); + slice["_class"] = json!("slice"); + let mut bitmap = rect("Photo", 0.0, 0.0, 30.0, 20.0); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref_class": "MSImageData", "_ref": "images/abc123.png"}); + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let (doc, _) = open_with(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![oval, slice, bitmap])], &[("images/abc123.png", &png)]); + assert!(matches!(node(&doc, "Round").kind, NodeKind::Ellipse { .. })); + assert!(matches!(node(&doc, "Cut").kind, NodeKind::Slice)); + let photo = node(&doc, "Photo"); + assert!(matches!(photo.kind, NodeKind::Rectangle)); + assert!(photo.fills.iter().any(|p| matches!(&p.kind, PaintKind::Image(_)))); + assert_eq!(doc.images.len(), 1); +} + +#[test] +fn unknown_class_is_a_warning() { + let mut odd = rect("Odd", 0.0, 0.0, 10.0, 10.0); + odd["_class"] = json!("MSSomethingNew"); + let (doc, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![odd])]); + assert!(doc.nodes().all(|n| n.name != "Odd")); + assert!(warnings.iter().any(|w| w.contains("MSSomethingNew")), "{warnings:?}"); +} + +#[test] +fn sketch_2025_frames_and_graphics() { + let mut frame = artboard("F", "Frame", 0.0, 0.0, 100.0, 100.0, vec![rect("Inside", 0.0, 0.0, 10.0, 10.0)]); + frame["_class"] = json!("frame"); + frame["clipsContent"] = json!(false); + let mut graphic = group("Graphic", 10.0, 10.0, 10.0, 10.0, vec![rect("Leaf", 0.0, 0.0, 10.0, 10.0)]); + graphic["_class"] = json!("graphic"); + frame["layers"].as_array_mut().unwrap().push(graphic); + let (doc, _) = open(vec![frame]); + assert!(matches!(node(&doc, "Frame").kind, NodeKind::Frame)); + assert!(!node(&doc, "Frame").clip); + assert!(matches!(node(&doc, "Graphic").kind, NodeKind::Group)); +} From 72c7eae868e2de5b27f8eaaacfe9d5f0f5b33e9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 15:59:59 +0200 Subject: [PATCH 035/208] M6: Sketch import: editable paths, shape groups, booleans, stars, polygons Paths become editable vector networks (handles, per-point corner radius, open and closed, winding rule). A shape group with one shape becomes that shape in the group's place; several combine as boolean operations, mixed operations nesting from the left as Sketch applies them. Stars and polygons stay stars and polygons while their points are the generated ones, edited ones and triangles become vectors. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/fixture.rs | 14 ++ crates/format/src/sketch/mod.rs | 3 + crates/format/src/sketch/path.rs | 193 +++++++++++++++++++++++++ crates/format/src/sketch/path_tests.rs | 154 ++++++++++++++++++++ crates/format/src/sketch/tree.rs | 69 ++++++--- 5 files changed, 410 insertions(+), 23 deletions(-) create mode 100644 crates/format/src/sketch/path.rs create mode 100644 crates/format/src/sketch/path_tests.rs diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index 231225c9..68084767 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -118,3 +118,17 @@ pub(crate) fn with(mut l: Value, key: &str, value: Value) -> Value { } l } + +/// A `shapePath` through `points` (`(x, y)` in 0–1 of its frame), straight segments. +pub(crate) fn shape_path(id: &str, x: f64, y: f64, w: f64, h: f64, points: &[(f64, f64)], closed: bool) -> Value { + let pts: Vec = points.iter().map(|(px, py)| point(*px, *py)).collect(); + let mut l = with(layer("shapePath", id, id, x, y, w, h), "points", Value::Array(pts)); + l["isClosed"] = json!(closed); + l["booleanOperation"] = json!(-1); + l +} + +/// A `shapeGroup` (Sketch 51 and older wrap every shape in one) holding `layers`. +pub(crate) fn shape_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + with(layer("shapeGroup", id, id, x, y, w, h), "layers", Value::Array(layers)) +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index a46706db..5d703622 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -13,6 +13,9 @@ #[cfg(test)] mod fixture; +mod path; +#[cfg(test)] +mod path_tests; #[cfg(test)] mod tests; mod tree; diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs new file mode 100644 index 00000000..a842b295 --- /dev/null +++ b/crates/format/src/sketch/path.rs @@ -0,0 +1,193 @@ +//! Sketch curve points → vector networks; shape groups → shapes and boolean operations. +//! +//! A path's `points` are `curvePoint`s whose `point`, `curveFrom` (the handle leaving the point) +//! and `curveTo` (the handle arriving at it) are `"{x, y}"` strings in 0–1 of the layer's frame; +//! values outside that range are allowed. Sketch 51 and older wrap every shape in a `shapeGroup` +//! that carries the style; its children combine left to right, each with its own +//! `booleanOperation` (−1 none and 0 union, 1 subtract, 2 intersect, 3 difference). + +use figcraft_doc::geom::{Affine, polygon_points, star_points}; +use figcraft_doc::{Region, Segment, VectorNetwork, Vertex, WindingRule}; +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, base, layer, layers, local, size}; + +/// Most points read from one path. +const MAX_POINTS: usize = 100_000; + +/// What a path layer becomes. +pub(crate) enum Shape { + Vector(VectorNetwork), + Star { count: u32, ratio: f64 }, + Polygon { count: u32 }, +} + +/// `"{x, y}"` → `(x, y)`, both finite. +pub(crate) fn parse_point(s: &str) -> Option<(f64, f64)> { + let inner = s.trim().strip_prefix('{')?.strip_suffix('}')?; + let (x, y) = inner.split_once(',')?; + let n = |t: &str| t.trim().parse::().ok().filter(|v| v.is_finite()); + Some((n(x)?, n(y)?)) +} + +/// The network of a path layer of `w`×`h` (`None` when it has no usable points). +pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option { + let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + if pts.len() > MAX_POINTS { + cx.warn(format!("paths with more than {MAX_POINTS} points were cut")); + } + let at = |p: &Value, key: &str| p.get(key).and_then(Value::as_str).and_then(parse_point).map(|(x, y)| (x * w, y * h)); + let mut vertices = Vec::new(); + // Per vertex: its handles (relative to it) when it has them. + let mut handles: Vec<((f64, f64), (f64, f64))> = Vec::new(); + for p in pts.iter().take(MAX_POINTS) { + let Some((x, y)) = at(p, "point") else { + cx.warn("path points that couldn't be read were skipped"); + continue; + }; + let curved = p.get("curveMode").and_then(Value::as_i64).is_some_and(|m| m != 1); + let has = |flag: &str| p.get(flag).and_then(Value::as_bool).unwrap_or(curved); + let handle = |flag: &str, key: &str| if has(flag) { at(p, key).map_or((0.0, 0.0), |(hx, hy)| (hx - x, hy - y)) } else { (0.0, 0.0) }; + handles.push((handle("hasCurveFrom", "curveFrom"), handle("hasCurveTo", "curveTo"))); + let mut v = Vertex::new(x, y); + v.radius = p.get("cornerRadius").and_then(Value::as_f64).filter(|r| r.is_finite()).map_or(0.0, |r| r.clamp(0.0, 1e6)); + vertices.push(v); + } + let n = vertices.len(); + if n == 0 { + return None; + } + let closed = l.get("isClosed").and_then(Value::as_bool).unwrap_or(false) && n > 1; + let count = if closed { n } else { n - 1 }; + let segments: Vec = (0..count) + .map(|i| { + let j = (i + 1) % n; + let ta = handles.get(i).map_or((0.0, 0.0), |h| h.0); + let tb = handles.get(j).map_or((0.0, 0.0), |h| h.1); + Segment { a: i, b: j, ta, tb } + }) + .collect(); + let winding = match l.get("style").and_then(|s| s.get("windingRule")).and_then(Value::as_i64) { + Some(1) => WindingRule::EvenOdd, + _ => WindingRule::NonZero, + }; + let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() }; + Some(VectorNetwork { vertices, segments, regions }) +} + +/// A path, star, polygon or triangle layer: a star or polygon while its points are still the +/// generated ones (same as FigCraft's, within half a percent), else an editable vector. +pub(crate) fn shape(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option { + let net = network(cx, l, w, h)?; + let class = l.get("_class").and_then(Value::as_str).unwrap_or(""); + let count = l.get("numberOfPoints").and_then(Value::as_u64).filter(|c| (3..=120).contains(c)).and_then(|c| u32::try_from(c).ok()); + let plain = net.vertices.iter().all(|v| v.radius == 0.0) && net.segments.iter().all(|s| s.ta == (0.0, 0.0) && s.tb == (0.0, 0.0)); + let matches = |pts: Vec| { + let tol = 0.005 * w.max(h) + 1e-6; + pts.len() == net.vertices.len() && pts.iter().zip(&net.vertices).all(|(p, v)| (p.x - v.x).abs() <= tol && (p.y - v.y).abs() <= tol) + }; + match (class, count) { + ("star", Some(count)) if plain => { + let ratio = l.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r > 0.0).map_or(0.5, |r| r.min(1.0)); + if matches(star_points(w, h, count, ratio)) { + return Some(Shape::Star { count, ratio }); + } + } + ("polygon", Some(count)) if plain && matches(polygon_points(w, h, count)) => return Some(Shape::Polygon { count }), + _ => {} + } + Some(Shape::Vector(net)) +} + +/// Write `shape` into `node` (its type and geometry). +pub(crate) fn put_shape(cx: &mut Cx, shape: Shape, node: &mut JsonMap) { + match shape { + Shape::Vector(net) => { + node.insert("type".into(), json!("VECTOR")); + node.insert("vectorNetworkRef".into(), json!(cx.networks.len())); + cx.networks.push(Some(net)); + } + Shape::Star { count, ratio } => { + node.insert("type".into(), json!("STAR")); + node.insert("pointCount".into(), json!(count)); + node.insert("innerRadius".into(), json!(ratio)); + } + Shape::Polygon { count } => { + node.insert("type".into(), json!("REGULAR_POLYGON")); + node.insert("pointCount".into(), json!(count)); + } + } +} + +/// A `shapeGroup`: its only child carrying the group's place, name and style, or its children +/// combined by boolean operations (mixed operations nest from the left). +pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let kids = layers(l); + if let [only] = kids { + let mut node = layer(cx, only, world, depth + 1, false)?; + let (cw, ch) = size(only); + let child_world = world * local(only, cw, ch); + let group = base(l, "GROUP", parent, child_world, cw, ch, root); + if let Some(m) = node.as_object_mut() { + for key in ["id", "name", "relativeTransform", "absoluteBoundingBox", "constraints", "locked"] { + match group.get(key) { + Some(v) => m.insert(key.into(), v.clone()), + None => m.remove(key), + }; + } + let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); + m.insert("visible".into(), json!(visible)); + } + return Some(node); + } + let mut chain: Vec = Vec::new(); + let mut op = "UNION"; + for (i, c) in kids.iter().enumerate() { + let Some(child) = layer(cx, c, world, depth + 2, false) else { continue }; + let this = boolean_op(c); + if i > 0 && this != op && chain.len() > 1 { + let inner = synthetic(l, op, parent, world, w, h, std::mem::take(&mut chain)); + chain.push(inner); + } + if i > 0 { + op = this; + } + chain.push(child); + } + if chain.is_empty() { + return None; + } + let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, root); + node.insert("booleanOperation".into(), json!(op)); + node.insert("children".into(), Value::Array(chain)); + Some(Value::Object(node)) +} + +/// A boolean operation the shape group doesn't have as a layer of its own: placed like the group. +fn synthetic(l: &Value, op: &str, parent: Affine, world: Affine, w: f64, h: f64, children: Vec) -> Value { + let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, false); + node.remove("id"); + let name = match op { + "SUBTRACT" => "Subtract", + "INTERSECT" => "Intersect", + "EXCLUDE" => "Exclude", + _ => "Union", + }; + node.insert("name".into(), json!(name)); + // Placed where the group is: inside it, that's no move at all. + node.insert("relativeTransform".into(), json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])); + node.insert("booleanOperation".into(), json!(op)); + node.insert("children".into(), Value::Array(children)); + Value::Object(node) +} + +fn boolean_op(l: &Value) -> &'static str { + match l.get("booleanOperation").and_then(Value::as_i64) { + Some(1) => "SUBTRACT", + Some(2) => "INTERSECT", + Some(3) => "EXCLUDE", + _ => "UNION", + } +} diff --git a/crates/format/src/sketch/path_tests.rs b/crates/format/src/sketch/path_tests.rs new file mode 100644 index 00000000..8e1528fb --- /dev/null +++ b/crates/format/src/sketch/path_tests.rs @@ -0,0 +1,154 @@ +//! Sketch paths → editable vector networks; shape groups → shapes and boolean operations; stars +//! and polygons. + +use figcraft_doc::{BoolOp, Document, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; +use super::path::parse_point; + +fn open(layers: Vec) -> Document { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]); + import(&z, "f").unwrap().0 +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}")) +} + +fn with_op(mut l: Value, op: i64) -> Value { + l["booleanOperation"] = json!(op); + l +} + +#[test] +fn parse_point_accepts_sketch_strings() { + assert_eq!(parse_point("{0.5, 1}"), Some((0.5, 1.0))); + assert_eq!(parse_point("{-0.25,2e-1}"), Some((-0.25, 0.2))); + assert_eq!(parse_point("{1e400, 0}"), None); + assert_eq!(parse_point("garbage"), None); + assert_eq!(parse_point("{0.1}"), None); + assert_eq!(parse_point(""), None); +} + +#[test] +fn triangle_path_is_editable_vector() { + let tri = shape_path("Tri", 10.0, 10.0, 100.0, 100.0, &[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)], true); + let open_line = shape_path("Line", 10.0, 200.0, 100.0, 50.0, &[(0.0, 0.0), (1.0, 1.0)], false); + let doc = open(vec![tri, open_line]); + let NodeKind::Vector { network } = &node(&doc, "Tri").kind else { panic!("not a vector") }; + let pts: Vec<(f64, f64)> = network.vertices.iter().map(|v| (v.x, v.y)).collect(); + assert_eq!(pts, [(50.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); + assert_eq!(network.segments.len(), 3); + assert_eq!(network.regions.len(), 1); + let NodeKind::Vector { network } = &node(&doc, "Line").kind else { panic!("not a vector") }; + assert_eq!(network.segments.len(), 1); + assert!(network.regions.is_empty()); +} + +#[test] +fn curve_handles_and_corner_radius_are_kept() { + let mut p = shape_path("Curve", 0.0, 0.0, 100.0, 100.0, &[(0.0, 0.0), (1.0, 0.0)], false); + p["points"][0]["curveMode"] = json!(2); + p["points"][0]["hasCurveFrom"] = json!(true); + p["points"][0]["curveFrom"] = json!("{0.25, 0.5}"); + p["points"][1]["curveMode"] = json!(2); + p["points"][1]["hasCurveTo"] = json!(true); + p["points"][1]["curveTo"] = json!("{0.75, 0.5}"); + p["points"][1]["cornerRadius"] = json!(6); + let doc = open(vec![p]); + let NodeKind::Vector { network } = &node(&doc, "Curve").kind else { panic!("not a vector") }; + let s = network.segments[0]; + assert_eq!((s.ta, s.tb), ((25.0, 50.0), (-25.0, 50.0))); + assert_eq!(network.vertices[1].radius, 6.0); +} + +#[test] +fn single_child_shape_group_collapses() { + let inner = with_op(rect("Inner", 0.0, 0.0, 50.0, 30.0), -1); + let doc = open(vec![shape_group("Button", 20.0, 40.0, 50.0, 30.0, vec![inner])]); + let b = node(&doc, "Button"); + assert!(matches!(b.kind, NodeKind::Rectangle)); + assert!(doc.nodes().all(|n| n.name != "Inner")); + let bb = doc.world_bounds(b.id).unwrap(); + assert_eq!((bb.x0, bb.y0, bb.width(), bb.height()), (20.0, 40.0, 50.0, 30.0)); +} + +#[test] +fn mixed_boolean_ops_chain_left() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(rect("B", 10.0, 10.0, 20.0, 20.0), 1); + let c = with_op(rect("C", 60.0, 0.0, 20.0, 20.0), 0); + let doc = open(vec![shape_group("Combined", 0.0, 0.0, 80.0, 50.0, vec![a, b, c])]); + let outer = node(&doc, "Combined"); + assert!(matches!(outer.kind, NodeKind::BooleanOp { op: BoolOp::Union })); + let kids: Vec<&figcraft_doc::Node> = outer.children.iter().map(|c| doc.node(*c).unwrap()).collect(); + assert_eq!(kids.len(), 2); + assert!(matches!(kids[0].kind, NodeKind::BooleanOp { op: BoolOp::Subtract })); + assert_eq!(kids[1].name, "C"); + let inner: Vec<&str> = kids[0].children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(inner, ["A", "B"]); + let bb = doc.world_bounds(node(&doc, "B").id).unwrap(); + assert_eq!((bb.x0, bb.y0), (10.0, 10.0)); +} + +#[test] +fn intersect_and_difference_ops() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(rect("B", 10.0, 10.0, 50.0, 50.0), 2); + let doc = open(vec![shape_group("Both", 0.0, 0.0, 60.0, 60.0, vec![a.clone(), b])]); + assert!(matches!(node(&doc, "Both").kind, NodeKind::BooleanOp { op: BoolOp::Intersect })); + let b = with_op(rect("B", 10.0, 10.0, 50.0, 50.0), 3); + let doc = open(vec![shape_group("Either", 0.0, 0.0, 60.0, 60.0, vec![a, b])]); + assert!(matches!(node(&doc, "Either").kind, NodeKind::BooleanOp { op: BoolOp::Exclude })); +} + +#[test] +fn standalone_shapes_52_plus() { + let doc = open(vec![shape_path("Free", 5.0, 5.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true)]); + assert!(matches!(node(&doc, "Free").kind, NodeKind::Vector { .. })); +} + +#[test] +fn star_and_polygon() { + let unit = |pts: Vec, w: f64, h: f64| -> Vec<(f64, f64)> { pts.iter().map(|p| (p.x / w, p.y / h)).collect() }; + let mut star = shape_path("Star", 0.0, 0.0, 100.0, 80.0, &unit(figcraft_doc::geom::star_points(100.0, 80.0, 5, 0.5), 100.0, 80.0), true); + star["_class"] = json!("star"); + star["numberOfPoints"] = json!(5); + star["radius"] = json!(0.5); + let mut poly = shape_path("Hexagon", 0.0, 100.0, 60.0, 60.0, &unit(figcraft_doc::geom::polygon_points(60.0, 60.0, 6), 60.0, 60.0), true); + poly["_class"] = json!("polygon"); + poly["numberOfPoints"] = json!(6); + let mut edited = star.clone(); + edited["do_objectID"] = json!("Edited"); + edited["name"] = json!("Edited"); + edited["points"][2]["point"] = json!("{0.9, 0.9}"); + let mut tri = shape_path("Triangle", 200.0, 0.0, 50.0, 50.0, &[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)], true); + tri["_class"] = json!("triangle"); + let doc = open(vec![star, poly, edited, tri]); + assert!(matches!(node(&doc, "Star").kind, NodeKind::Star { count: 5, .. })); + if let NodeKind::Star { inner_ratio, .. } = node(&doc, "Star").kind { + assert!((inner_ratio - 0.5).abs() < 1e-9); + } + assert!(matches!(node(&doc, "Hexagon").kind, NodeKind::Polygon { count: 6 })); + assert!(matches!(node(&doc, "Edited").kind, NodeKind::Vector { .. })); + assert!(matches!(node(&doc, "Triangle").kind, NodeKind::Vector { .. })); +} + +#[test] +fn points_outside_unit_are_kept_finite() { + let doc = open(vec![shape_path("Over", 0.0, 0.0, 10.0, 10.0, &[(-0.5, 2.0), (1.0, 0.0), (0.0, 0.0)], true)]); + let NodeKind::Vector { network } = &node(&doc, "Over").kind else { panic!("not a vector") }; + assert!(network.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())); + assert_eq!((network.vertices[0].x, network.vertices[0].y), (-5.0, 20.0)); +} + +#[test] +fn even_odd_winding() { + let mut p = shape_path("Holes", 0.0, 0.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true); + p["style"]["windingRule"] = json!(1); + let doc = open(vec![p]); + let NodeKind::Vector { network } = &node(&doc, "Holes").kind else { panic!("not a vector") }; + assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 59e3d637..89a43010 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -70,43 +70,31 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: return None; } let class = str_field(l, "_class")?; + if class == "shapeGroup" { + return count(cx).then(|| super::path::shape_group(cx, l, parent, depth, root)).flatten(); + } let ty = match class { "artboard" | "frame" => "FRAME", "group" | "graphic" => "GROUP", "rectangle" | "bitmap" => "RECTANGLE", "oval" => "ELLIPSE", "slice" => "SLICE", + "shapePath" | "star" | "polygon" | "triangle" => "VECTOR", other => { cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other))); return None; } }; - if cx.nodes >= cx.max_nodes { - cx.warn(format!("the file has more than {} layers; the rest were skipped", cx.max_nodes)); + if !count(cx) { return None; } - cx.nodes += 1; let (w, h) = size(l); let world = parent * local(l, w, h); - let mut node = JsonMap::new(); - node.insert("type".into(), json!(ty)); - if let Some(id) = str_field(l, "do_objectID") { - node.insert("id".into(), json!(id)); + let mut node = base(l, ty, parent, world, w, h, root); + if ty == "VECTOR" { + let shape = super::path::shape(cx, l, w, h)?; + super::path::put_shape(cx, shape, &mut node); } - node.insert("name".into(), json!(str_field(l, "name").unwrap_or(class))); - node.insert("visible".into(), json!(bool_field(l, "isVisible").unwrap_or(true))); - node.insert("locked".into(), json!(bool_field(l, "isLocked").unwrap_or(false))); - node.insert("size".into(), json!({"x": w, "y": h})); - let rel = parent.inverse() * world; - let [a, b, c, d, e, f] = rel.as_coeffs(); - node.insert("relativeTransform".into(), json!([[a, c, e], [b, d, f]])); - // The page places top-level layers by their transform; deeper ones also say where they end up, - // which tells the importer their transform is relative to their group. - if !root { - let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); - node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); - } - node.insert("constraints".into(), constraints(l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63))); match class { "artboard" | "frame" => { let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) }; @@ -134,8 +122,43 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: Some(Value::Object(node)) } +/// Count one more layer; false (with a warning) once the file's budget is spent. +fn count(cx: &mut Cx) -> bool { + if cx.nodes >= cx.max_nodes { + cx.warn(format!("the file has more than {} layers; the rest were skipped", cx.max_nodes)); + return false; + } + cx.nodes += 1; + true +} + +/// The properties every REST node of layer `l` has: its type, id, name, visibility, lock, size, +/// place (`world`, relative to `parent`) and constraints. +pub(crate) fn base(l: &Value, ty: &str, parent: Affine, world: Affine, w: f64, h: f64, root: bool) -> JsonMap { + let mut node = JsonMap::new(); + node.insert("type".into(), json!(ty)); + if let Some(id) = str_field(l, "do_objectID") { + node.insert("id".into(), json!(id)); + } + let class = str_field(l, "_class").unwrap_or("Layer"); + node.insert("name".into(), json!(str_field(l, "name").unwrap_or(class))); + node.insert("visible".into(), json!(bool_field(l, "isVisible").unwrap_or(true))); + node.insert("locked".into(), json!(bool_field(l, "isLocked").unwrap_or(false))); + node.insert("size".into(), json!({"x": w, "y": h})); + let [a, b, c, d, e, f] = (parent.inverse() * world).as_coeffs(); + node.insert("relativeTransform".into(), json!([[a, c, e], [b, d, f]])); + // The page places top-level layers by their transform; deeper ones also say where they end up, + // which tells the importer their transform is relative to their group. + if !root { + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); + } + node.insert("constraints".into(), constraints(l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63))); + node +} + /// A layer's size from its `frame` (finite, not negative, at most [`MAX_SIZE`]). -fn size(l: &Value) -> (f64, f64) { +pub(crate) fn size(l: &Value) -> (f64, f64) { let f = l.get("frame"); let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, MAX_SIZE); (n("width"), n("height")) @@ -143,7 +166,7 @@ fn size(l: &Value) -> (f64, f64) { /// A layer's transform within its parent: its frame's position, then its rotation (degrees, /// counter-clockwise on screen) and flips about its centre. -fn local(l: &Value, w: f64, h: f64) -> Affine { +pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { let f = l.get("frame"); let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-MAX_COORD, MAX_COORD); let rot = l.get("rotation").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) % 360.0; From d77d57912bf26408571a1844e7ca8addc244910c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:05:02 +0200 Subject: [PATCH 036/208] M6: Sketch import: fills, borders, effects, opacity, masks Solid, gradient (linear, radial with its ellipse, angular) and image pattern fills (tile, fill, stretch, fit); borders as strokes with the first enabled border's width and position, dashes, caps, joins and arrow ends; shadows, inner shadows, layer and background blurs; layer and paint opacity and blend modes; clipping masks, wrapped in a group of their own where Sketch breaks the mask chain; Display P3 documents. Shape groups pass their style to the shape or boolean operation they become. Noise fills, motion and zoom blurs are listed in the import report. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/fixture.rs | 17 ++ crates/format/src/sketch/mod.rs | 13 +- crates/format/src/sketch/path.rs | 7 + crates/format/src/sketch/style.rs | 265 ++++++++++++++++++++++++ crates/format/src/sketch/style_tests.rs | 235 +++++++++++++++++++++ crates/format/src/sketch/tree.rs | 53 ++++- 6 files changed, 585 insertions(+), 5 deletions(-) create mode 100644 crates/format/src/sketch/style.rs create mode 100644 crates/format/src/sketch/style_tests.rs diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index 68084767..dc6fefe9 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -132,3 +132,20 @@ pub(crate) fn shape_path(id: &str, x: f64, y: f64, w: f64, h: f64, points: &[(f6 pub(crate) fn shape_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { with(layer("shapeGroup", id, id, x, y, w, h), "layers", Value::Array(layers)) } + +/// A Sketch colour. +pub(crate) fn color(r: f64, g: f64, b: f64, a: f64) -> Value { + json!({"_class": "color", "red": r, "green": g, "blue": b, "alpha": a}) +} + +/// An enabled solid fill. +pub(crate) fn solid_fill(r: f64, g: f64, b: f64, a: f64) -> Value { + json!({"_class": "fill", "isEnabled": true, "fillType": 0, "color": color(r, g, b, a), + "contextSettings": {"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1}}) +} + +/// `l` with `style.` set to `value`. +pub(crate) fn styled(mut l: Value, key: &str, value: Value) -> Value { + l["style"][key] = value; + l +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 5d703622..8ab154d9 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -16,6 +16,9 @@ mod fixture; mod path; #[cfg(test)] mod path_tests; +mod style; +#[cfg(test)] +mod style_tests; #[cfg(test)] mod tests; mod tree; @@ -79,7 +82,10 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result let mut warnings = archive.warnings.clone(); warnings.extend(map.warnings); let images = |r: &str| archive.image(r); - let (doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?; + let (mut doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?; + if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) { + doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3; + } warnings.extend(more); Ok((doc, warnings)) } @@ -88,8 +94,9 @@ fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!("Sketch file: {}", msg.into())) } -/// The parts of a `.sketch` archive: the pages in document order and the images. +/// The parts of a `.sketch` archive: the document, the pages in document order and the images. pub(crate) struct Archive<'b> { + pub document: Value, /// `(id, page JSON)` in the document's order. pub pages: Vec<(String, Value)>, /// Problems met while reading (pages skipped). @@ -132,7 +139,7 @@ impl<'b> Archive<'b> { if refs.len() > MAX_PAGES { warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped")); } - Ok(Archive { pages, warnings, zip: z }) + Ok(Archive { document, pages, warnings, zip: z }) } /// The bytes of the image a layer references (`images/`). diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index a842b295..47a8f2d3 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -139,6 +139,10 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, } let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); m.insert("visible".into(), json!(visible)); + if let Some(st) = l.get("style") { + super::style::clear(m); + super::style::apply(cx, st, cw, ch, m); + } } return Some(node); } @@ -162,6 +166,9 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, root); node.insert("booleanOperation".into(), json!(op)); node.insert("children".into(), Value::Array(chain)); + if let Some(st) = l.get("style") { + super::style::apply(cx, st, w, h, &mut node); + } Some(Value::Object(node)) } diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs new file mode 100644 index 00000000..2da1b7a4 --- /dev/null +++ b/crates/format/src/sketch/style.rs @@ -0,0 +1,265 @@ +//! Sketch styles → REST paints, strokes, effects, opacity and blend modes. +//! +//! A layer's `style` lists `fills` and `borders` (each a colour, gradient or, for fills, an image +//! pattern, with its own `contextSettings` opacity and blend mode), `borderOptions` (dashes, caps, +//! joins), `shadows`, `innerShadows`, one `blur` and the layer's own `contextSettings`. Every +//! border has its own thickness and position; FigCraft strokes share one, so the first enabled +//! border's is used. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::path::parse_point; +use super::tree::{Cx, color}; + +/// Most paints or effects read from one list. +const MAX_LIST: usize = 64; + +/// Write the paints, strokes, effects, opacity and blend mode of `style` into `node`, a layer of +/// `w`×`h`. Lists the style doesn't have are left as they are. +pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { + let fills: Vec = list(style, "fills").iter().filter_map(|f| paint(cx, f, w, h)).collect(); + if !fills.is_empty() { + node.insert("fills".into(), Value::Array(fills)); + } + borders(cx, style, w, h, node); + let effects = effects(cx, style); + if !effects.is_empty() { + node.insert("effects".into(), Value::Array(effects)); + } + if let Some(ctx) = style.get("contextSettings") { + if let Some(o) = num(ctx, "opacity") { + node.insert("opacity".into(), json!(o.clamp(0.0, 1.0))); + } + if let Some(b) = ctx.get("blendMode").and_then(Value::as_i64).filter(|b| *b != 0).and_then(blend) { + node.insert("blendMode".into(), json!(b)); + } + } +} + +/// Remove what [`apply`] writes, so another style can take its place. +pub(crate) fn clear(node: &mut JsonMap) { + for key in [ + "fills", + "strokes", + "strokeWeight", + "strokeAlign", + "strokeDashes", + "strokeCap", + "strokeCapStart", + "strokeCapEnd", + "strokeJoin", + "effects", + "opacity", + "blendMode", + ] { + node.remove(key); + } +} + +/// One fill or border as a REST paint; `None` for kinds FigCraft doesn't have (with a warning). +fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option { + let mut p = match f.get("fillType").and_then(Value::as_i64).unwrap_or(0) { + 0 => json!({"type": "SOLID", "color": f.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0}))}), + 1 => gradient(f.get("gradient")?, w, h)?, + 4 => { + let Some(r) = f.get("image").and_then(|i| i.get("_ref")).and_then(Value::as_str) else { + cx.warn("image fills stored inside the JSON were skipped"); + return None; + }; + let mut p = json!({"type": "IMAGE", "imageRef": r}); + match f.get("patternFillType").and_then(Value::as_i64).unwrap_or(1) { + 0 => { + p["scaleMode"] = json!("TILE"); + p["scalingFactor"] = json!(num(f, "patternTileScale").filter(|s| *s > 0.0).unwrap_or(1.0)); + } + 2 => { + p["scaleMode"] = json!("STRETCH"); + p["imageTransform"] = json!([[1, 0, 0], [0, 1, 0]]); + } + 3 => p["scaleMode"] = json!("FIT"), + _ => p["scaleMode"] = json!("FILL"), + } + p + } + 5 => { + cx.warn("noise fills were skipped"); + return None; + } + other => { + cx.warn(format!("fills of type {other} were skipped")); + return None; + } + }; + p["visible"] = json!(f.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)); + if let Some(ctx) = f.get("contextSettings") { + if let Some(o) = num(ctx, "opacity") { + p["opacity"] = json!(o.clamp(0.0, 1.0)); + } + if let Some(b) = ctx.get("blendMode").and_then(Value::as_i64).and_then(blend) { + p["blendMode"] = json!(b); + } + } + Some(p) +} + +/// A gradient fill: `from` and `to` in 0–1 of the layer; a radial gradient's `elipseLength` is its +/// width across the main axis as a share of the main axis, measured in pixels. +fn gradient(g: &Value, w: f64, h: f64) -> Option { + let point = |k: &str| g.get(k).and_then(Value::as_str).and_then(parse_point); + let (from, to) = (point("from").unwrap_or((0.5, 0.0)), point("to").unwrap_or((0.5, 1.0))); + let stops: Vec = list(g, "stops") + .iter() + .filter_map(|s| Some(json!({"position": num(s, "position")?.clamp(0.0, 1.0), "color": s.get("color").and_then(color)?}))) + .collect(); + let (ty, handles) = match g.get("gradientType").and_then(Value::as_i64).unwrap_or(0) { + 1 => { + let e = num(g, "elipseLength").filter(|e| *e > 0.0).unwrap_or(1.0); + let (dx, dy) = ((to.0 - from.0) * w, (to.1 - from.1) * h); + let across = if w > 0.0 && h > 0.0 { (from.0 - dy * e / w, from.1 + dx * e / h) } else { from }; + ("GRADIENT_RADIAL", json!([pt(from), pt(to), pt(across)])) + } + 2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((0.5, 0.0))])), + _ => ("GRADIENT_LINEAR", json!([pt(from), pt(to)])), + }; + Some(json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops})) +} + +fn pt((x, y): (f64, f64)) -> Value { + json!({"x": x, "y": y}) +} + +/// Borders → stroke paints, and the first enabled border's thickness and position. +fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { + let list = list(style, "borders"); + let strokes: Vec = list.iter().filter_map(|b| paint(cx, b, w, h)).collect(); + if strokes.is_empty() { + return; + } + let enabled: Vec<&Value> = list.iter().filter(|b| b.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)).collect(); + let first = enabled.first().copied().or(list.first()); + let shape = |b: &Value| (num(b, "thickness").unwrap_or(1.0), b.get("position").and_then(Value::as_i64).unwrap_or(0)); + if let Some(b) = first { + let (t, pos) = shape(b); + if enabled.iter().any(|o| shape(o) != (t, pos)) { + cx.warn("layers with several borders of different widths or positions use the first one's"); + } + node.insert("strokeWeight".into(), json!(t.clamp(0.0, 1e6))); + node.insert( + "strokeAlign".into(), + json!(match pos { + 1 => "INSIDE", + 2 => "OUTSIDE", + _ => "CENTER", + }), + ); + } + node.insert("strokes".into(), Value::Array(strokes)); + if let Some(o) = style.get("borderOptions") { + let dashes: Vec = o + .get("dashPattern") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(Value::as_f64) + .filter(|d| d.is_finite() && *d >= 0.0) + .take(MAX_LIST) + .collect(); + if !dashes.is_empty() { + node.insert("strokeDashes".into(), json!(dashes)); + } + node.insert( + "strokeCap".into(), + json!(match o.get("lineCapStyle").and_then(Value::as_i64) { + Some(1) => "ROUND", + Some(2) => "SQUARE", + _ => "NONE", + }), + ); + node.insert( + "strokeJoin".into(), + json!(match o.get("lineJoinStyle").and_then(Value::as_i64) { + Some(1) => "ROUND", + Some(2) => "BEVEL", + _ => "MITER", + }), + ); + } + for (key, field) in [("startMarkerType", "strokeCapStart"), ("endMarkerType", "strokeCapEnd")] { + let cap = match style.get(key).and_then(Value::as_i64).unwrap_or(0) { + 0 => continue, + 1 => "LINE_ARROW", + 2 => "TRIANGLE_ARROW", + 5 => "CIRCLE_FILLED", + _ => { + cx.warn("line ends other than arrows and filled circles were drawn without an end"); + continue; + } + }; + node.insert(field.into(), json!(cap)); + } +} + +/// Shadows, inner shadows and the blur as REST effects. +fn effects(cx: &mut Cx, style: &Value) -> Vec { + let mut out = Vec::new(); + for (key, ty) in [("shadows", "DROP_SHADOW"), ("innerShadows", "INNER_SHADOW")] { + for s in list(style, key) { + let mut c = s.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 0.5})); + let o = s.get("contextSettings").and_then(|c| num(c, "opacity")).unwrap_or(1.0).clamp(0.0, 1.0); + c["a"] = json!(c["a"].as_f64().unwrap_or(1.0) * o); + out.push(json!({ + "type": ty, + "visible": s.get("isEnabled").and_then(Value::as_bool).unwrap_or(true), + "radius": num(s, "blurRadius").unwrap_or(0.0).clamp(0.0, 1e6), + "spread": num(s, "spread").unwrap_or(0.0), + "offset": {"x": num(s, "offsetX").unwrap_or(0.0), "y": num(s, "offsetY").unwrap_or(0.0)}, + "color": c, + })); + } + } + if let Some(b) = style.get("blur").filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) { + let radius = num(b, "radius").unwrap_or(0.0).clamp(0.0, 1e6); + match b.get("type").and_then(Value::as_i64).unwrap_or(0) { + 0 => out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius})), + 3 => out.push(json!({"type": "BACKGROUND_BLUR", "visible": true, "radius": radius})), + _ => cx.warn("motion and zoom blurs were skipped"), + } + } + out.truncate(MAX_LIST); + out +} + +/// Sketch's blend modes (0 normal … 17 plus lighter) by REST name. +fn blend(mode: i64) -> Option<&'static str> { + const MODES: [&str; 18] = [ + "NORMAL", + "DARKEN", + "MULTIPLY", + "COLOR_BURN", + "LIGHTEN", + "SCREEN", + "COLOR_DODGE", + "OVERLAY", + "SOFT_LIGHT", + "HARD_LIGHT", + "DIFFERENCE", + "EXCLUSION", + "HUE", + "SATURATION", + "COLOR", + "LUMINOSITY", + "LINEAR_BURN", + "LINEAR_DODGE", + ]; + MODES.get(usize::try_from(mode).ok()?).copied() +} + +fn list<'v>(v: &'v Value, key: &str) -> &'v [Value] { + let l = v.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + l.get(..MAX_LIST).unwrap_or(l) +} + +fn num(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_f64).filter(|x| x.is_finite()) +} diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs new file mode 100644 index 00000000..81b9f774 --- /dev/null +++ b/crates/format/src/sketch/style_tests.rs @@ -0,0 +1,235 @@ +//! Sketch styles → paints, strokes, effects, opacity, blend modes and masks. + +use figcraft_doc::color::{BlendMode, ColorProfile, GradientKind, ImageScaleMode, PaintKind}; +use figcraft_doc::{Document, EffectKind, NodeKind, StrokeAlign, StrokeCap, StrokeJoin}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +fn open_doc(document_json: Value, layers: Vec, files: &[(&str, &[u8])]) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document_json, &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], files); + import(&z, "f").unwrap() +} + +fn open(layers: Vec) -> (Document, Vec) { + open_doc(document(&["P"]), layers, &[]) +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d figcraft_doc::Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer named {name}")) +} + +fn r(name: &str) -> Value { + rect(name, 0.0, 0.0, 100.0, 50.0) +} + +#[test] +fn solid_and_disabled_fills() { + let mut second = solid_fill(0.0, 0.0, 1.0, 1.0); + second["isEnabled"] = json!(false); + let mut half = solid_fill(1.0, 0.0, 0.0, 1.0); + half["contextSettings"]["opacity"] = json!(0.5); + let (doc, _) = open(vec![styled(r("Two"), "fills", json!([half, second]))]); + let fills = &node(&doc, "Two").fills; + assert_eq!(fills.len(), 2); + assert!(matches!(fills[0].kind, PaintKind::Solid { color } if color.r == 1.0 && color.b == 0.0)); + assert!((fills[0].opacity - 0.5).abs() < 1e-6 && fills[0].visible); + assert!(!fills[1].visible); +} + +#[test] +fn collapsed_shape_group_takes_the_groups_style() { + let inner = with(r("Inner"), "booleanOperation", json!(-1)); + let g = styled(shape_group("Button", 0.0, 0.0, 100.0, 50.0, vec![inner]), "fills", json!([solid_fill(1.0, 0.0, 0.0, 1.0)])); + let (doc, _) = open(vec![g]); + assert!(matches!(node(&doc, "Button").fills[0].kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0)); +} + +#[test] +fn boolean_shape_group_takes_the_groups_style() { + let a = with(rect("A", 0.0, 0.0, 50.0, 50.0), "booleanOperation", json!(-1)); + let b = with(rect("B", 10.0, 10.0, 20.0, 20.0), "booleanOperation", json!(1)); + let g = styled(shape_group("Ring", 0.0, 0.0, 50.0, 50.0, vec![a, b]), "fills", json!([solid_fill(0.0, 1.0, 0.0, 1.0)])); + let (doc, _) = open(vec![g]); + assert_eq!(node(&doc, "Ring").fills.len(), 1); +} + +#[test] +fn gradients() { + let stops = json!([{"_class": "gradientStop", "position": 0, "color": color(1.0, 0.0, 0.0, 1.0)}, + {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 1.0, 1.0)}]); + let grad = |ty: i64, ellipse: f64| { + json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": ty, "from": "{0.5, 0}", "to": "{0.5, 1}", "elipseLength": ellipse, "stops": stops.clone()}}) + }; + let (doc, _) = open(vec![ + styled(r("Linear"), "fills", json!([grad(0, 0.0)])), + styled(r("Radial"), "fills", json!([grad(1, 0.5)])), + styled(r("Angular"), "fills", json!([grad(2, 0.0)])), + ]); + let g = |name: &str| match &node(&doc, name).fills[0].kind { + PaintKind::Gradient(g) => g.clone(), + other => panic!("{name}: {other:?}"), + }; + let lin = g("Linear"); + assert_eq!(lin.kind, GradientKind::Linear); + assert_eq!((lin.from, lin.to), ((0.5, 0.0), (0.5, 1.0))); + assert_eq!(lin.stops.len(), 2); + assert_eq!(lin.stops[1].color.b, 1.0); + assert_eq!(g("Radial").kind, GradientKind::Radial); + assert_eq!(g("Angular").kind, GradientKind::Angular); + // The radial gradient's width handle: half the length, across the main axis (in pixels). + let rad = g("Radial"); + let (dx, dy) = ((rad.width.0 - rad.from.0) * 100.0, (rad.width.1 - rad.from.1) * 50.0); + assert!(((dx * dx + dy * dy).sqrt() - 25.0).abs() < 1e-6, "{rad:?}"); +} + +#[test] +fn pattern_fill_modes() { + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let pat = |mode: i64| { + json!({"_class": "fill", "isEnabled": true, "fillType": 4, "patternFillType": mode, "patternTileScale": 1, + "image": {"_class": "MSJSONFileReference", "_ref": "images/p1.png"}}) + }; + let layers = (0..4).map(|m| styled(r(&format!("M{m}")), "fills", json!([pat(m)]))).collect(); + let (doc, _) = open_doc(document(&["P"]), layers, &[("images/p1.png", &png)]); + let mode = |name: &str| match &node(&doc, name).fills[0].kind { + PaintKind::Image(i) => i.scale_mode, + other => panic!("{other:?}"), + }; + assert_eq!(mode("M0"), ImageScaleMode::Tile); + assert_eq!(mode("M1"), ImageScaleMode::Fill); + assert_eq!(mode("M2"), ImageScaleMode::Crop); + assert_eq!(mode("M3"), ImageScaleMode::Fit); + assert_eq!(doc.images.len(), 1); +} + +#[test] +fn noise_fill_warns() { + let noise = json!({"_class": "fill", "isEnabled": true, "fillType": 5, "noiseIntensity": 0.3}); + let (_, warnings) = open(vec![styled(r("Noisy"), "fills", json!([noise]))]); + assert!(warnings.iter().any(|w| w.contains("noise")), "{warnings:?}"); +} + +#[test] +fn borders() { + let border = |pos: i64, t: f64| json!({"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": pos, "thickness": t}); + let opts = json!({"_class": "borderOptions", "isEnabled": true, "dashPattern": [4, 2], "lineCapStyle": 1, "lineJoinStyle": 2}); + let (doc, warnings) = open(vec![ + styled(r("Center"), "borders", json!([border(0, 2.0)])), + styled(styled(r("Inside"), "borders", json!([border(1, 3.0)])), "borderOptions", opts), + styled(r("Outside"), "borders", json!([border(2, 1.0)])), + styled(r("Two"), "borders", json!([border(1, 1.0), border(1, 5.0)])), + ]); + let s = |n: &str| node(&doc, n).stroke.clone(); + assert_eq!((s("Center").align, s("Center").weight), (StrokeAlign::Center, 2.0)); + assert_eq!((s("Inside").align, s("Inside").weight), (StrokeAlign::Inside, 3.0)); + assert_eq!(s("Inside").dashes, vec![4.0, 2.0]); + assert_eq!((s("Inside").cap_start, s("Inside").join), (StrokeCap::Round, StrokeJoin::Bevel)); + assert_eq!(s("Outside").align, StrokeAlign::Outside); + assert_eq!(node(&doc, "Two").strokes.len(), 2); + assert_eq!(s("Two").weight, 1.0); + assert!(warnings.iter().any(|w| w.contains("borders")), "{warnings:?}"); +} + +#[test] +fn shadows_and_blurs() { + let shadow = json!({"_class": "shadow", "isEnabled": true, "blurRadius": 8, "offsetX": 2, "offsetY": 4, "spread": 1, "color": color(0.0, 0.0, 0.0, 0.5), + "contextSettings": {"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1}}); + let mut off = shadow.clone(); + off["isEnabled"] = json!(false); + let blur = |ty: i64| json!({"_class": "blur", "isEnabled": true, "radius": 6, "type": ty}); + let (doc, warnings) = open(vec![ + styled(styled(r("Shadowed"), "shadows", json!([shadow, off])), "innerShadows", json!([shadow])), + styled(r("Blurred"), "blur", blur(0)), + styled(r("Frosted"), "blur", blur(3)), + styled(r("Moving"), "blur", blur(1)), + ]); + let fx = &node(&doc, "Shadowed").effects; + assert_eq!(fx.len(), 3); + match &fx[0].kind { + EffectKind::DropShadow(s) => assert_eq!((s.offset, s.radius, s.spread, s.color.a), ((2.0, 4.0), 8.0, 1.0, 0.5)), + other => panic!("{other:?}"), + } + assert!(!fx[1].visible); + assert!(matches!(fx[2].kind, EffectKind::InnerShadow(_))); + assert!(matches!(node(&doc, "Blurred").effects[0].kind, EffectKind::LayerBlur(_))); + assert!(matches!(node(&doc, "Frosted").effects[0].kind, EffectKind::BackgroundBlur(_))); + assert!(node(&doc, "Moving").effects.is_empty()); + assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}"); +} + +#[test] +fn opacity_and_blend() { + let modes = [ + BlendMode::Normal, + BlendMode::Darken, + BlendMode::Multiply, + BlendMode::ColorBurn, + BlendMode::Lighten, + BlendMode::Screen, + BlendMode::ColorDodge, + BlendMode::Overlay, + BlendMode::SoftLight, + BlendMode::HardLight, + BlendMode::Difference, + BlendMode::Exclusion, + BlendMode::Hue, + BlendMode::Saturation, + BlendMode::Color, + BlendMode::Luminosity, + BlendMode::PlusDarker, + BlendMode::PlusLighter, + ]; + let layers = (0..modes.len()) + .map(|i| styled(r(&format!("B{i}")), "contextSettings", json!({"_class": "graphicsContextSettings", "opacity": 0.3, "blendMode": i}))) + .collect(); + let (doc, _) = open(layers); + for (i, m) in modes.iter().enumerate() { + let n = node(&doc, &format!("B{i}")); + assert!((n.opacity - 0.3).abs() < 1e-6); + // A normal layer passes through like any other. + if i > 0 { + assert_eq!(n.blend, *m, "mode {i}"); + } + } +} + +#[test] +fn clipping_mask_chain() { + let mask = with(r("Mask"), "hasClippingMask", json!(true)); + let a = r("A"); + let b = with(r("B"), "shouldBreakMaskChain", json!(true)); + let c = r("C"); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 100.0, 50.0, vec![mask, a, b, c])]); + let m = node(&doc, "Mask"); + assert!(m.is_mask); + // The mask and what it clips share a group of their own, so B and C stay unclipped. + let holder = m.parent.unwrap(); + let kids: Vec<&str> = doc.node(holder).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(kids, ["Mask", "A"]); + assert_ne!(node(&doc, "B").parent, Some(holder)); + let bb = doc.world_bounds(node(&doc, "A").id).unwrap(); + assert_eq!((bb.x0, bb.y0), (0.0, 0.0)); +} + +#[test] +fn display_p3_document() { + let mut d = document(&["P"]); + d["colorSpace"] = json!(2); + let (doc, _) = open_doc(d, vec![], &[]); + assert_eq!(doc.color_profile, ColorProfile::DisplayP3); +} + +#[test] +fn style_on_frames_and_text_kinds_untouched() { + // An artboard keeps its background when its style has no fills. + let (doc, _) = open(vec![]); + assert!(matches!(node(&doc, "Board").kind, NodeKind::Frame)); + assert_eq!(node(&doc, "Board").fills.len(), 1); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 89a43010..303be326 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -50,7 +50,7 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { node.insert("type".into(), json!("CANVAS")); node.insert("id".into(), json!(id)); node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page"))); - let kids: Vec = layers(page).iter().filter_map(|l| layer(&mut cx, l, Affine::IDENTITY, 0, true)).collect(); + let kids = children(&mut cx, page, Affine::IDENTITY, (0.0, 0.0), 0, true); node.insert("children".into(), Value::Array(kids)); canvases.push(Value::Object(node)); } @@ -95,6 +95,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: let shape = super::path::shape(cx, l, w, h)?; super::path::put_shape(cx, shape, &mut node); } + if bool_field(l, "hasClippingMask") == Some(true) { + node.insert("isMask".into(), json!(true)); + node.insert("maskType".into(), json!(if l.get("clippingMaskMode").and_then(Value::as_i64) == Some(1) { "ALPHA" } else { "VECTOR" })); + } match class { "artboard" | "frame" => { let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) }; @@ -115,8 +119,11 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: }, _ => {} } + if let Some(st) = l.get("style") { + super::style::apply(cx, st, w, h, &mut node); + } if matches!(ty, "FRAME" | "GROUP") { - let kids: Vec = layers(l).iter().filter_map(|c| layer(cx, c, world, depth + 1, false)).collect(); + let kids = children(cx, l, world, (w, h), depth, false); node.insert("children".into(), Value::Array(kids)); } Some(Value::Object(node)) @@ -224,6 +231,48 @@ fn clip(s: &str) -> String { s.chars().take(64).collect() } +/// The child layers of `l` (placed at `world`, `size`) as REST nodes. A clipping mask clips the +/// layers after it up to one that breaks the chain (or the next mask); when that happens before +/// the end, the mask and what it clips are wrapped in a group of their own, since a FigCraft mask +/// clips everything above it. +pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), depth: usize, root: bool) -> Vec { + let built: Vec<(bool, Value)> = layers(l) + .iter() + .filter_map(|c| { + let breaks = bool_field(c, "shouldBreakMaskChain") == Some(true) || bool_field(c, "hasClippingMask") == Some(true); + layer(cx, c, world, depth + 1, root).map(|n| (breaks, n)) + }) + .collect(); + let mut out = Vec::new(); + let mut i = 0; + while let Some((_, n)) = built.get(i) { + let end = if n.get("isMask").and_then(Value::as_bool) == Some(true) { + built.iter().skip(i + 1).position(|(b, _)| *b).map(|k| i + 1 + k) + } else { + None + }; + let Some(end) = end else { + out.push(n.clone()); + i += 1; + continue; + }; + let masked: Vec = built.get(i..end).unwrap_or_default().iter().map(|(_, n)| n.clone()).collect(); + let mut g = JsonMap::new(); + g.insert("type".into(), json!("GROUP")); + g.insert("name".into(), json!("Mask group")); + g.insert("relativeTransform".into(), json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])); + g.insert("size".into(), json!({"x": size.0, "y": size.1})); + if !root { + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, size.0, size.1)); + g.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); + } + g.insert("children".into(), Value::Array(masked)); + out.push(Value::Object(g)); + i = end; + } + out +} + /// A text layer whose string is a binary property list (Sketch 43–47). fn is_archived_text(l: &Value) -> bool { l.get("attributedString").is_some_and(|a| a.get("archivedAttributedString").is_some()) From 8f32e4f504720d7959d98c9299372fe4168aec0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:09:08 +0200 Subject: [PATCH 037/208] M6: Sketch import: shape groups that are clipping masks clip Sketch 51 and older files make masks from shape groups; the mask flag was lost when the group became its shape or boolean operation, so a masked map spilled out of its card. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/path.rs | 2 ++ crates/format/src/sketch/style_tests.rs | 15 +++++++++++++++ crates/format/src/sketch/tree.rs | 13 +++++++++---- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 47a8f2d3..6ebb1a75 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -143,6 +143,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, super::style::clear(m); super::style::apply(cx, st, cw, ch, m); } + super::tree::mask(l, m); } return Some(node); } @@ -166,6 +167,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, root); node.insert("booleanOperation".into(), json!(op)); node.insert("children".into(), Value::Array(chain)); + super::tree::mask(l, &mut node); if let Some(st) = l.get("style") { super::style::apply(cx, st, w, h, &mut node); } diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index 81b9f774..6bc02bd8 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -233,3 +233,18 @@ fn style_on_frames_and_text_kinds_untouched() { assert!(matches!(node(&doc, "Board").kind, NodeKind::Frame)); assert_eq!(node(&doc, "Board").fills.len(), 1); } + +#[test] +fn shape_group_masks_clip() { + // Sketch 51 and older: the mask is a shape group. + let inner = with(r("Outline"), "booleanOperation", json!(-1)); + let mask = with(shape_group("Card", 0.0, 0.0, 100.0, 50.0, vec![inner]), "hasClippingMask", json!(true)); + let a = rect("A", 0.0, 0.0, 300.0, 300.0); + let b = with(r("B"), "shouldBreakMaskChain", json!(true)); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 300.0, 300.0, vec![mask, a, b])]); + let m = node(&doc, "Card"); + assert!(m.is_mask); + let holder = doc.node(m.parent.unwrap()).unwrap(); + let kids: Vec<&str> = holder.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(kids, ["Card", "A"]); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 303be326..bfa59455 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -95,10 +95,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: let shape = super::path::shape(cx, l, w, h)?; super::path::put_shape(cx, shape, &mut node); } - if bool_field(l, "hasClippingMask") == Some(true) { - node.insert("isMask".into(), json!(true)); - node.insert("maskType".into(), json!(if l.get("clippingMaskMode").and_then(Value::as_i64) == Some(1) { "ALPHA" } else { "VECTOR" })); - } + mask(l, &mut node); match class { "artboard" | "frame" => { let clips = if class == "artboard" { true } else { bool_field(l, "clipsContent").unwrap_or(true) }; @@ -129,6 +126,14 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: Some(Value::Object(node)) } +/// A clipping mask's flag and kind (outline or alpha). +pub(crate) fn mask(l: &Value, node: &mut JsonMap) { + if bool_field(l, "hasClippingMask") == Some(true) { + node.insert("isMask".into(), json!(true)); + node.insert("maskType".into(), json!(if l.get("clippingMaskMode").and_then(Value::as_i64) == Some(1) { "ALPHA" } else { "VECTOR" })); + } +} + /// Count one more layer; false (with a warning) once the file's budget is spent. fn count(cx: &mut Cx) -> bool { if cx.nodes >= cx.max_nodes { From ff9d966ea679803c9203bb14978e3c2df0808aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:18:34 +0200 Subject: [PATCH 038/208] M6: Sketch import: masks that show their own style draw it Sketch draws a clipping mask with its fills, borders and effects under what it clips; a FigCraft mask only clips. A plain copy of such a mask goes underneath it, so a light avatar circle with a white icon in it no longer vanishes on a white artboard. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sketch_import_tests.rs | 51 +++++++++++++++++++++++- crates/format/src/sketch/style_tests.rs | 35 ++++++++++++++++ crates/format/src/sketch/tree.rs | 26 ++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index cb3bf2f1..09959bb6 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -10,6 +10,11 @@ use crate::Session; /// A minimal `.sketch` zip with one page per `(id, name)`, saved by `app_version`. fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec { + sketch_with(app_version, pages, &[]) +} + +/// [`sketch`] with `layers` on the first page. +fn sketch_with(app_version: &str, pages: &[(&str, &str)], layers: &[Value]) -> Vec { let refs: Vec = pages.iter().map(|(id, _)| json!({"_class": "MSJSONFileReference", "_ref": format!("pages/{id}")})).collect(); let mut out = Vec::new(); { @@ -19,9 +24,10 @@ fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec { z.write_all(json!({"_class": "document", "pages": refs}).to_string().as_bytes()).unwrap(); z.start_file("meta.json", o).unwrap(); z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": 103}).to_string().as_bytes()).unwrap(); - for (id, name) in pages { + for (i, (id, name)) in pages.iter().enumerate() { + let ls = if i == 0 { layers.to_vec() } else { Vec::new() }; z.start_file(format!("pages/{id}.json"), o).unwrap(); - z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": []}).to_string().as_bytes()).unwrap(); + z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": ls}).to_string().as_bytes()).unwrap(); } z.finish().unwrap(); } @@ -87,3 +93,44 @@ fn old_and_damaged_sketch_files_fail_cleanly() { assert!(s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +fn shape(class: &str, name: &str, frame: [f64; 4], extra: Value) -> Value { + let mut l = json!({"_class": class, "do_objectID": name, "name": name, "isVisible": true, "resizingConstraint": 63, + "frame": {"x": frame[0], "y": frame[1], "width": frame[2], "height": frame[3]}, "style": {"fills": []}}); + for (k, v) in extra.as_object().unwrap() { + l[k] = v.clone(); + } + l +} + +fn fill(grey: f64) -> Value { + json!({"style": {"fills": [{"isEnabled": true, "fillType": 0, "color": {"red": grey, "green": grey, "blue": grey, "alpha": 1}}]}}) +} + +/// Sketch draws a mask's own fill under what it clips; the clipped layers show inside it. +#[test] +fn sketch_masks_show_their_content() { + let oval = shape("oval", "Ring", [0.0, 0.0, 60.0, 60.0], json!({"booleanOperation": -1, "points": []})); + let mut mask = shape("shapeGroup", "Circle", [0.0, 0.0, 60.0, 60.0], fill(0.8)); + mask["hasClippingMask"] = json!(true); + mask["layers"] = json!([oval]); + let pts = ["{0, 0}", "{1, 0}", "{1, 1}", "{0, 1}"].map(|p| json!({"point": p, "curveFrom": p, "curveTo": p, "curveMode": 1})); + let body = shape("shapePath", "Square", [0.0, 0.0, 20.0, 20.0], json!({"points": pts, "isClosed": true, "booleanOperation": -1})); + let mut content = shape("shapeGroup", "Body", [20.0, 20.0, 20.0, 20.0], fill(0.2)); + content["layers"] = json!([body]); + let icon = shape("group", "Icon", [0.0, 0.0, 60.0, 60.0], json!({"layers": [mask, content]})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("50.2", &[("A", "Page")], &[icon])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let icon = doc.nodes().find(|n| n.name == "Icon").unwrap().id; + let pix = figcraft_render::render_node(doc, icon, 1.0).unwrap(); + let px = |x: u32, y: u32| pix.pixel(x, y).unwrap(); + // The square in the middle, dark grey. + let c = px(30, 30); + assert!(c.alpha() > 200 && c.red() < 100, "content: {c:?}"); + // The mask's own fill around it, light grey; outside the circle nothing. + let m = px(30, 8); + assert!(m.alpha() > 200 && m.red() > 150, "mask fill: {m:?}"); + assert!(px(2, 2).alpha() < 50); +} diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index 6bc02bd8..ee428c88 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -248,3 +248,38 @@ fn shape_group_masks_clip() { let kids: Vec<&str> = holder.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); assert_eq!(kids, ["Card", "A"]); } + +#[test] +fn masked_icon_keeps_its_geometry() { + let oval = { + let mut o = with(rect("Path", 0.0, 0.0, 67.0, 67.0), "booleanOperation", json!(-1)); + o["_class"] = json!("oval"); + o + }; + let mask = styled( + with(shape_group("Circle", 0.0, 0.0, 67.0, 67.0, vec![oval]), "hasClippingMask", json!(true)), + "fills", + json!([solid_fill(0.9, 0.9, 0.9, 1.0)]), + ); + let p = |id: &str, x: f64, y: f64, w: f64, h: f64, op: i64| { + with(shape_path(id, x, y, w, h, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true), "booleanOperation", json!(op)) + }; + let content = styled( + shape_group( + "Body", + 12.0, + 12.0, + 45.0, + 62.0, + vec![p("P1", 9.0, 9.0, 22.0, 22.0, -1), p("P2", 25.0, 15.0, 20.0, 15.0, 0), p("P3", 0.0, 17.0, 31.0, 45.0, 0)], + ), + "fills", + json!([solid_fill(0.5, 0.5, 0.5, 1.0)]), + ); + let (doc, _) = open(vec![group("Icon", 152.0, 20.0, 67.0, 67.0, vec![mask, content])]); + let b = doc.world_bounds(node(&doc, "Circle").id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (152.0, 20.0, 67.0, 67.0), "mask"); + let b = doc.world_bounds(node(&doc, "Body").id).unwrap(); + assert!((b.x0 - 164.0).abs() < 1e-6 && (b.y0 - 41.0).abs() < 1e-6 && (b.width() - 45.0).abs() < 1e-6, "body {b:?}"); + assert!(matches!(node(&doc, "Circle").kind, NodeKind::Ellipse { .. })); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index bfa59455..c59b8c90 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -247,6 +247,12 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), let breaks = bool_field(c, "shouldBreakMaskChain") == Some(true) || bool_field(c, "hasClippingMask") == Some(true); layer(cx, c, world, depth + 1, root).map(|n| (breaks, n)) }) + .flat_map(|(breaks, n)| match drawn_mask(&n) { + // Sketch also draws a mask with its own style, under what it clips; a FigCraft mask + // only clips, so a plain copy of it goes underneath. + Some(copy) => vec![(false, copy), (breaks, n)], + None => vec![(breaks, n)], + }) .collect(); let mut out = Vec::new(); let mut i = 0; @@ -278,6 +284,26 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), out } +/// A plain copy of mask `n` when its style shows something (fills, borders or effects). +fn drawn_mask(n: &Value) -> Option { + if n.get("isMask").and_then(Value::as_bool) != Some(true) { + return None; + } + let shows = + |key: &str| n.get(key).and_then(Value::as_array).is_some_and(|l| l.iter().any(|p| p.get("visible").and_then(Value::as_bool) != Some(false))); + if !(shows("fills") || shows("strokes") || shows("effects")) { + return None; + } + let mut copy = n.clone(); + let m = copy.as_object_mut()?; + m.remove("isMask"); + m.remove("maskType"); + m.remove("id"); + let name = m.get("name").and_then(Value::as_str).unwrap_or("Mask").to_string(); + m.insert("name".into(), json!(format!("{name} (drawn)"))); + Some(copy) +} + /// A text layer whose string is a binary property list (Sketch 43–47). fn is_archived_text(l: &Value) -> bool { l.get("attributedString").is_some_and(|a| a.get("archivedAttributedString").is_some()) From a0542e581c57de857fac34ca6c9c21829dcd6d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:23:30 +0200 Subject: [PATCH 039/208] M6: Sketch import: text with styled runs and font names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text layers keep their string and styled runs (font, size, colour, line height, letter spacing, decorations, text transform; ranges in UTF-16 units, clamped), paragraph alignment and spacing, vertical alignment and how the box sizes. Fonts named by PostScript name become family and style; the engine then matches families to installed ones ignoring spaces (PingFangSC → PingFang SC). Fonts embedded in newer files are named in the import report. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 35 +++++ crates/engine/src/sketch_import_tests.rs | 11 ++ crates/format/src/fig/mod.rs | 2 +- crates/format/src/sketch/fixture.rs | 19 +++ crates/format/src/sketch/mod.rs | 19 +++ crates/format/src/sketch/text.rs | 165 ++++++++++++++++++++ crates/format/src/sketch/text_tests.rs | 188 +++++++++++++++++++++++ crates/format/src/sketch/tree.rs | 4 + 8 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 crates/format/src/sketch/text.rs create mode 100644 crates/format/src/sketch/text_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4f8802b7..179f1c06 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -558,11 +558,46 @@ fn load_with( fn load_sketch(bytes: &[u8], name: &str) -> Option> { let r = figcraft_format::sketch::try_import(bytes, name)?; Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + match_font_families(&mut doc); link_instances(&mut doc); figcraft_format::Loaded { issues: warnings, ..doc.into() } })) } +/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from +/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and +/// case. +fn match_font_families(doc: &mut Document) { + let installed = figcraft_text::fontdb::family_names(); + let mut cache: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut fix = |style: &mut figcraft_doc::TextStyle| { + let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed)); + if let Some(f) = found { + style.font_family = f.clone(); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + fix(&mut td.style); + for r in &mut td.runs { + fix(&mut r.style); + } + } + } +} + +/// The installed family `family` means when it isn't installed under that exact name: the one +/// whose name is the same without spaces, ignoring case. +pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { + let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::(); + if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) { + return None; + } + let want = key(family); + installed.iter().find(|f| key(f) == want).cloned() +} + /// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { let r = figcraft_format::fig::try_import(bytes, name)?; diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index 09959bb6..ac35cb06 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -134,3 +134,14 @@ fn sketch_masks_show_their_content() { assert!(m.alpha() > 200 && m.red() > 150, "mask fill: {m:?}"); assert!(px(2, 2).alpha() < 50); } + +/// Family names guessed from PostScript names find the installed family whatever its spaces. +#[test] +fn sketch_font_families_match_installed_ones() { + let installed = ["PingFang SC".to_string(), "Helvetica Neue".to_string(), "IBM Plex Sans".to_string()]; + let m = |f: &str| crate::cmd::file::installed_family(f, &installed); + assert_eq!(m("Ping Fang SC").as_deref(), Some("PingFang SC")); + assert_eq!(m("Helvetica Neue"), None, "already right"); + assert_eq!(m("IBMPlex Sans").as_deref(), Some("IBM Plex Sans")); + assert_eq!(m("Comic Sans"), None); +} diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 4c103d08..8932c18a 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -115,7 +115,7 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma /// A font style under the name the format uses: the standard weight names as words ("SemiBold" /// → "Semi Bold", "ExtraBoldItalic" → "Extra Bold Italic", "Regular Italic" → "Italic"). Other /// names stay as they are. -fn style_name(style: &str) -> String { +pub(crate) fn style_name(style: &str) -> String { const WEIGHTS: [&str; 12] = ["Thin", "Extra Light", "Ultra Light", "Light", "Regular", "Medium", "Semi Bold", "Demi Bold", "Bold", "Extra Bold", "Ultra Bold", "Black"]; let compact = |s: &str| s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect::(); diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index dc6fefe9..a7c5ea73 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -149,3 +149,22 @@ pub(crate) fn styled(mut l: Value, key: &str, value: Value) -> Value { l["style"][key] = value; l } + +/// A text layer showing `string`, one run per `(location, length, font, size, rgba)`; locations and +/// lengths count UTF-16 code units, as Sketch does. +pub(crate) fn text(id: &str, x: f64, y: f64, w: f64, h: f64, string: &str, runs: &[(usize, usize, &str, f64, [f64; 4])]) -> Value { + let attrs: Vec = runs + .iter() + .map(|(loc, len, font, size, [r, g, b, a])| { + json!({"_class": "stringAttribute", "location": loc, "length": len, "attributes": { + "MSAttributedStringFontAttribute": {"_class": "fontDescriptor", "attributes": {"name": font, "size": size}}, + "MSAttributedStringColorAttribute": color(*r, *g, *b, *a), + "paragraphStyle": {"_class": "paragraphStyle", "alignment": 0}, + }}) + }) + .collect(); + let mut l = layer("text", id, id, x, y, w, h); + l["attributedString"] = json!({"_class": "attributedString", "string": string, "attributes": attrs}); + l["textBehaviour"] = json!(1); + l +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 8ab154d9..8859c4a9 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -21,6 +21,9 @@ mod style; mod style_tests; #[cfg(test)] mod tests; +mod text; +#[cfg(test)] +mod text_tests; mod tree; #[cfg(test)] mod tree_tests; @@ -81,6 +84,9 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result } let mut warnings = archive.warnings.clone(); warnings.extend(map.warnings); + if let Some(note) = embedded_fonts(&archive.document) { + warnings.push(note); + } let images = |r: &str| archive.image(r); let (mut doc, more) = crate::rest::import_with_networks_plain(&map.json, &images, map.networks, max_nodes)?; if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) { @@ -90,6 +96,19 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result Ok((doc, warnings)) } +/// The note about fonts the file carries (Sketch 7x and newer can embed them): FigCraft doesn't +/// use fonts from inside documents, so their text shows in installed fonts. +fn embedded_fonts(document: &Value) -> Option { + let refs = document.get("fontReferences").and_then(Value::as_array)?; + let families: BTreeSet = + refs.iter().filter_map(|r| r.get("fontFamilyName").and_then(Value::as_str)).map(|f| f.chars().take(64).collect()).take(20).collect(); + if families.is_empty() { + return None; + } + let list: Vec<&str> = families.iter().map(String::as_str).collect(); + Some(format!("the file has embedded fonts FigCraft doesn't use ({}); install them to see the text as designed", list.join(", "))) +} + fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!("Sketch file: {}", msg.into())) } diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs new file mode 100644 index 00000000..edafe30e --- /dev/null +++ b/crates/format/src/sketch/text.rs @@ -0,0 +1,165 @@ +//! Sketch text layers → REST text nodes. +//! +//! A text layer's `attributedString` holds the `string` and `attributes`: runs given by `location` +//! and `length` in UTF-16 code units (like the REST importer's per-character style ids), each with +//! a font (PostScript name and size), colour, kerning, paragraph style, decorations and text +//! transform. `textBehaviour` sizes the box (0 auto width, 1 fixed width, 2 fixed); the style's +//! `textStyle` gives the vertical alignment. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, color}; + +/// Most characters read from one text layer (the REST importer's own limit). +const MAX_CHARS: usize = 1 << 20; +/// Most runs read from one text layer. +const MAX_RUNS: usize = 10_000; + +/// Fill `node` (a TEXT node) from text layer `l`. +pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { + let a = l.get("attributedString"); + let string: String = a.and_then(|a| a.get("string")).and_then(Value::as_str).unwrap_or("").chars().take(MAX_CHARS).collect(); + let units = string.encode_utf16().count(); + let runs: &[Value] = a.and_then(|a| a.get("attributes")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + if runs.len() > MAX_RUNS { + cx.warn(format!("text with more than {MAX_RUNS} styled runs keeps the first ones")); + } + let fallback = l.get("style").and_then(|s| s.get("textStyle")).and_then(|t| t.get("encodedAttributes")); + let styles: Vec<(usize, usize, Value)> = runs + .iter() + .take(MAX_RUNS) + .filter_map(|r| { + let start = usize::try_from(r.get("location")?.as_u64()?).ok()?.min(units); + let len = usize::try_from(r.get("length")?.as_u64()?).unwrap_or(usize::MAX); + Some((start, start.saturating_add(len).min(units), type_style(cx, r.get("attributes")?))) + }) + .collect(); + let base = styles.first().map(|s| s.2.clone()).or_else(|| fallback.map(|f| type_style(cx, f))).unwrap_or_else(|| type_style(cx, &json!({}))); + let mut style = base.clone(); + let mut fills = None; + if let Some(m) = style.as_object_mut() { + fills = m.remove("fills"); + // Paragraph settings are the first run's. + let paragraph = runs.first().and_then(|r| r.get("attributes")).or(fallback).and_then(|a| a.get("paragraphStyle")); + let align = match paragraph.and_then(|p| p.get("alignment")).and_then(Value::as_i64) { + Some(1) => "RIGHT", + Some(2) => "CENTER", + Some(3) => "JUSTIFIED", + _ => "LEFT", + }; + m.insert("textAlignHorizontal".into(), json!(align)); + if let Some(sp) = paragraph.and_then(|p| p.get("paragraphSpacing")).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) { + m.insert("paragraphSpacing".into(), json!(sp.min(1e4))); + } + let vertical = l.get("style").and_then(|s| s.get("textStyle")).and_then(|t| t.get("verticalAlignment")).and_then(Value::as_i64); + m.insert( + "textAlignVertical".into(), + json!(match vertical { + Some(1) => "CENTER", + Some(2) => "BOTTOM", + _ => "TOP", + }), + ); + m.insert( + "textAutoResize".into(), + json!(match l.get("textBehaviour").and_then(Value::as_i64) { + Some(1) => "HEIGHT", + Some(2) => "NONE", + _ => "WIDTH_AND_HEIGHT", + }), + ); + } + node.insert("characters".into(), json!(string)); + node.insert("style".into(), style); + if let Some(f) = fills { + node.insert("fills".into(), f); + } + // Runs that differ from the first: a style id per UTF-16 unit. + let mut ids = vec![0u64; units]; + let mut table = JsonMap::new(); + for (start, end, s) in &styles { + if *s == base { + continue; + } + let id = table.len() as u64 + 1; + table.insert(id.to_string(), s.clone()); + for slot in ids.get_mut(*start..*end).into_iter().flatten() { + *slot = id; + } + } + if !table.is_empty() { + node.insert("characterStyleOverrides".into(), json!(ids)); + node.insert("styleOverrideTable".into(), Value::Object(table)); + } +} + +/// A run's attributes as a REST type style (with its colour as `fills`). +fn type_style(cx: &mut Cx, a: &Value) -> Value { + let font = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")); + let ps = font.and_then(|f| f.get("name")).and_then(Value::as_str).unwrap_or(""); + let size = font.and_then(|f| f.get("size")).and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(12.0); + let (family, style) = font_name(ps); + if ps.is_empty() { + cx.warn("text without a font uses Helvetica"); + } + let mut s = json!({"fontFamily": family, "fontStyle": style, "fontSize": size.min(10_000.0)}); + let p = a.get("paragraphStyle"); + let line = |k: &str| p.and_then(|p| p.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + if let Some(lh) = line("maximumLineHeight").or_else(|| line("minimumLineHeight")) { + s["lineHeightPx"] = json!(lh.min(1e4)); + s["lineHeightUnit"] = json!("PIXELS"); + } + if let Some(k) = a.get("kerning").and_then(Value::as_f64).filter(|k| k.is_finite()) { + s["letterSpacing"] = json!(k); + } + let set = |k: &str| a.get(k).and_then(Value::as_i64).is_some_and(|v| v != 0); + s["textDecoration"] = json!(if set("underlineStyle") { + "UNDERLINE" + } else if set("strikethroughStyle") { + "STRIKETHROUGH" + } else { + "NONE" + }); + s["textCase"] = json!(match a.get("MSAttributedStringTextTransformAttribute").and_then(Value::as_i64) { + Some(1) => "UPPER", + Some(2) => "LOWER", + _ => "ORIGINAL", + }); + let c = a.get("MSAttributedStringColorAttribute").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0})); + s["fills"] = json!([{"type": "SOLID", "color": c}]); + s +} + +/// Family and style of a font from its PostScript name: "HelveticaNeue-Bold" → ("Helvetica Neue", +/// "Bold"). Words of the family are split at case changes, which can't always match the real name +/// ("PingFangSC" → "Ping Fang SC"); the engine matches families against the installed fonts ignoring +/// spaces. Apple's system fonts take the SF Pro names. +pub(crate) fn font_name(ps: &str) -> (String, String) { + let (family, style) = ps.split_once('-').unwrap_or((ps, "")); + let style = if style.is_empty() { "Regular".to_string() } else { crate::fig::style_name(style) }; + let family = match family { + "" => "Helvetica".to_string(), + ".SFNSText" | ".SFUIText" | "SanFranciscoText" | ".SFProText" => "SF Pro Text".into(), + ".SFNSDisplay" | ".SFUIDisplay" | "SanFranciscoDisplay" | ".SFProDisplay" => "SF Pro Display".into(), + f => words(f.trim_start_matches('.')), + }; + (family, style) +} + +/// "IBMPlexSans" → "IBM Plex Sans": a space before a capital that follows a small letter, and +/// before the last capital of a run of capitals followed by a small letter. +fn words(s: &str) -> String { + let chars: Vec = s.chars().collect(); + let mut out = String::with_capacity(s.len() + 4); + for (i, c) in chars.iter().enumerate() { + let prev = i.checked_sub(1).and_then(|p| chars.get(p)); + let next = chars.get(i + 1); + let split = c.is_uppercase() + && (prev.is_some_and(|p| p.is_lowercase()) || (prev.is_some_and(|p| p.is_uppercase()) && next.is_some_and(|n| n.is_lowercase()))); + if split { + out.push(' '); + } + out.push(*c); + } + out +} diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs new file mode 100644 index 00000000..bde9dcef --- /dev/null +++ b/crates/format/src/sketch/text_tests.rs @@ -0,0 +1,188 @@ +//! Sketch text layers → text with styled runs; font PostScript names → family and style. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, LetterSpacing, LineHeight, NodeKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; +use super::text::font_name; + +const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0]; +const RED: [f64; 4] = [1.0, 0.0, 0.0, 1.0]; + +fn open(layers: Vec) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]); + import(&z, "f").unwrap() +} + +fn data<'d>(doc: &'d Document, name: &str) -> &'d TextData { + match &doc.nodes().find(|n| n.name == name).unwrap().kind { + NodeKind::Text(td) => td, + other => panic!("{name}: {other:?}"), + } +} + +fn red(fills: &Option>) -> bool { + fills.as_ref().is_some_and(|f| f.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0))) +} + +#[test] +fn two_runs() { + let t = text("Hi", 0.0, 0.0, 200.0, 20.0, "Hello world", &[(0, 5, "HelveticaNeue-Bold", 16.0, RED), (5, 6, "HelveticaNeue", 12.0, BLACK)]); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Hi"); + assert_eq!(td.characters, "Hello world"); + let style_at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style); + let (a, b) = (style_at(0), style_at(7)); + assert_eq!((a.font_family.as_str(), a.font_style.as_str(), a.font_size), ("Helvetica Neue", "Bold", 16.0)); + assert_eq!((b.font_family.as_str(), b.font_style.as_str(), b.font_size), ("Helvetica Neue", "Regular", 12.0)); + let node = doc.nodes().find(|n| n.name == "Hi").unwrap(); + assert!(red(&a.fills) || (red(&Some(node.fills.clone())) && !red(&b.fills))); +} + +#[test] +fn utf16_ranges() { + // "a😀b": the emoji takes two UTF-16 units. + let t = text( + "Emoji", + 0.0, + 0.0, + 200.0, + 20.0, + "a😀b", + &[(0, 1, "Helvetica", 10.0, BLACK), (1, 2, "Helvetica", 20.0, BLACK), (3, 1, "Helvetica", 30.0, BLACK)], + ); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Emoji"); + let size_at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(td.style.font_size, |r| r.style.font_size); + assert_eq!((size_at(0), size_at(1), size_at(2)), (10.0, 20.0, 30.0)); + // A range splitting the emoji and one past the end: no panic, the text stays whole. + let t = text( + "Split", + 0.0, + 50.0, + 200.0, + 20.0, + "a😀b", + &[(0, 2, "Helvetica", 10.0, BLACK), (2, 99, "Helvetica", 20.0, BLACK), (500, 3, "Helvetica", 5.0, BLACK)], + ); + let (doc, _) = open(vec![t]); + assert_eq!(data(&doc, "Split").characters, "a😀b"); +} + +#[test] +fn paragraph_and_spacing() { + let mut t = text("Para", 0.0, 0.0, 200.0, 40.0, "Centered", &[(0, 8, "Helvetica", 14.0, BLACK)]); + let a = &mut t["attributedString"]["attributes"][0]["attributes"]; + a["paragraphStyle"] = + json!({"_class": "paragraphStyle", "alignment": 2, "maximumLineHeight": 24, "minimumLineHeight": 24, "paragraphSpacing": 8}); + a["kerning"] = json!(0.5); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Para"); + assert_eq!(td.align_h, TextAlignH::Center); + assert_eq!(td.style.line_height, LineHeight::Pixels(24.0)); + assert_eq!(td.paragraph_spacing, 8.0); + assert_eq!(td.style.letter_spacing, LetterSpacing::Pixels(0.5)); + for (code, want) in [(0, TextAlignH::Left), (1, TextAlignH::Right), (3, TextAlignH::Justified), (4, TextAlignH::Left)] { + let mut t = text("A", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]); + t["attributedString"]["attributes"][0]["attributes"]["paragraphStyle"]["alignment"] = json!(code); + let (doc, _) = open(vec![t]); + assert_eq!(data(&doc, "A").align_h, want, "alignment {code}"); + } +} + +#[test] +fn behaviour_and_vertical_alignment() { + let cases = [(0, TextAutoResize::WidthAndHeight), (1, TextAutoResize::Height), (2, TextAutoResize::None)]; + for (b, want) in cases { + let mut t = text("T", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]); + t["textBehaviour"] = json!(b); + let (doc, _) = open(vec![t]); + assert_eq!(data(&doc, "T").auto_resize, want, "behaviour {b}"); + } + for (v, want) in [(0, TextAlignV::Top), (1, TextAlignV::Middle), (2, TextAlignV::Bottom)] { + let mut t = text("V", 0.0, 0.0, 100.0, 60.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]); + t["textBehaviour"] = json!(2); + t["style"]["textStyle"] = json!({"_class": "textStyle", "verticalAlignment": v, "encodedAttributes": {}}); + let (doc, _) = open(vec![t]); + assert_eq!(data(&doc, "V").align_v, want, "vertical {v}"); + } +} + +#[test] +fn decorations_and_case() { + let mut t = text( + "Deco", + 0.0, + 0.0, + 200.0, + 20.0, + "abcdef", + &[(0, 2, "Helvetica", 14.0, BLACK), (2, 2, "Helvetica", 14.0, BLACK), (4, 2, "Helvetica", 14.0, BLACK)], + ); + t["attributedString"]["attributes"][0]["attributes"]["underlineStyle"] = json!(1); + t["attributedString"]["attributes"][1]["attributes"]["strikethroughStyle"] = json!(1); + t["attributedString"]["attributes"][2]["attributes"]["MSAttributedStringTextTransformAttribute"] = json!(1); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Deco"); + let at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style); + assert_eq!(at(0).decoration, TextDecoration::Underline); + assert_eq!(at(2).decoration, TextDecoration::Strikethrough); + assert_eq!(at(4).case, TextCase::Upper); +} + +#[test] +fn text_layer_fill_wins_over_run_colour() { + let t = styled(text("Filled", 0.0, 0.0, 100.0, 20.0, "x", &[(0, 1, "Helvetica", 14.0, BLACK)]), "fills", json!([solid_fill(1.0, 0.0, 0.0, 1.0)])); + let (doc, _) = open(vec![t]); + let n = doc.nodes().find(|n| n.name == "Filled").unwrap(); + assert!(red(&Some(n.fills.clone()))); +} + +#[test] +fn font_names() { + let cases = [ + ("HelveticaNeue-Bold", "Helvetica Neue", "Bold"), + ("HelveticaNeue", "Helvetica Neue", "Regular"), + ("HelveticaNeue-Medium", "Helvetica Neue", "Medium"), + ("PingFangSC-Semibold", "Ping Fang SC", "Semi Bold"), + (".SFNSText", "SF Pro Text", "Regular"), + (".SFNSDisplay-Bold", "SF Pro Display", "Bold"), + ("SanFranciscoText-Semibold", "SF Pro Text", "Semi Bold"), + ("Impact", "Impact", "Regular"), + ("IBMPlexSans-MediumItalic", "IBM Plex Sans", "Medium Italic"), + ("Roboto-Italic", "Roboto", "Italic"), + ("", "Helvetica", "Regular"), + ]; + for (ps, family, style) in cases { + assert_eq!(font_name(ps), (family.to_string(), style.to_string()), "{ps}"); + } +} + +#[test] +fn long_and_odd_text_is_safe() { + let long = "x".repeat(10_000); + let t = text("Long", 0.0, 0.0, 100.0, 20.0, &long, &[(0, usize::MAX >> 1, "Helvetica", 14.0, BLACK)]); + let mut odd = text("Odd", 0.0, 0.0, 100.0, 20.0, "abc", &[(0, 3, "Helvetica", -5.0, BLACK)]); + odd["attributedString"]["attributes"][0]["attributes"]["MSAttributedStringFontAttribute"] = json!("junk"); + let mut empty = text("Empty", 0.0, 0.0, 100.0, 20.0, "", &[]); + empty["attributedString"]["string"] = json!(5); + let (doc, _) = open(vec![t, odd, empty]); + assert_eq!(data(&doc, "Long").characters.len(), 10_000); + assert!(data(&doc, "Odd").style.font_size > 0.0); +} + +#[test] +fn embedded_fonts_are_reported() { + let mut d = document(&["P"]); + d["fontReferences"] = json!([ + {"_class": "fontReference", "fontFamilyName": "IBM Plex Sans", "fontFileName": "IBMPlexSans-Regular.otf", "fontData": {"_ref": "fonts/abc"}}, + {"_class": "fontReference", "fontFamilyName": "IBM Plex Sans", "fontFileName": "IBMPlexSans-Bold.otf", "fontData": {"_ref": "fonts/def"}}, + {"_class": "fontReference", "fontFamilyName": "IBM Plex Mono", "fontData": {"_ref": "fonts/ghi"}}, + ]); + let z = sketch_zip("83.2", 139, d, &[page("P", "Page", vec![])], &[("fonts/abc", b"x")]); + let (_, warnings) = import(&z, "f").unwrap(); + let w = warnings.iter().find(|w| w.contains("embedded")).expect("a note about embedded fonts"); + assert!(w.contains("IBM Plex Mono") && w.contains("IBM Plex Sans"), "{w}"); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index c59b8c90..16382dbe 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -80,6 +80,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: "oval" => "ELLIPSE", "slice" => "SLICE", "shapePath" | "star" | "polygon" | "triangle" => "VECTOR", + "text" => "TEXT", other => { cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other))); return None; @@ -91,6 +92,9 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: let (w, h) = size(l); let world = parent * local(l, w, h); let mut node = base(l, ty, parent, world, w, h, root); + if ty == "TEXT" { + super::text::node(cx, l, &mut node); + } if ty == "VECTOR" { let shape = super::path::shape(cx, l, w, h)?; super::path::put_shape(cx, shape, &mut node); From 60cc184ab7ebb3b0a23e83c3ea6f2b0d816cddf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:28:16 +0200 Subject: [PATCH 040/208] M6: Sketch import: PingFang families keep their own names Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/text.rs | 6 ++++-- crates/format/src/sketch/text_tests.rs | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index edafe30e..74e7a731 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -132,8 +132,8 @@ fn type_style(cx: &mut Cx, a: &Value) -> Value { /// Family and style of a font from its PostScript name: "HelveticaNeue-Bold" → ("Helvetica Neue", /// "Bold"). Words of the family are split at case changes, which can't always match the real name -/// ("PingFangSC" → "Ping Fang SC"); the engine matches families against the installed fonts ignoring -/// spaces. Apple's system fonts take the SF Pro names. +/// (a CJK family would split as "Ping Fang SC"); the engine matches families against the installed +/// fonts ignoring spaces. Apple's system fonts take the SF Pro names, PingFang its own. pub(crate) fn font_name(ps: &str) -> (String, String) { let (family, style) = ps.split_once('-').unwrap_or((ps, "")); let style = if style.is_empty() { "Regular".to_string() } else { crate::fig::style_name(style) }; @@ -141,6 +141,8 @@ pub(crate) fn font_name(ps: &str) -> (String, String) { "" => "Helvetica".to_string(), ".SFNSText" | ".SFUIText" | "SanFranciscoText" | ".SFProText" => "SF Pro Text".into(), ".SFNSDisplay" | ".SFUIDisplay" | "SanFranciscoDisplay" | ".SFProDisplay" => "SF Pro Display".into(), + // Apple's CJK families whose names don't split at case changes. + "PingFangSC" | "PingFangTC" | "PingFangHK" => format!("PingFang {}", family.get(8..).unwrap_or("")), f => words(f.trim_start_matches('.')), }; (family, style) diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index bde9dcef..a7ee96b0 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -146,7 +146,8 @@ fn font_names() { ("HelveticaNeue-Bold", "Helvetica Neue", "Bold"), ("HelveticaNeue", "Helvetica Neue", "Regular"), ("HelveticaNeue-Medium", "Helvetica Neue", "Medium"), - ("PingFangSC-Semibold", "Ping Fang SC", "Semi Bold"), + ("PingFangSC-Semibold", "PingFang SC", "Semi Bold"), + ("PingFangTC-Regular", "PingFang TC", "Regular"), (".SFNSText", "SF Pro Text", "Regular"), (".SFNSDisplay-Bold", "SF Pro Display", "Bold"), ("SanFranciscoText-Semibold", "SF Pro Text", "Semi Bold"), From 4437d49a7250ddbb312adf435084bcc36c9053e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:36:10 +0200 Subject: [PATCH 041/208] M6: Sketch import: symbols as components, instances with overrides Symbol masters become components, also those copied from libraries (on a page of their own); instances carry their master's layers with their overrides applied (text, nested symbol swaps and hiding, images, layer and text styles, nested paths) and resize them by their constraints, so editing a master still updates every instance. A symbol inside itself stops with a note; instances of missing symbols are empty frames. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 + crates/engine/src/sketch_import_tests.rs | 30 +++ crates/format/Cargo.toml | 1 + crates/format/src/sketch/fixture.rs | 18 ++ crates/format/src/sketch/mod.rs | 37 ++- crates/format/src/sketch/symbols.rs | 298 ++++++++++++++++++++++ crates/format/src/sketch/symbols_tests.rs | 165 ++++++++++++ crates/format/src/sketch/tree.rs | 33 ++- 8 files changed, 579 insertions(+), 5 deletions(-) create mode 100644 crates/format/src/sketch/symbols.rs create mode 100644 crates/format/src/sketch/symbols_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 459c8320..0d118fda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1579,6 +1579,7 @@ dependencies = [ "thiserror 2.0.21", "wat", "web-time", + "zip", ] [[package]] @@ -1586,6 +1587,7 @@ name = "figcraft-format" version = "0.9.0" dependencies = [ "figcraft-doc", + "figcraft-layout", "figcraft-pathops", "flate2", "image", diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index ac35cb06..10a7b9ca 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -6,6 +6,8 @@ use std::io::Write; use base64::Engine; use serde_json::{Value, json}; +use figcraft_doc::NodeKind; + use crate::Session; /// A minimal `.sketch` zip with one page per `(id, name)`, saved by `app_version`. @@ -145,3 +147,31 @@ fn sketch_font_families_match_installed_ones() { assert_eq!(m("IBMPlex Sans").as_deref(), Some("IBM Plex Sans")); assert_eq!(m("Comic Sans"), None); } + +/// Editing a master changes its instances, and a resized instance's layout isn't an override. +#[test] +fn sketch_instances_stay_live() { + let tag = shape("rectangle", "Tag", [80.0, 0.0, 20.0, 40.0], json!({"resizingConstraint": 60, "points": []})); + let master = shape("symbolMaster", "Bar", [0.0, 0.0, 100.0, 40.0], json!({"symbolID": "S", "layers": [tag]})); + let inst = shape("symbolInstance", "Wide", [0.0, 100.0, 200.0, 40.0], json!({"symbolID": "S", "overrideValues": []})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("50.2", &[("A", "Page")], &[master, inst])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let find = |s: &Session, inst: bool| { + let doc = s.doc().unwrap(); + let root = doc + .nodes() + .find(|n| if inst { matches!(n.kind, NodeKind::Instance { .. }) } else { matches!(n.kind, NodeKind::Component { .. }) }) + .unwrap() + .id; + doc.subtree(root).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Tag")).unwrap() + }; + let (main_tag, inst_tag) = (find(&s, false), find(&s, true)); + assert!(s.doc().unwrap().node(inst_tag).unwrap().overrides.is_empty(), "{:?}", s.doc().unwrap().node(inst_tag).unwrap().overrides); + s.execute("node.set", &json!({"id": main_tag, "props": {"fill": "#ff0000"}})).unwrap(); + let doc = s.doc().unwrap(); + let fill = &doc.node(inst_tag).unwrap().fills; + assert!(fill.iter().any(|p| matches!(p.kind, figcraft_doc::color::PaintKind::Solid { color } if color.r > 0.99 && color.g < 0.01)), "{fill:?}"); + let b = doc.world_bounds(inst_tag).unwrap(); + assert!((b.x0 - 180.0).abs() < 1e-6, "{b:?}"); +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 3dca3912..80a943e2 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] figcraft-doc = { workspace = true } figcraft-pathops = { workspace = true } +figcraft-layout = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } zip = { workspace = true } diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index a7c5ea73..ac426f0e 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -168,3 +168,21 @@ pub(crate) fn text(id: &str, x: f64, y: f64, w: f64, h: f64, string: &str, runs: l["textBehaviour"] = json!(1); l } + +/// A symbol master `symbol_id` holding `layers`. +pub(crate) fn master(id: &str, symbol_id: &str, name: &str, w: f64, h: f64, layers: Vec) -> Value { + let mut l = artboard(id, name, 0.0, 0.0, w, h, layers); + l["_class"] = json!("symbolMaster"); + l["symbolID"] = json!(symbol_id); + l +} + +/// An instance of symbol `symbol_id` with `overrides` (`overrideName`, value). +pub(crate) fn instance(id: &str, symbol_id: &str, x: f64, y: f64, w: f64, h: f64, overrides: &[(&str, Value)]) -> Value { + let mut l = layer("symbolInstance", id, id, x, y, w, h); + l["symbolID"] = json!(symbol_id); + l["scale"] = json!(1); + l["overrideValues"] = + Value::Array(overrides.iter().map(|(name, value)| json!({"_class": "overrideValue", "overrideName": name, "value": value})).collect()); + l +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 8859c4a9..b9063a70 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -19,6 +19,9 @@ mod path_tests; mod style; #[cfg(test)] mod style_tests; +mod symbols; +#[cfg(test)] +mod symbols_tests; #[cfg(test)] mod tests; mod text; @@ -68,16 +71,26 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option Result<(Document, Vec), FormatError> { - import_within(bytes, name, MAX_JSON_MEMBER) + import_parts(bytes, name, MAX_JSON_MEMBER, crate::fig::layer_budget(bytes.len())) } /// [`import`] reading pages of at most `max_member` bytes of JSON each. +#[cfg(test)] pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result<(Document, Vec), FormatError> { + import_parts(bytes, name, max_member, crate::fig::layer_budget(bytes.len())) +} + +/// [`import`] with at most `max_nodes` layers. +#[cfg(test)] +pub(crate) fn import_within_nodes(bytes: &[u8], name: &str, max_nodes: usize) -> Result<(Document, Vec), FormatError> { + import_parts(bytes, name, MAX_JSON_MEMBER, max_nodes) +} + +fn import_parts(bytes: &[u8], name: &str, max_member: u64, max_nodes: usize) -> Result<(Document, Vec), FormatError> { if bytes.starts_with(b"SQLite format 3\0") { return Err(FormatError::Io(TOO_OLD.into())); } let archive = Archive::open(bytes, max_member)?; - let max_nodes = crate::fig::layer_budget(bytes.len()); let map = tree::to_rest(&archive, name, max_nodes); if map.too_old { return Err(FormatError::Io(TOO_OLD.into())); @@ -92,6 +105,7 @@ pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) { doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3; } + fit_instances(&mut doc); warnings.extend(more); Ok((doc, warnings)) } @@ -109,6 +123,25 @@ fn embedded_fonts(document: &Value) -> Option { Some(format!("the file has embedded fonts FigCraft doesn't use ({}); install them to see the text as designed", list.join(", "))) } +/// Instances come in with their master's layers laid out at the master's size; resized ones lay +/// them out for their own size by their constraints, innermost first. +fn fit_instances(doc: &mut Document) { + let mut insts: Vec<(usize, figcraft_doc::NodeId, f64, f64)> = doc + .nodes() + .filter_map(|n| match n.kind { + figcraft_doc::NodeKind::Instance { component, .. } => { + let c = doc.node(component)?; + Some((doc.ancestors(n.id).len(), n.id, c.width, c.height)) + } + _ => None, + }) + .collect(); + insts.sort_by_key(|i| std::cmp::Reverse(i.0)); + for (_, id, w, h) in insts { + figcraft_layout::apply_constraints(doc, id, w, h); + } +} + fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!("Sketch file: {}", msg.into())) } diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs new file mode 100644 index 00000000..d6e7165c --- /dev/null +++ b/crates/format/src/sketch/symbols.rs @@ -0,0 +1,298 @@ +//! Sketch symbols → REST components and instances. +//! +//! A `symbolMaster` (on any page, or copied from a library into `document.foreignSymbols`) is a +//! component; a `symbolInstance` names it by `symbolID`. REST instances carry their layers, so an +//! instance is its master's layers with the instance's `overrideValues` applied, laid out at the +//! master's size (the import then resizes them to the instance by their constraints). An override +//! is named `_`: `path` is the `do_objectID` of a layer of the master, or of +//! nested instances and then a layer of theirs, joined by `/`; properties: `stringValue` (text), +//! `symbolID` (swap a nested instance; empty hides it), `image`, `layerStyle` and `textStyle` +//! (shared style ids). + +use std::collections::BTreeMap; + +use figcraft_doc::geom::Affine; +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, base, bool_field, children, local, size, str_field}; + +/// Most masters read from one file. +const MAX_MASTERS: usize = 100_000; + +/// Every symbol master of a file by `symbolID`, and the library ones no page holds. +#[derive(Default)] +pub(crate) struct Symbols { + masters: BTreeMap, + /// Masters copied from libraries that no page holds (they get a page of their own), with the + /// libraries' names. + pub foreign: Vec, + pub libraries: Vec, +} + +impl Symbols { + /// The masters of `pages` and of the document's library copies. + pub(crate) fn read(document: &Value, pages: &[(String, Value)]) -> Symbols { + let mut s = Symbols::default(); + let mut found = Vec::new(); + for (_, p) in pages { + masters_in(p, &mut found, 0); + } + for m in found { + s.add(m.clone()); + } + for f in document.get("foreignSymbols").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() { + let Some(m) = f.get("symbolMaster") else { continue }; + let Some(id) = str_field(m, "symbolID") else { continue }; + if s.masters.contains_key(id) { + continue; + } + if let Some(lib) = str_field(f, "sourceLibraryName") + && !s.libraries.iter().any(|l| l == lib) + { + s.libraries.push(lib.chars().take(64).collect()); + } + s.add(m.clone()); + s.foreign.push(m.clone()); + } + s + } + + fn add(&mut self, m: Value) { + if self.masters.len() >= MAX_MASTERS { + return; + } + if let Some(id) = str_field(&m, "symbolID") { + self.masters.entry(id.to_string()).or_insert(m); + } + } + + fn master(&self, symbol_id: &str) -> Option<&Value> { + self.masters.get(symbol_id) + } +} + +fn masters_in<'v>(l: &'v Value, out: &mut Vec<&'v Value>, depth: usize) { + if depth > 64 { + return; + } + for c in super::tree::layers(l) { + if str_field(c, "_class") == Some("symbolMaster") { + out.push(c); + } else { + masters_in(c, out, depth + 1); + } + } +} + +/// A master's background as REST fills (masters show one only when they have it on). +pub(crate) fn master_fills(m: &Value) -> Option { + if bool_field(m, "hasBackgroundColor") != Some(true) { + return None; + } + let c = m.get("backgroundColor").and_then(super::tree::color)?; + Some(json!([{"type": "SOLID", "color": c}])) +} + +/// A symbol master as a COMPONENT node. +pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let mut node = base(l, "COMPONENT", parent, world, w, h, root); + node.insert("clipsContent".into(), json!(true)); + if let Some(f) = master_fills(l) { + node.insert("fills".into(), f); + } + let sid = str_field(l, "symbolID").unwrap_or("").to_string(); + cx.chain.push(sid); + let kids = children(cx, l, world, (w, h), depth, false); + cx.chain.pop(); + node.insert("children".into(), Value::Array(kids)); + Some(Value::Object(node)) +} + +/// A symbol instance as an INSTANCE node holding its master's layers with its overrides, or an +/// empty frame when its master isn't in the file. +pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { + let (w, h) = size(l); + let world = parent * local(l, w, h); + let sid = str_field(l, "symbolID").unwrap_or(""); + let Some(m) = cx.symbols.master(sid).cloned() else { + cx.warn("instances of symbols that aren't in the file were imported as empty frames"); + let mut node = base(l, "FRAME", parent, world, w, h, root); + node.insert("children".into(), json!([])); + return Some(Value::Object(node)); + }; + if cx.chain.iter().any(|c| c == sid) { + let name: String = str_field(&m, "name").unwrap_or("").chars().take(64).collect(); + cx.warn(format!("symbol {name} contains itself; the inner copy was left out")); + return None; + } + if l.get("scale").and_then(Value::as_f64).is_some_and(|s| s.is_finite() && (s - 1.0).abs() > 1e-9) { + cx.warn("scaled symbol instances were imported at their size, unscaled"); + } + let mut node = base(l, "INSTANCE", parent, world, w, h, root); + node.insert("componentId".into(), json!(str_field(&m, "do_objectID").unwrap_or(sid))); + node.insert("clipsContent".into(), json!(true)); + if let Some(f) = master_fills(&m) { + node.insert("fills".into(), f); + } + if let Some(st) = l.get("style") { + super::style::apply(cx, st, w, h, &mut node); + } + let overrides = overrides(l); + let mut copy = m; + if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { + apply(layers, &overrides, cx.document, 0); + } + let (mw, mh) = size(©); + cx.chain.push(sid.to_string()); + let mut kids = children(cx, ©, world, (mw, mh), depth, false); + cx.chain.pop(); + let prefix = str_field(l, "do_objectID").unwrap_or("I").to_string(); + for k in &mut kids { + prefix_ids(k, &prefix, 0); + } + node.insert("children".into(), Value::Array(kids)); + Some(Value::Object(node)) +} + +/// An instance's overrides by name (`_` → value). +fn overrides(l: &Value) -> BTreeMap { + l.get("overrideValues") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .take(100_000) + .filter_map(|o| Some((str_field(o, "overrideName")?.to_string(), o.get("value")?.clone()))) + .collect() +} + +/// Apply `ov` to the layers of a master's copy: to the layers it names, and passed on to nested +/// instances for the layers inside them (an outer override wins over the nested instance's own). +fn apply(layers: &mut [Value], ov: &BTreeMap, document: &Value, depth: usize) { + if depth > 64 || ov.is_empty() { + return; + } + for l in layers { + let Some(id) = str_field(l, "do_objectID").map(str::to_string) else { continue }; + let get = |prop: &str| ov.get(&format!("{id}_{prop}")); + match str_field(l, "_class") { + Some("symbolInstance") => { + match get("symbolID").map(|v| v.as_str().unwrap_or("")) { + Some("") => l["isVisible"] = json!(false), + Some(other) => l["symbolID"] = json!(other), + None => {} + } + let prefix = format!("{id}/"); + let inner: Vec = ov + .iter() + .filter_map(|(k, v)| k.strip_prefix(&prefix).map(|rest| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))) + .collect(); + if !inner.is_empty() { + let mut own = l.get("overrideValues").and_then(Value::as_array).cloned().unwrap_or_default(); + own.extend(inner); + l["overrideValues"] = Value::Array(own); + } + continue; + } + Some("text") => { + if let Some(s) = get("stringValue").and_then(Value::as_str) { + set_string(l, s); + } + } + Some("bitmap") => { + if let Some(img) = get("image").filter(|v| v.get("_ref").is_some()) { + l["image"] = img.clone(); + } + } + _ => {} + } + for (prop, list) in [("layerStyle", "layerStyles"), ("textStyle", "layerTextStyles")] { + if let Some(style) = get(prop).and_then(Value::as_str).and_then(|sid| shared_style(document, list, sid)) { + l["style"] = style.clone(); + if prop == "textStyle" + && let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) + && let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) + { + for r in runs { + r["attributes"] = enc.clone(); + } + } + } + } + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + apply(kids, ov, document, depth + 1); + } + } +} + +/// A text layer's string replaced, in the style of its first run. +fn set_string(l: &mut Value, s: &str) { + let units = s.encode_utf16().count(); + let first = l.get("attributedString").and_then(|a| a.get("attributes")).and_then(Value::as_array).and_then(|a| a.first()).cloned(); + let Some(a) = l.get_mut("attributedString").and_then(Value::as_object_mut) else { return }; + a.insert("string".into(), json!(s)); + let runs = match first { + Some(mut r) => { + r["location"] = json!(0); + r["length"] = json!(units); + vec![r] + } + None => Vec::new(), + }; + a.insert("attributes".into(), Value::Array(runs)); +} + +/// The style of shared style `id` from the document's `list` (local) or its library copies. +pub(crate) fn shared_style<'v>(document: &'v Value, list: &str, id: &str) -> Option<&'v Value> { + let local = document.get(list).and_then(|c| c.get("objects")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let foreign_key = if list == "layerStyles" { "foreignLayerStyles" } else { "foreignTextStyles" }; + let foreign = document.get(foreign_key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + local + .iter() + .find(|s| str_field(s, "do_objectID") == Some(id)) + .or_else(|| foreign.iter().filter_map(|f| f.get("localSharedStyle")).find(|s| str_field(s, "do_objectID") == Some(id))) + .and_then(|s| s.get("value")) +} + +/// Make the ids of an instance's layers its own (`;`): the master's layers keep +/// theirs. +fn prefix_ids(v: &mut Value, prefix: &str, depth: usize) { + if depth > 256 { + return; + } + let Some(m) = v.as_object_mut() else { return }; + if let Some(id) = m.get("id").and_then(Value::as_str).map(str::to_string) { + m.insert("id".into(), json!(format!("{prefix};{id}"))); + } + if let Some(kids) = m.get_mut("children").and_then(Value::as_array_mut) { + for k in kids { + prefix_ids(k, prefix, depth + 1); + } + } +} + +/// Library masters no page holds: a page of their own, side by side. +pub(crate) fn library_page(cx: &mut Cx, masters: &[Value]) -> Value { + let mut x = 0.0; + let mut kids = Vec::new(); + for m in masters { + let mut placed = m.clone(); + let (w, _) = size(m); + if let Some(f) = placed.get_mut("frame").and_then(Value::as_object_mut) { + f.insert("x".into(), json!(x)); + f.insert("y".into(), json!(0.0)); + } + x += w + 100.0; + if let Some(node) = component(cx, &placed, Affine::IDENTITY, 0, true) { + kids.push(node); + } + } + let mut page = JsonMap::new(); + page.insert("type".into(), json!("CANVAS")); + page.insert("id".into(), json!("library-symbols")); + page.insert("name".into(), json!("Library symbols")); + page.insert("children".into(), Value::Array(kids)); + Value::Object(page) +} diff --git a/crates/format/src/sketch/symbols_tests.rs b/crates/format/src/sketch/symbols_tests.rs new file mode 100644 index 00000000..e2204399 --- /dev/null +++ b/crates/format/src/sketch/symbols_tests.rs @@ -0,0 +1,165 @@ +//! Sketch symbols → components and instances with overrides. + +use figcraft_doc::{Document, Node, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::{import, import_within_nodes}; + +const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0]; + +/// One page with `layers`, one "Symbols" page with `masters`, and `document` changes. +fn open_doc(doc_json: Value, layers: Vec, masters: Vec) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, doc_json, &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]); + import(&z, "f").unwrap() +} + +fn open(layers: Vec, masters: Vec) -> (Document, Vec) { + let mut d = document(&["P", "S"]); + d["do_objectID"] = json!("DOC"); + open_doc(d, layers, masters) +} + +fn named<'d>(doc: &'d Document, name: &str) -> Vec<&'d Node> { + doc.nodes().filter(|n| n.name == name).collect() +} + +fn inside<'d>(doc: &'d Document, root: &Node, name: &str) -> &'d Node { + doc.subtree(root.id).into_iter().filter_map(|id| doc.node(id)).find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +fn chars(n: &Node) -> &str { + match &n.kind { + NodeKind::Text(td) => &td.characters, + other => panic!("{other:?}"), + } +} + +fn instance_of<'d>(doc: &'d Document, name: &str) -> (&'d Node, &'d Node) { + let inst = named(doc, name).into_iter().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap_or_else(|| panic!("no instance {name}")); + let NodeKind::Instance { component, .. } = inst.kind else { panic!("not an instance") }; + (inst, doc.node(component).unwrap()) +} + +fn button() -> Value { + master("M1", "S1", "Button", 100.0, 40.0, vec![text("Label", 10.0, 10.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, BLACK)])]) +} + +#[test] +fn master_is_component_instance_links() { + let (doc, _) = open(vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[])], vec![button()]); + let (inst, main) = instance_of(&doc, "I1"); + assert!(matches!(main.kind, NodeKind::Component { .. })); + assert_eq!(main.name, "Button"); + assert_eq!(chars(inside(&doc, inst, "Label")), "Go"); +} + +#[test] +fn string_override() { + let (doc, _) = open(vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[("Label_stringValue", json!("OK"))])], vec![button()]); + let (inst, main) = instance_of(&doc, "I1"); + assert_eq!(chars(inside(&doc, inst, "Label")), "OK"); + assert_eq!(chars(inside(&doc, main, "Label")), "Go"); +} + +#[test] +fn nested_symbol_swap_and_hide() { + let icon_a = master("MA", "SA", "Icon A", 20.0, 20.0, vec![rect("Dot A", 0.0, 0.0, 20.0, 20.0)]); + let icon_b = master("MB", "SB", "Icon B", 20.0, 20.0, vec![rect("Dot B", 0.0, 0.0, 20.0, 20.0)]); + let card = master("MC", "SC", "Card", 100.0, 40.0, vec![instance("N", "SA", 0.0, 0.0, 20.0, 20.0, &[])]); + let swapped = instance("Swapped", "SC", 0.0, 0.0, 100.0, 40.0, &[("N_symbolID", json!("SB"))]); + let hidden = instance("Hidden", "SC", 0.0, 100.0, 100.0, 40.0, &[("N_symbolID", json!(""))]); + let (doc, _) = open(vec![swapped, hidden], vec![icon_a, icon_b, card]); + let (sw, _) = instance_of(&doc, "Swapped"); + assert!(doc.subtree(sw.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot B")); + assert!(!doc.subtree(sw.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot A")); + let (hd, _) = instance_of(&doc, "Hidden"); + let nested = doc.node(hd.children[0]).unwrap(); + assert!(!nested.visible); +} + +#[test] +fn nested_path_override() { + let inner = master("MB", "SB", "Badge", 50.0, 20.0, vec![text("Count", 0.0, 0.0, 50.0, 20.0, "1", &[(0, 1, "Helvetica", 12.0, BLACK)])]); + let outer = master("MA", "SA", "Row", 100.0, 20.0, vec![instance("N", "SB", 50.0, 0.0, 50.0, 20.0, &[])]); + let (doc, _) = open(vec![instance("I1", "SA", 0.0, 0.0, 100.0, 20.0, &[("N/Count_stringValue", json!("42"))])], vec![inner, outer]); + let (inst, _) = instance_of(&doc, "I1"); + assert_eq!(chars(inside(&doc, inst, "Count")), "42"); +} + +#[test] +fn image_override() { + let mut bitmap = rect("Photo", 0.0, 0.0, 40.0, 40.0); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"}); + let m = master("M", "S", "Avatar", 40.0, 40.0, vec![bitmap]); + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(1, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let mut png2 = Vec::new(); + image::RgbaImage::new(2, 1).write_to(&mut std::io::Cursor::new(&mut png2), image::ImageFormat::Png).unwrap(); + let inst = instance("I1", "S", 0.0, 0.0, 40.0, 40.0, &[("Photo_image", json!({"_class": "MSJSONFileReference", "_ref": "images/b.png"}))]); + let z = sketch_zip( + "50.2", + 103, + document(&["P", "S"]), + &[page("P", "Page", vec![inst]), page("S", "Symbols", vec![m])], + &[("images/a.png", &png), ("images/b.png", &png2)], + ); + let (doc, _) = import(&z, "f").unwrap(); + assert_eq!(doc.images.len(), 2); +} + +#[test] +fn foreign_symbols_are_components() { + let mut d = document(&["P", "S"]); + d["foreignSymbols"] = json!([{"_class": "MSImmutableForeignSymbol", "sourceLibraryName": "Kit", "symbolMaster": button()}]); + let (doc, warnings) = open_doc(d, vec![instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[])], vec![]); + let (_, main) = instance_of(&doc, "I1"); + assert_eq!(main.name, "Button"); + let page = doc.pages.iter().copied().find(|p| doc.is_ancestor(*p, main.id)).unwrap(); + assert_eq!(doc.node(page).unwrap().name, "Library symbols"); + assert!(warnings.iter().any(|w| w.contains("Kit")), "{warnings:?}"); +} + +#[test] +fn missing_master_warns() { + let (doc, warnings) = open(vec![instance("Lost", "NOPE", 0.0, 0.0, 30.0, 30.0, &[])], vec![]); + let n = named(&doc, "Lost")[0]; + assert!(matches!(n.kind, NodeKind::Frame)); + assert_eq!((n.width, n.height), (30.0, 30.0)); + assert!(warnings.iter().any(|w| w.contains("symbol")), "{warnings:?}"); +} + +#[test] +fn self_containing_master_stops() { + let a = master("MA", "SA", "Loop A", 50.0, 50.0, vec![instance("Self", "SA", 0.0, 0.0, 10.0, 10.0, &[])]); + let b = master("MB", "SB", "Loop B", 50.0, 50.0, vec![instance("ToC", "SC", 0.0, 0.0, 10.0, 10.0, &[])]); + let c = master("MC", "SC", "Loop C", 50.0, 50.0, vec![instance("ToB", "SB", 0.0, 0.0, 10.0, 10.0, &[])]); + let (_, warnings) = open(vec![instance("I1", "SA", 0.0, 0.0, 50.0, 50.0, &[]), instance("I2", "SB", 100.0, 0.0, 50.0, 50.0, &[])], vec![a, b, c]); + assert!(warnings.iter().any(|w| w.contains("contains itself")), "{warnings:?}"); +} + +#[test] +fn instance_explosion_is_bounded() { + let big = master("M", "S", "Big", 100.0, 100.0, (0..1000).map(|i| rect(&format!("R{i}"), 0.0, 0.0, 1.0, 1.0)).collect()); + let many: Vec = (0..1000).map(|i| instance(&format!("I{i}"), "S", 0.0, 0.0, 100.0, 100.0, &[])).collect(); + let z = sketch_zip("50.2", 103, document(&["P", "S"]), &[page("P", "Page", many), page("S", "Symbols", vec![big])], &[]); + let (doc, warnings) = import_within_nodes(&z, "f", 20_000).unwrap(); + assert!(doc.nodes().count() <= 20_100); + assert!(warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); +} + +#[test] +fn resized_instance_follows_constraints() { + // Pinned right with a fixed width (bits 1 and 2 cleared). + let pinned = with(rect("Tag", 80.0, 0.0, 20.0, 40.0), "resizingConstraint", json!(60)); + let m = master("M", "S", "Bar", 100.0, 40.0, vec![pinned]); + let (doc, _) = open(vec![instance("Wide", "S", 0.0, 0.0, 200.0, 40.0, &[])], vec![m]); + let (inst, _) = instance_of(&doc, "Wide"); + let tag = inside(&doc, inst, "Tag"); + let b = doc.world_bounds(tag.id).unwrap(); + assert!((b.x0 - 180.0).abs() < 1e-6 && (b.width() - 20.0).abs() < 1e-6, "{b:?}"); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 16382dbe..11c7a414 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -23,7 +23,12 @@ pub(crate) struct Map { } /// The mapping's state while walking the file. -pub(crate) struct Cx { +pub(crate) struct Cx<'a> { + /// `document.json`: shared styles and library copies. + pub document: &'a Value, + pub symbols: super::symbols::Symbols, + /// The symbols being expanded, outermost first (a symbol inside itself stops there). + pub chain: Vec, pub warnings: Vec, pub networks: Vec>, pub too_old: bool, @@ -32,7 +37,7 @@ pub(crate) struct Cx { pub max_nodes: usize, } -impl Cx { +impl Cx<'_> { pub(crate) fn warn(&mut self, msg: impl Into) { let msg = msg.into(); if !self.warnings.contains(&msg) { @@ -43,7 +48,17 @@ impl Cx { /// Map the pages of `archive` to a REST file JSON named `name`. pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { - let mut cx = Cx { warnings: Vec::new(), networks: Vec::new(), too_old: false, nodes: 0, max_nodes }; + let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); + let mut cx = Cx { + document: &archive.document, + symbols, + chain: Vec::new(), + warnings: Vec::new(), + networks: Vec::new(), + too_old: false, + nodes: 0, + max_nodes, + }; let mut canvases = Vec::new(); for (id, page) in &archive.pages { let mut node = JsonMap::new(); @@ -54,6 +69,12 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { node.insert("children".into(), Value::Array(kids)); canvases.push(Value::Object(node)); } + let foreign = std::mem::take(&mut cx.symbols.foreign); + if !foreign.is_empty() { + canvases.push(super::symbols::library_page(&mut cx, &foreign)); + let libraries = cx.symbols.libraries.join(", "); + cx.warn(format!("symbols from the libraries {libraries} became local components on the page Library symbols")); + } let json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}}); Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old } } @@ -73,6 +94,12 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: if class == "shapeGroup" { return count(cx).then(|| super::path::shape_group(cx, l, parent, depth, root)).flatten(); } + if class == "symbolMaster" { + return count(cx).then(|| super::symbols::component(cx, l, parent, depth, root)).flatten(); + } + if class == "symbolInstance" { + return count(cx).then(|| super::symbols::instance(cx, l, parent, depth, root)).flatten(); + } let ty = match class { "artboard" | "frame" => "FRAME", "group" | "graphic" => "GROUP", From 4be7017ed1d746707d6a8a64aa36e1e68e3304e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:42:22 +0200 Subject: [PATCH 042/208] M6: Sketch import: shared styles and colour variables Shared layer styles become a paint style for their fills, a paint style for their border colours (linked to strokes) and an effect style; shared text styles become text styles (colour stays on the layers); local and library copies alike, unused ones listed too. Layers link to them, keeping their own values where they differ. Color Variables (swatches, also library ones) become colour variables in a Color Variables collection, with fills, borders and text colours bound to them. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/mod.rs | 3 + crates/format/src/sketch/path.rs | 9 +- crates/format/src/sketch/shared.rs | 134 ++++++++++++++++++++ crates/format/src/sketch/shared_tests.rs | 150 +++++++++++++++++++++++ crates/format/src/sketch/style.rs | 25 +++- crates/format/src/sketch/symbols.rs | 5 +- crates/format/src/sketch/text.rs | 8 +- crates/format/src/sketch/tree.rs | 26 +++- 8 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 crates/format/src/sketch/shared.rs create mode 100644 crates/format/src/sketch/shared_tests.rs diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index b9063a70..db0421f8 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -16,6 +16,9 @@ mod fixture; mod path; #[cfg(test)] mod path_tests; +mod shared; +#[cfg(test)] +mod shared_tests; mod style; #[cfg(test)] mod style_tests; diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 6ebb1a75..0cce6e08 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -139,9 +139,10 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, } let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); m.insert("visible".into(), json!(visible)); - if let Some(st) = l.get("style") { + if l.get("style").is_some() { super::style::clear(m); - super::style::apply(cx, st, cw, ch, m); + m.remove("styles"); + super::tree::styles(cx, l, cw, ch, m); } super::tree::mask(l, m); } @@ -168,9 +169,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, node.insert("booleanOperation".into(), json!(op)); node.insert("children".into(), Value::Array(chain)); super::tree::mask(l, &mut node); - if let Some(st) = l.get("style") { - super::style::apply(cx, st, w, h, &mut node); - } + super::tree::styles(cx, l, w, h, &mut node); Some(Value::Object(node)) } diff --git a/crates/format/src/sketch/shared.rs b/crates/format/src/sketch/shared.rs new file mode 100644 index 00000000..104a1931 --- /dev/null +++ b/crates/format/src/sketch/shared.rs @@ -0,0 +1,134 @@ +//! Sketch shared styles and swatches → REST style definitions and colour variables. +//! +//! A Sketch layer style holds fills, borders and effects together; FigCraft styles hold one kind +//! each, so a layer style becomes up to three styles named after it: its fills (a paint style), +//! its border colours (a paint style for strokes; widths stay on the layers) and its effects. A +//! text style becomes a text style; its colour stays on the layers, as FigCraft text styles carry +//! none. Layers link to them by `style.sharedObjectID`. Swatches (Color Variables, Sketch 69+) +//! become colour variables in one collection; colours name theirs by `swatchID`. + +use std::collections::BTreeMap; + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, color, str_field}; + +/// Most shared styles or swatches read from one file. +const MAX_SHARED: usize = 20_000; +/// The collection swatches become variables in. +const COLLECTION: &str = "sketch-color-variables"; +const MODE: &str = "default"; + +/// A file's shared styles and swatches. +#[derive(Default)] +pub(crate) struct Shared { + /// `(id, name, style)` of layer styles, then text styles. + layer: Vec<(String, String, Value)>, + text: Vec<(String, String, Value)>, + swatches: BTreeMap, +} + +impl Shared { + pub(crate) fn read(document: &Value) -> Shared { + let mut s = Shared::default(); + let objects = + |key: &str| -> Vec { document.get(key).and_then(|c| c.get("objects")).and_then(Value::as_array).cloned().unwrap_or_default() }; + let foreign = |key: &str, inner: &str| -> Vec { + document.get(key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().filter_map(|f| f.get(inner).cloned()).collect() + }; + let entry = |o: &Value| { + Some(( + str_field(o, "do_objectID")?.to_string(), + str_field(o, "name").unwrap_or("Style").chars().take(200).collect(), + o.get("value")?.clone(), + )) + }; + for o in objects("layerStyles").iter().chain(&foreign("foreignLayerStyles", "localSharedStyle")).take(MAX_SHARED) { + s.layer.extend(entry(o)); + } + for o in objects("layerTextStyles").iter().chain(&foreign("foreignTextStyles", "localSharedStyle")).take(MAX_SHARED) { + s.text.extend(entry(o)); + } + let foreign_swatches = [foreign("foreignSwatches", "localSwatch"), foreign("foreignSwatches", "localSharedSwatch")].concat(); + for o in objects("sharedSwatches").iter().chain(&foreign_swatches).take(MAX_SHARED) { + if let Some((id, name, value)) = entry(o) + && let Some(c) = color(&value) + { + s.swatches.entry(id).or_insert((name, c)); + } + } + s + } + + pub(crate) fn has_swatch(&self, id: &str) -> bool { + self.swatches.contains_key(id) + } + + /// The REST `styleDefinitions` of every shared style. + pub(crate) fn definitions(cx: &mut Cx) -> Vec { + let layer = cx.shared.layer.clone(); + let text = cx.shared.text.clone(); + let mut out = Vec::new(); + for (id, name, style) in &layer { + let fills = super::style::fills(cx, style, 100.0, 100.0); + if !fills.is_empty() { + out.push(json!({"id": format!("{id}:fill"), "name": name, "styleType": "FILL", "node": {"fills": fills}})); + } + let strokes = super::style::border_paints(cx, style, 100.0, 100.0); + if !strokes.is_empty() { + out.push(json!({"id": format!("{id}:stroke"), "name": format!("{name} border"), "styleType": "FILL", "node": {"fills": strokes}})); + } + let effects = super::style::effects(cx, style); + if !effects.is_empty() { + out.push(json!({"id": format!("{id}:effect"), "name": name, "styleType": "EFFECT", "node": {"effects": effects}})); + } + } + for (id, name, style) in &text { + let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) else { continue }; + let mut ts = super::text::type_style(cx, enc); + if let Some(m) = ts.as_object_mut() { + m.remove("fills"); + } + out.push(json!({"id": id, "name": name, "styleType": "TEXT", "node": {"type": "TEXT", "characters": "", "style": ts}})); + } + out + } + + /// Link `node` (layer `l`) to the styles made from its shared style. + pub(crate) fn link(&self, cx_defs: &[String], l: &Value, node: &mut JsonMap) { + let Some(sid) = l.get("style").and_then(|s| str_field(s, "sharedObjectID")) else { return }; + let mut refs = JsonMap::new(); + let is_text = str_field(l, "_class") == Some("text"); + if is_text && self.text.iter().any(|(id, _, _)| id == sid) { + refs.insert("text".into(), json!(sid)); + } else if self.layer.iter().any(|(id, _, _)| id == sid) { + for slot in ["fill", "stroke", "effect"] { + let id = format!("{sid}:{slot}"); + if cx_defs.contains(&id) { + refs.insert(slot.into(), json!(id)); + } + } + } + if !refs.is_empty() { + node.insert("styles".into(), Value::Object(refs)); + } + } + + /// The REST `variableCollections` and `variables` for the swatches (none without swatches). + pub(crate) fn variables(&self) -> Option<(Value, Value)> { + if self.swatches.is_empty() { + return None; + } + let ids: Vec<&String> = self.swatches.keys().collect(); + let collections = json!({COLLECTION: {"id": COLLECTION, "name": "Color Variables", "modes": [{"modeId": MODE, "name": "Default"}], + "defaultModeId": MODE, "variableIds": ids}}); + let vars: JsonMap = self + .swatches + .iter() + .map(|(id, (name, c))| { + (id.clone(), json!({"id": id, "name": name, "resolvedType": "COLOR", "variableCollectionId": COLLECTION, "valuesByMode": {MODE: c}})) + }) + .collect(); + Some((collections, Value::Object(vars))) + } +} diff --git a/crates/format/src/sketch/shared_tests.rs b/crates/format/src/sketch/shared_tests.rs new file mode 100644 index 00000000..571a6d2e --- /dev/null +++ b/crates/format/src/sketch/shared_tests.rs @@ -0,0 +1,150 @@ +//! Sketch shared styles → FigCraft styles; Color Variables (swatches) → colour variables. + +use figcraft_doc::color::PaintKind; +use figcraft_doc::{Document, Node, StyleKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +const BLACK: [f64; 4] = [0.0, 0.0, 0.0, 1.0]; + +fn open(doc_json: Value, layers: Vec) -> (Document, Vec) { + let z = sketch_zip("83.2", 139, doc_json, &[page("P", "Page", vec![artboard("A", "Board", 0.0, 0.0, 400.0, 400.0, layers)])], &[]); + import(&z, "f").unwrap() +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +fn style<'d>(doc: &'d Document, id: &str) -> &'d figcraft_doc::Style { + doc.styles.iter().find(|s| s.id == id).unwrap_or_else(|| panic!("no style {id}: {:?}", doc.styles.iter().map(|s| &s.id).collect::>())) +} + +fn card_style() -> Value { + json!({"_class": "style", "fills": [solid_fill(0.0, 0.0, 1.0, 1.0)], "borders": [], + "shadows": [{"_class": "shadow", "isEnabled": true, "blurRadius": 4, "offsetX": 0, "offsetY": 2, "spread": 0, "color": color(0.0, 0.0, 0.0, 0.3)}], + "innerShadows": []}) +} + +fn with_shared(d: &mut Value, layer_styles: Vec, text_styles: Vec) { + d["layerStyles"]["objects"] = Value::Array(layer_styles); + d["layerTextStyles"]["objects"] = Value::Array(text_styles); +} + +fn shared(id: &str, name: &str, value: Value) -> Value { + json!({"_class": "sharedStyle", "do_objectID": id, "name": name, "value": value}) +} + +fn linked(mut l: Value, style: Value, shared_id: &str) -> Value { + l["style"] = style; + l["style"]["sharedObjectID"] = json!(shared_id); + l +} + +#[test] +fn layer_style_splits() { + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "CARD")]); + let n = node(&doc, "R"); + let fill = n.styles.fill.clone().expect("fill style linked"); + let effect = n.styles.effect.clone().expect("effect style linked"); + assert_eq!(style(&doc, &fill).name, "Card"); + assert!(matches!(style(&doc, &fill).kind, StyleKind::Paint { .. })); + assert!(matches!(style(&doc, &effect).kind, StyleKind::Effect { .. })); + assert_eq!(style(&doc, &effect).name, "Card"); +} + +#[test] +fn layer_style_borders_become_a_stroke_style() { + let mut st = card_style(); + st["borders"] = + json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(1.0, 0.0, 0.0, 1.0), "position": 1, "thickness": 2}]); + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", st.clone())], vec![]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), st, "CARD")]); + let stroke = node(&doc, "R").styles.stroke.clone().expect("stroke style linked"); + assert!(style(&doc, &stroke).name.starts_with("Card")); +} + +#[test] +fn unused_shared_styles_are_listed() { + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]); + let (doc, _) = open(d, vec![]); + assert!(doc.styles.iter().any(|s| s.name == "Card")); +} + +#[test] +fn text_style_links() { + let enc = json!({"MSAttributedStringFontAttribute": {"_class": "fontDescriptor", "attributes": {"name": "Helvetica-Bold", "size": 32}}, + "MSAttributedStringColorAttribute": color(1.0, 0.0, 0.0, 1.0), + "paragraphStyle": {"_class": "paragraphStyle", "alignment": 0, "maximumLineHeight": 40, "minimumLineHeight": 40}}); + let st = json!({"_class": "style", "textStyle": {"_class": "textStyle", "encodedAttributes": enc, "verticalAlignment": 0}}); + let mut d = document(&["P"]); + with_shared(&mut d, vec![], vec![shared("H1", "Heading/H1", st.clone())]); + let t = linked(text("Title", 0.0, 0.0, 200.0, 40.0, "Hello", &[(0, 5, "Helvetica-Bold", 32.0, [1.0, 0.0, 0.0, 1.0])]), st, "H1"); + let (doc, _) = open(d, vec![t]); + let n = node(&doc, "Title"); + let sid = n.styles.text.clone().expect("text style linked"); + let s = style(&doc, &sid); + assert_eq!(s.name, "Heading/H1"); + let StyleKind::Text { style: ts } = &s.kind else { panic!("not a text style") }; + assert_eq!((ts.font_family.as_str(), ts.font_style.as_str(), ts.font_size), ("Helvetica", "Bold", 32.0)); + assert!(n.fills.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0))); +} + +#[test] +fn locally_changed_style_keeps_link() { + let mut local = card_style(); + local["fills"] = json!([solid_fill(0.0, 1.0, 0.0, 1.0)]); + let mut d = document(&["P"]); + with_shared(&mut d, vec![shared("CARD", "Card", card_style())], vec![]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), local, "CARD")]); + let n = node(&doc, "R"); + assert!(n.styles.fill.is_some()); + assert!(matches!(n.fills[0].kind, PaintKind::Solid { color } if color.g == 1.0 && color.b == 0.0)); +} + +#[test] +fn foreign_styles_too() { + let mut d = document(&["P"]); + d["foreignLayerStyles"] = json!([{"_class": "MSImmutableForeignLayerStyle", "sourceLibraryName": "Kit", "localSharedStyle": shared("FCARD", "Kit card", card_style())}]); + let (doc, _) = open(d, vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "FCARD")]); + assert_eq!(style(&doc, node(&doc, "R").styles.fill.as_deref().unwrap()).name, "Kit card"); +} + +#[test] +fn swatches_are_color_variables() { + let mut d = document(&["P"]); + d["sharedSwatches"] = json!({"_class": "swatchContainer", "objects": [{"_class": "swatch", "do_objectID": "SW1", "name": "Brand/Blue", "value": color(0.06, 0.38, 1.0, 1.0)}]}); + d["foreignSwatches"] = json!([{"_class": "MSImmutableForeignSwatch", "sourceLibraryName": "Kit", "localSwatch": {"_class": "swatch", "do_objectID": "SW2", "name": "Kit/Red", "value": color(1.0, 0.0, 0.0, 1.0)}}]); + let mut fill = solid_fill(0.06, 0.38, 1.0, 1.0); + fill["color"]["swatchID"] = json!("SW1"); + let mut fill2 = solid_fill(1.0, 0.0, 0.0, 1.0); + fill2["color"]["swatchID"] = json!("SW2"); + let (doc, _) = open( + d, + vec![styled(rect("R", 0.0, 0.0, 50.0, 50.0), "fills", json!([fill])), styled(rect("S", 0.0, 0.0, 50.0, 50.0), "fills", json!([fill2]))], + ); + let coll = doc.variables.collections.iter().find(|c| c.name == "Color Variables").expect("collection"); + let var = doc.variables.variables.iter().find(|v| v.name == "Brand/Blue").expect("variable"); + assert_eq!(var.collection, coll.id); + assert!(doc.variables.variables.iter().any(|v| v.name == "Kit/Red")); + assert_eq!(node(&doc, "R").bound.get("fill"), Some(&var.id)); + assert!(node(&doc, "S").bound.contains_key("fill")); +} + +#[test] +fn unknown_shared_ids_are_ignored() { + let (doc, _) = open(document(&["P"]), vec![linked(rect("R", 0.0, 0.0, 50.0, 50.0), card_style(), "NOPE")]); + let n = node(&doc, "R"); + assert!(n.styles.fill.is_none()); + let mut f = solid_fill(0.0, 0.0, 0.0, 1.0); + f["color"]["swatchID"] = json!("NOPE"); + let (doc, _) = + open(document(&["P"]), vec![styled(text("T", 0.0, 0.0, 10.0, 10.0, "x", &[(0, 1, "Helvetica", 9.0, BLACK)]), "fills", json!([f]))]); + assert!(node(&doc, "T").bound.is_empty()); +} diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index 2da1b7a4..de2529aa 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -17,7 +17,7 @@ const MAX_LIST: usize = 64; /// Write the paints, strokes, effects, opacity and blend mode of `style` into `node`, a layer of /// `w`×`h`. Lists the style doesn't have are left as they are. pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { - let fills: Vec = list(style, "fills").iter().filter_map(|f| paint(cx, f, w, h)).collect(); + let fills = fills(cx, style, w, h); if !fills.is_empty() { node.insert("fills".into(), Value::Array(fills)); } @@ -36,6 +36,16 @@ pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonM } } +/// A style's fills as REST paints. +pub(crate) fn fills(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { + list(style, "fills").iter().filter_map(|f| paint(cx, f, w, h)).collect() +} + +/// A style's borders as REST paints. +pub(crate) fn border_paints(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { + list(style, "borders").iter().filter_map(|b| paint(cx, b, w, h)).collect() +} + /// Remove what [`apply`] writes, so another style can take its place. pub(crate) fn clear(node: &mut JsonMap) { for key in [ @@ -91,6 +101,9 @@ fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option { } }; p["visible"] = json!(f.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)); + if let Some(var) = swatch(cx, f.get("color")) { + p["boundVariables"] = var; + } if let Some(ctx) = f.get("contextSettings") { if let Some(o) = num(ctx, "opacity") { p["opacity"] = json!(o.clamp(0.0, 1.0)); @@ -131,7 +144,7 @@ fn pt((x, y): (f64, f64)) -> Value { /// Borders → stroke paints, and the first enabled border's thickness and position. fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap) { let list = list(style, "borders"); - let strokes: Vec = list.iter().filter_map(|b| paint(cx, b, w, h)).collect(); + let strokes = border_paints(cx, style, w, h); if strokes.is_empty() { return; } @@ -201,7 +214,7 @@ fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap Vec { +pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { let mut out = Vec::new(); for (key, ty) in [("shadows", "DROP_SHADOW"), ("innerShadows", "INNER_SHADOW")] { for s in list(style, key) { @@ -230,6 +243,12 @@ fn effects(cx: &mut Cx, style: &Value) -> Vec { out } +/// The variable binding of a colour that names a swatch the file has (`{color: alias}`). +pub(crate) fn swatch(cx: &Cx, c: Option<&Value>) -> Option { + let id = c?.get("swatchID").and_then(Value::as_str).filter(|id| cx.shared.has_swatch(id))?; + Some(json!({"color": {"type": "VARIABLE_ALIAS", "id": id}})) +} + /// Sketch's blend modes (0 normal … 17 plus lighter) by REST name. fn blend(mode: i64) -> Option<&'static str> { const MODES: [&str; 18] = [ diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index d6e7165c..15079702 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -209,8 +209,11 @@ fn apply(layers: &mut [Value], ov: &BTreeMap, document: &Value, d _ => {} } for (prop, list) in [("layerStyle", "layerStyles"), ("textStyle", "layerTextStyles")] { - if let Some(style) = get(prop).and_then(Value::as_str).and_then(|sid| shared_style(document, list, sid)) { + let sid = get(prop).and_then(Value::as_str); + if let Some((sid, style)) = sid.and_then(|sid| Some((sid, shared_style(document, list, sid)?))) { l["style"] = style.clone(); + // The layer now uses that shared style: it links to it. + l["style"]["sharedObjectID"] = json!(sid); if prop == "textStyle" && let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) && let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 74e7a731..3cdb2afc 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -94,7 +94,7 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { } /// A run's attributes as a REST type style (with its colour as `fills`). -fn type_style(cx: &mut Cx, a: &Value) -> Value { +pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { let font = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")); let ps = font.and_then(|f| f.get("name")).and_then(Value::as_str).unwrap_or(""); let size = font.and_then(|f| f.get("size")).and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(12.0); @@ -126,7 +126,11 @@ fn type_style(cx: &mut Cx, a: &Value) -> Value { _ => "ORIGINAL", }); let c = a.get("MSAttributedStringColorAttribute").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 1.0})); - s["fills"] = json!([{"type": "SOLID", "color": c}]); + let mut paint = json!({"type": "SOLID", "color": c}); + if let Some(var) = super::style::swatch(cx, a.get("MSAttributedStringColorAttribute")) { + paint["boundVariables"] = var; + } + s["fills"] = json!([paint]); s } diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 11c7a414..481eec70 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -27,6 +27,9 @@ pub(crate) struct Cx<'a> { /// `document.json`: shared styles and library copies. pub document: &'a Value, pub symbols: super::symbols::Symbols, + pub shared: super::shared::Shared, + /// Ids of the styles made from the shared styles. + pub style_defs: Vec, /// The symbols being expanded, outermost first (a symbol inside itself stops there). pub chain: Vec, pub warnings: Vec, @@ -49,9 +52,12 @@ impl Cx<'_> { /// Map the pages of `archive` to a REST file JSON named `name`. pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); + let shared = super::shared::Shared::read(&archive.document); let mut cx = Cx { document: &archive.document, symbols, + shared, + style_defs: Vec::new(), chain: Vec::new(), warnings: Vec::new(), networks: Vec::new(), @@ -59,6 +65,8 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { nodes: 0, max_nodes, }; + let defs = super::shared::Shared::definitions(&mut cx); + cx.style_defs = defs.iter().filter_map(|d| d.get("id").and_then(Value::as_str).map(str::to_string)).collect(); let mut canvases = Vec::new(); for (id, page) in &archive.pages { let mut node = JsonMap::new(); @@ -75,7 +83,11 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { let libraries = cx.symbols.libraries.join(", "); cx.warn(format!("symbols from the libraries {libraries} became local components on the page Library symbols")); } - let json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}}); + let mut json = json!({"name": name, "document": {"type": "DOCUMENT", "id": "0:0", "children": canvases}, "styleDefinitions": defs}); + if let Some((collections, variables)) = cx.shared.variables() { + json["variableCollections"] = collections; + json["variables"] = variables; + } Map { json, warnings: cx.warnings, networks: cx.networks, too_old: cx.too_old } } @@ -147,9 +159,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: }, _ => {} } - if let Some(st) = l.get("style") { - super::style::apply(cx, st, w, h, &mut node); - } + styles(cx, l, w, h, &mut node); if matches!(ty, "FRAME" | "GROUP") { let kids = children(cx, l, world, (w, h), depth, false); node.insert("children".into(), Value::Array(kids)); @@ -157,6 +167,14 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: Some(Value::Object(node)) } +/// Layer `l`'s style into `node`, and its links to the styles made from its shared style. +pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap) { + if let Some(st) = l.get("style") { + super::style::apply(cx, st, w, h, node); + } + cx.shared.link(&cx.style_defs, l, node); +} + /// A clipping mask's flag and kind (outline or alpha). pub(crate) fn mask(l: &Value, node: &mut JsonMap) { if bool_field(l, "hasClippingMask") == Some(true) { From 31698164acacf2348a70beea39e8a2d5cf9b97cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 16:46:21 +0200 Subject: [PATCH 043/208] M6: Sketch import: prototype links, export settings, layout grids Layers linked to artboards navigate there on click (back links go back, slide animations slide), hotspots become transparent frames with their link, flow-home artboards start a flow; export options become export settings (scale, width or height, suffix); artboard layout and square grids become layout grids, which instances show like their masters. Smart Layout, stacks and locked overrides are noted once per file. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/mod.rs | 3 + crates/format/src/sketch/path.rs | 2 + crates/format/src/sketch/proto.rs | 117 ++++++++++++++++++++++ crates/format/src/sketch/proto_tests.rs | 125 ++++++++++++++++++++++++ crates/format/src/sketch/symbols.rs | 4 + crates/format/src/sketch/tree.rs | 20 ++++ 6 files changed, 271 insertions(+) create mode 100644 crates/format/src/sketch/proto.rs create mode 100644 crates/format/src/sketch/proto_tests.rs diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index db0421f8..3132e658 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -16,6 +16,9 @@ mod fixture; mod path; #[cfg(test)] mod path_tests; +mod proto; +#[cfg(test)] +mod proto_tests; mod shared; #[cfg(test)] mod shared_tests; diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 0cce6e08..8fbe84cd 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -143,6 +143,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, super::style::clear(m); m.remove("styles"); super::tree::styles(cx, l, cw, ch, m); + super::tree::extras(cx, l, ch, m); } super::tree::mask(l, m); } @@ -170,6 +171,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, node.insert("children".into(), Value::Array(chain)); super::tree::mask(l, &mut node); super::tree::styles(cx, l, w, h, &mut node); + super::tree::extras(cx, l, h, &mut node); Some(Value::Object(node)) } diff --git a/crates/format/src/sketch/proto.rs b/crates/format/src/sketch/proto.rs new file mode 100644 index 00000000..afa6df48 --- /dev/null +++ b/crates/format/src/sketch/proto.rs @@ -0,0 +1,117 @@ +//! Sketch prototype links, hotspots, export options and layout grids → REST. +//! +//! A layer's `flow` links it to an artboard (`destinationArtboardID`, or `back`) with an +//! `animationType` (0 none, 1–4 slide from the right, left, bottom, top); hotspots are layers that +//! only carry a link; `isFlowHome` marks an artboard where a prototype starts. + +use serde_json::{Map as JsonMap, Value, json}; + +use super::tree::{Cx, bool_field, str_field}; + +/// Most export settings read from one layer. +const MAX_EXPORTS: usize = 64; + +/// The click interaction of layer `l`'s `flow`, if it has one. +pub(crate) fn interactions(l: &Value, node: &mut JsonMap) { + let Some(flow) = l.get("flow") else { return }; + let Some(dest) = str_field(flow, "destinationArtboardID").filter(|d| !d.is_empty()) else { return }; + let action = if dest == "back" { + json!({"type": "BACK"}) + } else { + let direction = match flow.get("animationType").and_then(Value::as_i64) { + Some(1) => Some("LEFT"), + Some(2) => Some("RIGHT"), + Some(3) => Some("TOP"), + Some(4) => Some("BOTTOM"), + _ => None, + }; + let transition = + direction.map_or(Value::Null, |d| json!({"type": "SLIDE_IN", "direction": d, "duration": 0.3, "easing": {"type": "EASE_IN_AND_OUT"}})); + json!({"type": "NODE", "destinationId": dest, "navigation": "NAVIGATE", "transition": transition}) + }; + node.insert("interactions".into(), json!([{"trigger": {"type": "ON_CLICK"}, "actions": [action]}])); +} + +/// The artboards of a page marked as where a prototype starts, as REST `flowStartingPoints`. +pub(crate) fn starting_points(page: &Value) -> Vec { + super::tree::layers(page) + .iter() + .filter(|a| bool_field(a, "isFlowHome") == Some(true)) + .filter_map(|a| Some(json!({"nodeId": str_field(a, "do_objectID")?, "name": str_field(a, "name").unwrap_or("Flow")}))) + .collect() +} + +/// Layer `l`'s export options as REST `exportSettings`. +pub(crate) fn export_settings(cx: &mut Cx, l: &Value, node: &mut JsonMap) { + let formats = l.get("exportOptions").and_then(|o| o.get("exportFormats")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut out = Vec::new(); + for f in formats.iter().take(MAX_EXPORTS) { + let format = match str_field(f, "fileFormat").unwrap_or("png").to_ascii_lowercase().as_str() { + "png" => "PNG", + "jpg" | "jpeg" => "JPG", + "svg" => "SVG", + "pdf" => "PDF", + "webp" => "WEBP", + _ => { + cx.warn("export settings in formats FigCraft doesn't export (TIFF, EPS, HEIC) were skipped"); + continue; + } + }; + let num = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); + let constraint = match (f.get("visibleScaleType").and_then(Value::as_i64), num("absoluteSize")) { + (Some(1), Some(s)) => json!({"type": "WIDTH", "value": s.min(16_384.0)}), + (Some(2), Some(s)) => json!({"type": "HEIGHT", "value": s.min(16_384.0)}), + _ => json!({"type": "SCALE", "value": num("scale").unwrap_or(1.0).min(64.0)}), + }; + let name: String = str_field(f, "name").unwrap_or("").chars().take(200).collect(); + if f.get("namingScheme").and_then(Value::as_i64) == Some(1) && !name.is_empty() { + cx.warn("export names Sketch puts before the layer name come after it"); + } + out.push(json!({"format": format, "suffix": name, "constraint": constraint})); + } + if !out.is_empty() { + node.insert("exportSettings".into(), Value::Array(out)); + } +} + +/// An artboard's layout grid (columns, rows) and square grid as REST `layoutGrids`. +pub(crate) fn layout_grids(l: &Value, h: f64, node: &mut JsonMap) { + let mut out = Vec::new(); + if let Some(g) = l.get("layout").filter(|g| bool_field(g, "isEnabled") != Some(false)) { + let num = |k: &str| g.get(k).and_then(Value::as_f64).filter(|v| v.is_finite() && *v >= 0.0); + if bool_field(g, "drawVertical") != Some(false) + && let (Some(count), Some(width)) = (num("numberOfColumns"), num("columnWidth")) + { + out.push(json!({"pattern": "COLUMNS", "alignment": "MIN", "count": count.min(1000.0), "sectionSize": width.min(1e5), + "gutterSize": num("gutterWidth").unwrap_or(0.0).min(1e5), "offset": num("horizontalOffset").unwrap_or(0.0).min(1e5), "visible": true})); + } + if bool_field(g, "drawHorizontal") == Some(true) { + let gutter = num("gutterHeight").unwrap_or(0.0).min(1e5); + let section = (gutter * num("rowHeightMultiplication").unwrap_or(1.0)).min(1e5); + if section > 0.0 { + let count = (h / (section + gutter)).floor().clamp(1.0, 1000.0); + out.push(json!({"pattern": "ROWS", "alignment": "MIN", "count": count, "sectionSize": section, "gutterSize": gutter, "offset": 0, "visible": true})); + } + } + } + if let Some(g) = l.get("grid").filter(|g| bool_field(g, "isEnabled") != Some(false)) + && let Some(size) = g.get("gridSize").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) + { + out.push(json!({"pattern": "GRID", "sectionSize": size.min(1e5), "visible": true})); + } + if !out.is_empty() { + node.insert("layoutGrids".into(), Value::Array(out)); + } +} + +/// Notes about what the layer has that FigCraft keeps only as laid out: Smart Layout and Stacks, +/// and overrides a master locks. +pub(crate) fn notes(cx: &mut Cx, l: &Value) { + if l.get("groupLayout").and_then(|g| str_field(g, "_class")).is_some_and(|c| c != "MSImmutableFreeformGroupLayout") { + cx.warn("Smart Layout and stacks were imported as laid out; they don't resize with their content"); + } + let locked = l.get("overrideProperties").and_then(Value::as_array).is_some_and(|p| p.iter().any(|o| bool_field(o, "canOverride") == Some(false))); + if locked { + cx.warn("overrides Sketch symbols locked can be changed in FigCraft"); + } +} diff --git a/crates/format/src/sketch/proto_tests.rs b/crates/format/src/sketch/proto_tests.rs new file mode 100644 index 00000000..be9b835d --- /dev/null +++ b/crates/format/src/sketch/proto_tests.rs @@ -0,0 +1,125 @@ +//! Sketch prototype links, hotspots, export options, layout grids, smart layout and locked +//! overrides. + +use figcraft_doc::{Document, ExportFormat, Node, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +fn open(layers: Vec) -> (Document, Vec) { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "Page", layers)], &[]); + import(&z, "f").unwrap() +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +fn screens(button: Value) -> Vec { + vec![artboard("A1", "Home", 0.0, 0.0, 100.0, 100.0, vec![button]), artboard("A2", "Detail", 200.0, 0.0, 100.0, 100.0, vec![])] +} + +#[test] +fn flow_navigates() { + let b = with( + rect("Go", 0.0, 0.0, 20.0, 20.0), + "flow", + json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "A2", "animationType": 1}), + ); + let (doc, _) = open(screens(b)); + let go = node(&doc, "Go"); + assert_eq!(go.interactions.len(), 1, "{:?}", go.interactions); + let detail = node(&doc, "Detail").id; + let text = format!("{:?}", go.interactions[0]); + assert!(text.contains(&format!("{detail:?}")), "{text}"); +} + +#[test] +fn flow_back() { + let b = with( + rect("Back", 0.0, 0.0, 20.0, 20.0), + "flow", + json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "back", "animationType": 0}), + ); + let (doc, _) = open(screens(b)); + let text = format!("{:?}", node(&doc, "Back").interactions); + assert!(text.contains("Back"), "{text}"); +} + +#[test] +fn hotspot_is_transparent_frame_with_interaction() { + let mut h = with( + rect("Hot", 0.0, 0.0, 20.0, 20.0), + "flow", + json!({"_class": "MSImmutableFlowConnection", "destinationArtboardID": "A2", "animationType": 0}), + ); + h["_class"] = json!("MSImmutableHotspotLayer"); + let (doc, _) = open(screens(h)); + let n = node(&doc, "Hot"); + assert!(matches!(n.kind, NodeKind::Frame)); + assert!(n.fills.is_empty()); + assert_eq!(n.interactions.len(), 1); +} + +#[test] +fn flow_home_is_starting_point() { + let mut home = artboard("A1", "Home", 0.0, 0.0, 100.0, 100.0, vec![]); + home["isFlowHome"] = json!(true); + let (doc, _) = open(vec![home]); + let page = doc.node(doc.pages[0]).unwrap(); + let NodeKind::Page { flows, .. } = &page.kind else { panic!("not a page") }; + assert_eq!(flows.len(), 1); + assert_eq!(flows[0].start, node(&doc, "Home").id); +} + +#[test] +fn export_options() { + let mut r = rect("Icon", 0.0, 0.0, 20.0, 20.0); + r["exportOptions"] = json!({"_class": "exportOptions", "exportFormats": [ + {"_class": "exportFormat", "fileFormat": "png", "scale": 2, "name": "@2x", "namingScheme": 0, "absoluteSize": 0, "visibleScaleType": 0}, + {"_class": "exportFormat", "fileFormat": "svg", "scale": 1, "name": "", "namingScheme": 0, "absoluteSize": 0, "visibleScaleType": 0}, + {"_class": "exportFormat", "fileFormat": "png", "scale": 1, "name": "", "namingScheme": 0, "absoluteSize": 64, "visibleScaleType": 1}, + ]}); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![r])]); + let e = &node(&doc, "Icon").exports; + assert_eq!(e.len(), 3); + assert_eq!(e[0].format, ExportFormat::Png); + assert_eq!(e[0].suffix, "@2x"); + assert_eq!(e[1].format, ExportFormat::Svg); + let text = format!("{:?}", e[2]); + assert!(text.contains("64"), "{text}"); +} + +#[test] +fn layout_and_square_grids() { + let mut a = artboard("A", "Board", 0.0, 0.0, 1200.0, 800.0, vec![]); + a["layout"] = json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 84, "gutterWidth": 16, "horizontalOffset": 24, "numberOfColumns": 12, + "totalWidth": 1152, "guttersOutside": false, "drawVertical": true, "drawHorizontal": false, "gutterHeight": 8, "rowHeightMultiplication": 3}); + a["grid"] = json!({"_class": "simpleGrid", "isEnabled": true, "gridSize": 8, "thickGridTimes": 0}); + let (doc, _) = open(vec![a]); + let grids = format!("{:?}", node(&doc, "Board").grids); + assert_eq!(node(&doc, "Board").grids.len(), 2, "{grids}"); + assert!(grids.contains("12") && grids.contains("16"), "{grids}"); +} + +#[test] +fn smart_layout_warns_once() { + let g = |id: &str| { + with( + group(id, 0.0, 0.0, 10.0, 10.0, vec![rect(&format!("{id}r"), 0.0, 0.0, 10.0, 10.0)]), + "groupLayout", + json!({"_class": "MSImmutableInferredGroupLayout", "axis": 0}), + ) + }; + let (_, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![g("G1"), g("G2"), g("G3")])]); + assert_eq!(warnings.iter().filter(|w| w.contains("Smart Layout")).count(), 1, "{warnings:?}"); +} + +#[test] +fn locked_overrides_are_noted() { + let mut m = master("M", "S", "Locked", 50.0, 20.0, vec![text("L", 0.0, 0.0, 50.0, 20.0, "x", &[(0, 1, "Helvetica", 9.0, [0.0, 0.0, 0.0, 1.0])])]); + m["overrideProperties"] = json!([{"_class": "MSImmutableOverrideProperty", "overrideName": "L_stringValue", "canOverride": false}]); + let (_, warnings) = open(vec![m]); + assert!(warnings.iter().any(|w| w.contains("locked")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index 15079702..d25a6b56 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -102,6 +102,7 @@ pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, ro if let Some(f) = master_fills(l) { node.insert("fills".into(), f); } + super::tree::extras(cx, l, h, &mut node); let sid = str_field(l, "symbolID").unwrap_or("").to_string(); cx.chain.push(sid); let kids = children(cx, l, world, (w, h), depth, false); @@ -139,6 +140,9 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo if let Some(st) = l.get("style") { super::style::apply(cx, st, w, h, &mut node); } + super::tree::extras(cx, l, h, &mut node); + // An instance shows its master's grids (else they'd differ, and count as an override). + super::proto::layout_grids(&m, h, &mut node); let overrides = overrides(l); let mut copy = m; if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 481eec70..d63afa0f 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -75,6 +75,10 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { node.insert("name".into(), json!(str_field(page, "name").unwrap_or("Page"))); let kids = children(&mut cx, page, Affine::IDENTITY, (0.0, 0.0), 0, true); node.insert("children".into(), Value::Array(kids)); + let starts = super::proto::starting_points(page); + if !starts.is_empty() { + node.insert("flowStartingPoints".into(), Value::Array(starts)); + } canvases.push(Value::Object(node)); } let foreign = std::mem::take(&mut cx.symbols.foreign); @@ -120,6 +124,7 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: "slice" => "SLICE", "shapePath" | "star" | "polygon" | "triangle" => "VECTOR", "text" => "TEXT", + "MSImmutableHotspotLayer" | "hotspot" => "FRAME", other => { cx.warn(format!("Sketch {} layers are not supported yet and were skipped", clip(other))); return None; @@ -160,6 +165,11 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: _ => {} } styles(cx, l, w, h, &mut node); + extras(cx, l, h, &mut node); + if class == "MSImmutableHotspotLayer" || class == "hotspot" { + node.insert("clipsContent".into(), json!(false)); + node.remove("fills"); + } if matches!(ty, "FRAME" | "GROUP") { let kids = children(cx, l, world, (w, h), depth, false); node.insert("children".into(), Value::Array(kids)); @@ -175,6 +185,16 @@ pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap< cx.shared.link(&cx.style_defs, l, node); } +/// Layer `l`'s prototype link, export settings, layout grids (frames) and notes. +pub(crate) fn extras(cx: &mut Cx, l: &Value, h: f64, node: &mut JsonMap) { + super::proto::interactions(l, node); + super::proto::export_settings(cx, l, node); + if matches!(node.get("type").and_then(Value::as_str), Some("FRAME" | "COMPONENT" | "INSTANCE")) { + super::proto::layout_grids(l, h, node); + } + super::proto::notes(cx, l); +} + /// A clipping mask's flag and kind (outline or alpha). pub(crate) fn mask(l: &Value, node: &mut JsonMap) { if bool_field(l, "hasClippingMask") == Some(true) { From 233c8e97083dd80d598484740a2d0e6d0827d9f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 17:02:03 +0200 Subject: [PATCH 044/208] M6: Sketch import: local corpus check and docs FIGCRAFT_SKETCH_SAMPLES runs every .sketch file of a folder through file.open (opens with linked instances and finite geometry, or is refused as too old). File format parity gains a .sketch row; CLI, MCP, roadmap and ROADMAP mention the import. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +- crates/engine/src/sketch_import_tests.rs | 44 ++++++++++++++++++++++++ docs/cli.md | 3 +- docs/file-format-parity.md | 4 ++- docs/mcp.md | 2 +- docs/roadmap.md | 5 +-- 6 files changed, 55 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..3f57ed57 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (`.sketch` import, best effort (Sketch 48 and newer); community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -316,6 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index 10a7b9ca..c1ac6a59 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -175,3 +175,47 @@ fn sketch_instances_stay_live() { let b = doc.world_bounds(inst_tag).unwrap(); assert!((b.x0 - 180.0).abs() < 1e-6, "{b:?}"); } + +/// Local only (`FIGCRAFT_SKETCH_SAMPLES` names a folder of `.sketch` files): each opens (Sketch 48 +/// and newer) with its instances linked and finite geometry, or is refused as too old. +#[test] +fn local_sketch_samples_open() { + let Ok(dir) = std::env::var("FIGCRAFT_SKETCH_SAMPLES") else { return }; + let mut files: Vec = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect(); + files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch"))); + files.sort(); + assert!(!files.is_empty(), "no .sketch files in {dir}"); + for path in files { + let started = std::time::Instant::now(); + let mut s = Session::new(); + match s.execute("file.open", &json!({"path": path.to_string_lossy()})) { + Err(e) => { + let e = e.to_string(); + assert!(e.contains("older than 48"), "{}: {e}", path.display()); + eprintln!("{}: refused as too old", path.display()); + } + Ok(r) => { + let doc = s.doc().unwrap(); + let (mut layers, mut instances, mut unlinked) = (0, 0, 0); + for n in doc.nodes() { + layers += 1; + assert!( + n.width.is_finite() && n.height.is_finite() && n.transform.as_coeffs().iter().all(|c| c.is_finite()), + "{}: {}", + path.display(), + n.name + ); + if let NodeKind::Instance { component, .. } = n.kind { + instances += 1; + if !doc.contains(component) { + unlinked += 1; + } + } + } + assert_eq!(unlinked, 0, "{}", path.display()); + let notes = r.get("warnings").and_then(|w| w.as_array()).map_or(0, Vec::len); + eprintln!("{}: {layers} layers, {instances} instances, {notes} notes, {:?}", path.display(), started.elapsed()); + } + } + } +} diff --git a/docs/cli.md b/docs/cli.md index 699b6ba2..bf783d11 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -36,7 +36,8 @@ layers}]` with `--json`); their text renders in a fallback font. Every subcommand that reads a file also takes the public REST API file JSON (the response of `GET /v1/files/:key` or of its nodes endpoint, written to a `.json` file): it is imported on the fly. Image fills come from `images/.png` (or `.jpg`, …) next to the JSON when present, else a -grey placeholder. `.fig` files are read the same way (best effort, images from the file itself); +grey placeholder. `.fig` files are read the same way (best effort, images from the file itself), and +so are `.sketch` files saved by Sketch 48 or newer; `info` lists what wasn't imported as `note:` lines. ```text diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index adf0b0f2..ed2d3157 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (`.sketch` import, best effort, Sketch 48 and newer; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -18,6 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape or as boolean operations (mixed operations nested from the left), bitmaps, slices, hotspots; transforms (rotation, flips), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), resized by their constraints and still live; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (80,000 layers open in ~4 s, debug build); Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -165,6 +166,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Checked on fixtures and locally on one real Sketch 50 file and the IBM Carbon Design Kit (Sketch 47, 68, 83). Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..a56fbd95 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -303,7 +303,7 @@ Or in a project's `.mcp.json`: | `select {ids}` | Replace the selection (`[]` deselects). | | `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | -| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. | +| `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.sketch` (Sketch 48 and newer, best effort), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. | | `undo` / `redo` | Step through history. | | `pointer {kind, x, y, to?, steps?, tool?, shift, alt, cmd, pressure?, pressureTo?}` | Mouse gestures in page coordinates with the current tool: `click`, `double`, `drag`, `down`, `move`, `up`, `hover`. A dragged layer drops into the frame or auto layout slot under the pointer, except one inside a group, a boolean group or an instance (outside its slots), which only moves. `pressure` (0–1) is pen pressure; a drag goes from `pressure` to `pressureTo` (see Brushes and pen pressure). | | `key {key, shift, alt, cmd}` | A shortcut or key as if typed on the canvas (every default shortcut, tool keys, Escape, Delete, arrows, number keys for opacity, arrows that pan the view when nothing is selected; while editing text, the text shortcuts and then text editing keys). Keys are names like `A`, `0`, `Enter`, `ArrowLeft`, `=`, `/`, or egui's (`Plus`, `Questionmark`); `cmd` is Cmd on macOS and Ctrl elsewhere. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 8d63a469..fcca7553 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (`.sketch` import, best effort; community batch #753: Selection colors, smart-selection spacing, a Present tab; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -53,7 +53,7 @@ blockers. | M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | | M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11; vector edit mode regions 2026-10-13) | spell check, Figma Draw multi-edit | 7–18 | | M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 | -| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | +| M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11; best-effort `.sketch` import 2026-10-21) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | | M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | | M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | | M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-21 | minor | M6: best-effort `.sketch` import (Sketch 48 and newer), see [file-format-parity](file-format-parity.md). Percentages unchanged | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | From 6640498195c07107c1b4c38a5ef2c9d4effe42a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 17:22:07 +0200 Subject: [PATCH 045/208] M6: Sketch import: hostile files can't panic, run out of memory or hang From the branch review: overrides that point at junk (shared styles that aren't objects, text runs that aren't) no longer panic; symbol expansion spends a work budget (JSON copied, overrides handed on to nested instances, grouped once by their first path segment), so small files can't expand into gigabytes; far path points stay in range; notes are capped and de-duplicated with a set; shared styles are looked up by index; a styled second mask's drawn copy isn't clipped by the first; JSON members are refused past 100 times their packed size or 2 GB in all, and an unreadable document.json keeps the pages. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/mod.rs | 56 ++++++++-- crates/format/src/sketch/path.rs | 6 +- crates/format/src/sketch/path_tests.rs | 13 +++ crates/format/src/sketch/shared.rs | 27 ++++- crates/format/src/sketch/style_tests.rs | 12 ++ crates/format/src/sketch/symbols.rs | 127 +++++++++++++++------- crates/format/src/sketch/symbols_tests.rs | 54 +++++++++ crates/format/src/sketch/tree.rs | 54 +++++++-- crates/format/src/sketch/tree_tests.rs | 52 +++++++++ 9 files changed, 337 insertions(+), 64 deletions(-) diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 3132e658..3c64b409 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -40,7 +40,7 @@ mod tree_tests; use std::collections::BTreeSet; use figcraft_doc::Document; -use serde_json::Value; +use serde_json::{Value, json}; use crate::FormatError; @@ -53,6 +53,15 @@ pub const NEWEST_TESTED_VERSION: u64 = 139; pub const TOO_OLD: &str = "saved by a Sketch older than 48; open it in a newer Sketch and save it again"; /// Largest JSON member read (a real page of a few thousand layers takes 33 MB). const MAX_JSON_MEMBER: u64 = 512 * 1024 * 1024; +/// Most bytes JSON members may unpack to, per compressed byte (real files pack 5–20 times; a +/// zero-filled array a thousand times), with a floor for small members. +const MAX_JSON_RATIO: u64 = 100; +const MIN_JSON_ALLOWANCE: u64 = 16 * 1024 * 1024; +/// Most JSON read from one file in all. +#[cfg(not(target_pointer_width = "32"))] +const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024; /// Largest image member read. const MAX_IMAGE: u64 = 256 * 1024 * 1024; /// Most pages read from one file. @@ -165,22 +174,36 @@ pub(crate) struct Archive<'b> { impl<'b> Archive<'b> { fn open(bytes: &'b [u8], max_member: u64) -> Result { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?; - let json = |z: &mut zip::ZipArchive>, member: &str, limit: u64| -> Result { - let data = crate::read_member(z, member, limit)?; - serde_json::from_slice(&data).map_err(|e| corrupt(format!("{member}: {e}"))) - }; - let meta = json(&mut z, "meta.json", MAX_JSON_MEMBER)?; + let mut total = 0u64; + let meta = json_member(&mut z, "meta.json", MAX_JSON_MEMBER, &mut total)?; if !meta.is_object() { return Err(corrupt("meta.json is not an object")); } if app_major(&meta).is_some_and(|v| v < MIN_SKETCH) { return Err(FormatError::Io(TOO_OLD.into())); } - let document = json(&mut z, "document.json", MAX_JSON_MEMBER)?; let mut warnings = Vec::new(); if meta.get("version").and_then(Value::as_u64).is_some_and(|v| v > NEWEST_TESTED_VERSION) { warnings.push("the file was saved by a newer Sketch than the import was checked with; some layers may differ".to_string()); } + // A document.json that can't be read (nested too deep, damaged) loses its shared styles and + // library copies, not the pages: they are then taken in archive order. + let document = match json_member(&mut z, "document.json", MAX_JSON_MEMBER, &mut total) { + Ok(d) if d.is_object() => d, + other => { + let why = other.err().map_or_else(|| "not an object".to_string(), |e| e.to_string()); + warnings.push(format!( + "the document's shared styles and library symbols were skipped: {}", + why.trim_start_matches("not a valid FigCraft file: ") + )); + let pages: Vec = z + .file_names() + .filter_map(|n| n.strip_prefix("pages/")?.strip_suffix(".json")) + .map(|id| json!({"_ref": format!("pages/{id}")})) + .collect(); + json!({"pages": pages}) + } + }; let refs = document.get("pages").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); let mut pages = Vec::new(); for r in refs.iter().take(MAX_PAGES) { @@ -188,7 +211,7 @@ impl<'b> Archive<'b> { warnings.push("pages the document lists without a valid reference were skipped".into()); continue; }; - match json(&mut z, &format!("pages/{id}.json"), max_member) { + match json_member(&mut z, &format!("pages/{id}.json"), max_member, &mut total) { Ok(page) if page.is_object() => pages.push((id.to_string(), page)), Ok(_) => warnings.push("pages that aren't JSON objects were skipped".into()), Err(e) => warnings.push(format!("a page was skipped: {}", e.to_string().trim_start_matches("not a valid FigCraft file: "))), @@ -212,6 +235,23 @@ impl<'b> Archive<'b> { } } +/// JSON member `name`, refused before unpacking when it would be larger than `limit`, than +/// [`MAX_JSON_RATIO`] times what it takes in the archive (past a floor), or than what is left of +/// the file's [`MAX_JSON_TOTAL`] (`total`: read so far). +fn json_member(z: &mut zip::ZipArchive>, name: &str, limit: u64, total: &mut u64) -> Result { + let (size, packed) = { + let f = z.by_name(name).map_err(|e| corrupt(format!("{name}: {e}")))?; + (f.size(), f.compressed_size()) + }; + let allowed = limit.min(packed.saturating_mul(MAX_JSON_RATIO).max(MIN_JSON_ALLOWANCE)).min(MAX_JSON_TOTAL.saturating_sub(*total)); + if size > allowed { + return Err(corrupt(format!("{name} is too large"))); + } + let data = crate::read_member(z, name, allowed)?; + *total += data.len() as u64; + serde_json::from_slice(&data).map_err(|e| corrupt(format!("{name}: {e}"))) +} + /// The major version of the Sketch that saved the file (`meta.json` `appVersion`, "50.2" → 50). fn app_major(meta: &Value) -> Option { meta.get("appVersion").and_then(Value::as_str)?.split('.').next()?.trim().parse().ok() diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 8fbe84cd..30a9f256 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -12,6 +12,8 @@ use serde_json::{Map as JsonMap, Value, json}; use super::tree::{Cx, base, layer, layers, local, size}; +/// Farthest a point or handle may be from its layer (the REST importer's own range). +const MAX_COORD: f64 = 1e7; /// Most points read from one path. const MAX_POINTS: usize = 100_000; @@ -36,7 +38,9 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option MAX_POINTS { cx.warn(format!("paths with more than {MAX_POINTS} points were cut")); } - let at = |p: &Value, key: &str| p.get(key).and_then(Value::as_str).and_then(parse_point).map(|(x, y)| (x * w, y * h)); + // Far points stay within the importer's coordinate range (a product could overflow). + let fit = |v: f64| if v.is_finite() { v.clamp(-MAX_COORD, MAX_COORD) } else { 0.0 }; + let at = |p: &Value, key: &str| p.get(key).and_then(Value::as_str).and_then(parse_point).map(|(x, y)| (fit(x * w), fit(y * h))); let mut vertices = Vec::new(); // Per vertex: its handles (relative to it) when it has them. let mut handles: Vec<((f64, f64), (f64, f64))> = Vec::new(); diff --git a/crates/format/src/sketch/path_tests.rs b/crates/format/src/sketch/path_tests.rs index 8e1528fb..fb03d74c 100644 --- a/crates/format/src/sketch/path_tests.rs +++ b/crates/format/src/sketch/path_tests.rs @@ -152,3 +152,16 @@ fn even_odd_winding() { let NodeKind::Vector { network } = &node(&doc, "Holes").kind else { panic!("not a vector") }; assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); } + +#[test] +fn huge_points_stay_finite() { + let mut p = shape_path("Huge", 0.0, 0.0, 1e6, 1e6, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true); + p["points"][1]["point"] = json!("{1e308, 0}"); + p["points"][1]["curveMode"] = json!(2); + p["points"][1]["curveFrom"] = json!("{-1e308, 1e308}"); + let doc = open(vec![p]); + if let NodeKind::Vector { network } = &node(&doc, "Huge").kind { + assert!(network.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())); + assert!(network.segments.iter().all(|s| [s.ta.0, s.ta.1, s.tb.0, s.tb.1].iter().all(|c| c.is_finite()))); + } +} diff --git a/crates/format/src/sketch/shared.rs b/crates/format/src/sketch/shared.rs index 104a1931..ee122148 100644 --- a/crates/format/src/sketch/shared.rs +++ b/crates/format/src/sketch/shared.rs @@ -7,7 +7,7 @@ //! none. Layers link to them by `style.sharedObjectID`. Swatches (Color Variables, Sketch 69+) //! become colour variables in one collection; colours name theirs by `swatchID`. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Map as JsonMap, Value, json}; @@ -25,6 +25,9 @@ pub(crate) struct Shared { /// `(id, name, style)` of layer styles, then text styles. layer: Vec<(String, String, Value)>, text: Vec<(String, String, Value)>, + /// Positions in `layer` and `text` by id. + layer_index: BTreeMap, + text_index: BTreeMap, swatches: BTreeMap, } @@ -44,10 +47,16 @@ impl Shared { )) }; for o in objects("layerStyles").iter().chain(&foreign("foreignLayerStyles", "localSharedStyle")).take(MAX_SHARED) { - s.layer.extend(entry(o)); + if let Some(e) = entry(o).filter(|e| !s.layer_index.contains_key(&e.0)) { + s.layer_index.insert(e.0.clone(), s.layer.len()); + s.layer.push(e); + } } for o in objects("layerTextStyles").iter().chain(&foreign("foreignTextStyles", "localSharedStyle")).take(MAX_SHARED) { - s.text.extend(entry(o)); + if let Some(e) = entry(o).filter(|e| !s.text_index.contains_key(&e.0)) { + s.text_index.insert(e.0.clone(), s.text.len()); + s.text.push(e); + } } let foreign_swatches = [foreign("foreignSwatches", "localSwatch"), foreign("foreignSwatches", "localSharedSwatch")].concat(); for o in objects("sharedSwatches").iter().chain(&foreign_swatches).take(MAX_SHARED) { @@ -60,6 +69,12 @@ impl Shared { s } + /// The style of shared text (`text`) or layer style `id`. + pub(crate) fn style(&self, text: bool, id: &str) -> Option<&Value> { + let (index, list) = if text { (&self.text_index, &self.text) } else { (&self.layer_index, &self.layer) }; + list.get(*index.get(id)?).map(|e| &e.2) + } + pub(crate) fn has_swatch(&self, id: &str) -> bool { self.swatches.contains_key(id) } @@ -95,13 +110,13 @@ impl Shared { } /// Link `node` (layer `l`) to the styles made from its shared style. - pub(crate) fn link(&self, cx_defs: &[String], l: &Value, node: &mut JsonMap) { + pub(crate) fn link(&self, cx_defs: &BTreeSet, l: &Value, node: &mut JsonMap) { let Some(sid) = l.get("style").and_then(|s| str_field(s, "sharedObjectID")) else { return }; let mut refs = JsonMap::new(); let is_text = str_field(l, "_class") == Some("text"); - if is_text && self.text.iter().any(|(id, _, _)| id == sid) { + if is_text && self.text_index.contains_key(sid) { refs.insert("text".into(), json!(sid)); - } else if self.layer.iter().any(|(id, _, _)| id == sid) { + } else if self.layer_index.contains_key(sid) { for slot in ["fill", "stroke", "effect"] { let id = format!("{sid}:{slot}"); if cx_defs.contains(&id) { diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index ee428c88..b1b2801b 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -283,3 +283,15 @@ fn masked_icon_keeps_its_geometry() { assert!((b.x0 - 164.0).abs() < 1e-6 && (b.y0 - 41.0).abs() < 1e-6 && (b.width() - 45.0).abs() < 1e-6, "body {b:?}"); assert!(matches!(node(&doc, "Circle").kind, NodeKind::Ellipse { .. })); } + +#[test] +fn two_styled_masks_each_draw() { + let fill = json!([solid_fill(0.5, 0.5, 0.5, 1.0)]); + let m1 = styled(with(r("Mask1"), "hasClippingMask", json!(true)), "fills", fill.clone()); + let m2 = styled(with(rect("Mask2", 200.0, 0.0, 100.0, 50.0), "hasClippingMask", json!(true)), "fills", fill); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 300.0, 50.0, vec![m1, r("A"), m2, rect("B", 200.0, 0.0, 10.0, 10.0)])]); + // The drawn copy of the second mask isn't inside the first mask's group. + let copy = doc.nodes().find(|n| n.name == "Mask2 (drawn)").unwrap(); + let first_group = node(&doc, "Mask1").parent.unwrap(); + assert_ne!(copy.parent, Some(first_group)); +} diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index d25a6b56..b87b7138 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -14,8 +14,11 @@ use std::collections::BTreeMap; use figcraft_doc::geom::Affine; use serde_json::{Map as JsonMap, Value, json}; +use super::shared::Shared; use super::tree::{Cx, base, bool_field, children, local, size, str_field}; +/// Work units an override handed on to a nested instance costs (the JSON values it adds). +const OVERRIDE_COST: u64 = 8; /// Most masters read from one file. const MAX_MASTERS: usize = 100_000; @@ -23,6 +26,8 @@ const MAX_MASTERS: usize = 100_000; #[derive(Default)] pub(crate) struct Symbols { masters: BTreeMap, + /// What expanding each master costs (see [`weight`]). + weights: BTreeMap, /// Masters copied from libraries that no page holds (they get a page of their own), with the /// libraries' names. pub foreign: Vec, @@ -61,8 +66,11 @@ impl Symbols { if self.masters.len() >= MAX_MASTERS { return; } - if let Some(id) = str_field(&m, "symbolID") { - self.masters.entry(id.to_string()).or_insert(m); + if let Some(id) = str_field(&m, "symbolID") + && !self.masters.contains_key(id) + { + self.weights.insert(id.to_string(), weight(&m, 0)); + self.masters.insert(id.to_string(), m); } } @@ -71,6 +79,19 @@ impl Symbols { } } +/// What copying and mapping `v` costs, in work units: one per JSON value, one per 8 bytes of text. +fn weight(v: &Value, depth: usize) -> u64 { + if depth > 256 { + return 1; + } + match v { + Value::String(s) => 1 + s.len() as u64 / 8, + Value::Array(a) => 1 + a.iter().map(|x| weight(x, depth + 1)).sum::(), + Value::Object(o) => 1 + o.iter().map(|(k, x)| k.len() as u64 / 8 + weight(x, depth + 1)).sum::(), + _ => 1, + } +} + fn masters_in<'v>(l: &'v Value, out: &mut Vec<&'v Value>, depth: usize) { if depth > 64 { return; @@ -117,6 +138,12 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo let (w, h) = size(l); let world = parent * local(l, w, h); let sid = str_field(l, "symbolID").unwrap_or(""); + let cost = cx.symbols.weights.get(sid).copied().unwrap_or(0); + if cx.symbols.master(sid).is_some() && !cx.spend(cost) { + let mut node = base(l, "FRAME", parent, world, w, h, root); + node.insert("children".into(), json!([])); + return Some(Value::Object(node)); + } let Some(m) = cx.symbols.master(sid).cloned() else { cx.warn("instances of symbols that aren't in the file were imported as empty frames"); let mut node = base(l, "FRAME", parent, world, w, h, root); @@ -144,9 +171,17 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo // An instance shows its master's grids (else they'd differ, and count as an override). super::proto::layout_grids(&m, h, &mut node); let overrides = overrides(l); + let nested = nested(&overrides); let mut copy = m; + let mut budget = cx.work_left(); if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { - apply(layers, &overrides, cx.document, 0); + apply(layers, &overrides, &nested, &cx.shared, 0, &mut budget); + } + let used = cx.work_left().saturating_sub(budget); + cx.spend(used); + if budget == 0 { + // The budget ran out handing overrides on: noted. + cx.spend(1); } let (mw, mh) = size(©); cx.chain.push(sid.to_string()); @@ -172,9 +207,30 @@ fn overrides(l: &Value) -> BTreeMap { .collect() } +/// The overrides of layers inside nested instances, by the nested instance's id: `X/rest_prop` → +/// `X` → [(`rest_prop`, value)]. +fn nested(ov: &BTreeMap) -> BTreeMap<&str, Vec<(&str, &Value)>> { + let mut out: BTreeMap<&str, Vec<(&str, &Value)>> = BTreeMap::new(); + for (k, v) in ov { + if let Some((first, rest)) = k.split_once('/') { + out.entry(first).or_default().push((rest, v)); + } + } + out +} + /// Apply `ov` to the layers of a master's copy: to the layers it names, and passed on to nested /// instances for the layers inside them (an outer override wins over the nested instance's own). -fn apply(layers: &mut [Value], ov: &BTreeMap, document: &Value, depth: usize) { +/// Each override handed to a nested instance costs [`OVERRIDE_COST`] units of `budget`; once it is spent, the +/// rest aren't handed on. +fn apply( + layers: &mut [Value], + ov: &BTreeMap, + nested: &BTreeMap<&str, Vec<(&str, &Value)>>, + shared: &Shared, + depth: usize, + budget: &mut u64, +) { if depth > 64 || ov.is_empty() { return; } @@ -188,14 +244,12 @@ fn apply(layers: &mut [Value], ov: &BTreeMap, document: &Value, d Some(other) => l["symbolID"] = json!(other), None => {} } - let prefix = format!("{id}/"); - let inner: Vec = ov - .iter() - .filter_map(|(k, v)| k.strip_prefix(&prefix).map(|rest| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))) - .collect(); - if !inner.is_empty() { + let inner = nested.get(id.as_str()).map(Vec::as_slice).unwrap_or_default(); + let take = usize::try_from(*budget / OVERRIDE_COST).unwrap_or(usize::MAX).min(inner.len()); + *budget -= take as u64 * OVERRIDE_COST; + if take > 0 { let mut own = l.get("overrideValues").and_then(Value::as_array).cloned().unwrap_or_default(); - own.extend(inner); + own.extend(inner.iter().take(take).map(|(rest, v)| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))); l["overrideValues"] = Value::Array(own); } continue; @@ -212,24 +266,29 @@ fn apply(layers: &mut [Value], ov: &BTreeMap, document: &Value, d } _ => {} } - for (prop, list) in [("layerStyle", "layerStyles"), ("textStyle", "layerTextStyles")] { + for (prop, text) in [("layerStyle", false), ("textStyle", true)] { let sid = get(prop).and_then(Value::as_str); - if let Some((sid, style)) = sid.and_then(|sid| Some((sid, shared_style(document, list, sid)?))) { - l["style"] = style.clone(); + // Shared styles that aren't objects are junk: left alone. + let Some((sid, style)) = sid.and_then(|sid| Some((sid, shared.style(text, sid).filter(|s| s.is_object())?))) else { continue }; + let mut style = style.clone(); + if let Some(m) = style.as_object_mut() { // The layer now uses that shared style: it links to it. - l["style"]["sharedObjectID"] = json!(sid); - if prop == "textStyle" - && let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) - && let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) - { - for r in runs { - r["attributes"] = enc.clone(); - } + m.insert("sharedObjectID".into(), json!(sid)); + } + if text + && let Some(enc) = style.get("textStyle").and_then(|t| t.get("encodedAttributes")) + && let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) + { + for r in runs.iter_mut().filter_map(Value::as_object_mut) { + r.insert("attributes".into(), enc.clone()); } } + if let Some(m) = l.as_object_mut() { + m.insert("style".into(), style); + } } if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { - apply(kids, ov, document, depth + 1); + apply(kids, ov, nested, shared, depth + 1, budget); } } } @@ -241,28 +300,16 @@ fn set_string(l: &mut Value, s: &str) { let Some(a) = l.get_mut("attributedString").and_then(Value::as_object_mut) else { return }; a.insert("string".into(), json!(s)); let runs = match first { - Some(mut r) => { - r["location"] = json!(0); - r["length"] = json!(units); - vec![r] + Some(Value::Object(mut r)) => { + r.insert("location".into(), json!(0)); + r.insert("length".into(), json!(units)); + vec![Value::Object(r)] } - None => Vec::new(), + _ => Vec::new(), }; a.insert("attributes".into(), Value::Array(runs)); } -/// The style of shared style `id` from the document's `list` (local) or its library copies. -pub(crate) fn shared_style<'v>(document: &'v Value, list: &str, id: &str) -> Option<&'v Value> { - let local = document.get(list).and_then(|c| c.get("objects")).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); - let foreign_key = if list == "layerStyles" { "foreignLayerStyles" } else { "foreignTextStyles" }; - let foreign = document.get(foreign_key).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); - local - .iter() - .find(|s| str_field(s, "do_objectID") == Some(id)) - .or_else(|| foreign.iter().filter_map(|f| f.get("localSharedStyle")).find(|s| str_field(s, "do_objectID") == Some(id))) - .and_then(|s| s.get("value")) -} - /// Make the ids of an instance's layers its own (`;`): the master's layers keep /// theirs. fn prefix_ids(v: &mut Value, prefix: &str, depth: usize) { diff --git a/crates/format/src/sketch/symbols_tests.rs b/crates/format/src/sketch/symbols_tests.rs index e2204399..4c98ebe2 100644 --- a/crates/format/src/sketch/symbols_tests.rs +++ b/crates/format/src/sketch/symbols_tests.rs @@ -163,3 +163,57 @@ fn resized_instance_follows_constraints() { let b = doc.world_bounds(tag.id).unwrap(); assert!((b.x0 - 180.0).abs() < 1e-6 && (b.width() - 20.0).abs() < 1e-6, "{b:?}"); } + +/// Overrides pointing at junk (a shared style that isn't an object, text runs that aren't) never +/// panic. +#[test] +fn override_application_survives_junk() { + let mut d = document(&["P", "S"]); + d["layerStyles"]["objects"] = json!([{"_class": "sharedStyle", "do_objectID": "S1", "name": "Junk", "value": 5}]); + d["layerTextStyles"]["objects"] = + json!([{"_class": "sharedStyle", "do_objectID": "T1", "name": "Junk text", "value": {"textStyle": {"encodedAttributes": {}}}}]); + let mut t = text("Label", 0.0, 0.0, 50.0, 20.0, "x", &[(0, 1, "Helvetica", 9.0, BLACK)]); + t["attributedString"]["attributes"] = json!([5, "x", null]); + let m = master("M", "S", "Junk master", 50.0, 20.0, vec![rect("C", 0.0, 0.0, 10.0, 10.0), t]); + let inst = instance( + "I1", + "S", + 0.0, + 0.0, + 50.0, + 20.0, + &[("C_layerStyle", json!("S1")), ("Label_textStyle", json!("T1")), ("Label_stringValue", json!("y"))], + ); + let z = sketch_zip("50.2", 103, d, &[page("P", "Page", vec![inst]), page("S", "Symbols", vec![m])], &[]); + assert!(import(&z, "f").is_ok()); +} + +/// A huge master expanded by many instances stops at the file's work budget. +#[test] +fn expansion_work_is_bounded() { + let long = "x".repeat(200_000); + let m = master("M", "S", "Heavy", 50.0, 20.0, vec![text("Long", 0.0, 0.0, 50.0, 20.0, &long, &[(0, 200_000, "Helvetica", 9.0, BLACK)])]); + let many: Vec = (0..3000).map(|i| instance(&format!("I{i}"), "S", 0.0, 0.0, 50.0, 20.0, &[])).collect(); + let z = sketch_zip("50.2", 103, document(&["P", "S"]), &[page("P", "Page", many), page("S", "Symbols", vec![m])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(warnings.iter().any(|w| w.contains("expand")), "{warnings:?}"); +} + +/// Outer overrides fanned out to many nested instances stop at the work budget. +#[test] +fn override_fan_out_is_bounded() { + let inner = master("MB", "SB", "Cell", 10.0, 10.0, vec![text("T", 0.0, 0.0, 10.0, 10.0, "1", &[(0, 1, "Helvetica", 9.0, BLACK)])]); + let cells: Vec = (0..3000).map(|_| instance("X", "SB", 0.0, 0.0, 10.0, 10.0, &[])).collect(); + let grid = master("MA", "SA", "Grid", 100.0, 100.0, cells); + let overrides: Vec<(String, Value)> = (0..3000).map(|i| (format!("X/N{i}_stringValue"), json!("v"))).collect(); + let refs: Vec<(&str, Value)> = overrides.iter().map(|(k, v)| (k.as_str(), v.clone())).collect(); + let z = sketch_zip( + "50.2", + 103, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "SA", 0.0, 0.0, 100.0, 100.0, &refs)]), page("S", "Symbols", vec![inner, grid])], + &[], + ); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(warnings.iter().any(|w| w.contains("expand")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index d63afa0f..c1bf0432 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -6,6 +6,16 @@ use serde_json::{Map as JsonMap, Value, json}; use super::Archive; +/// Most distinct notes kept. +const MAX_NOTES: usize = 200; +/// Work units symbol expansion may spend on one file (one per JSON value copied, see +/// `symbols::weight`): about three times what the largest design kit checked spends (21M, with +/// 2 GB at its peak), well below what runs out of memory; less on 32-bit targets (the web), where +/// such a kit keeps its first instances and notes the rest. +#[cfg(not(target_pointer_width = "32"))] +const MAX_WORK: u64 = 60_000_000; +#[cfg(target_pointer_width = "32")] +const MAX_WORK: u64 = 15_000_000; /// Deepest layer nesting followed (deeper layers are skipped with a warning). const MAX_DEPTH: usize = 200; /// Largest size and farthest position kept (the REST importer's own bounds). @@ -23,13 +33,16 @@ pub(crate) struct Map { } /// The mapping's state while walking the file. -pub(crate) struct Cx<'a> { - /// `document.json`: shared styles and library copies. - pub document: &'a Value, +pub(crate) struct Cx { pub symbols: super::symbols::Symbols, pub shared: super::shared::Shared, /// Ids of the styles made from the shared styles. - pub style_defs: Vec, + pub style_defs: std::collections::BTreeSet, + /// Work spent expanding symbols, and the most the file may take (see `symbols::weight`). + pub work: u64, + pub max_work: u64, + /// Notes given so far (each once). + pub seen: std::collections::BTreeSet, /// The symbols being expanded, outermost first (a symbol inside itself stops there). pub chain: Vec, pub warnings: Vec, @@ -40,13 +53,33 @@ pub(crate) struct Cx<'a> { pub max_nodes: usize, } -impl Cx<'_> { +impl Cx { pub(crate) fn warn(&mut self, msg: impl Into) { let msg = msg.into(); - if !self.warnings.contains(&msg) { + if self.seen.len() > MAX_NOTES || !self.seen.insert(msg.clone()) { + return; + } + if self.seen.len() > MAX_NOTES { + self.warnings.push("more notes were left out".into()); + } else { self.warnings.push(msg); } } + + /// Spend `n` units of the work budget; false (with a note, once) when it would run out. + pub(crate) fn spend(&mut self, n: u64) -> bool { + if self.work.saturating_add(n) > self.max_work { + self.work = self.max_work; + self.warn("the file's symbols expand to more than the import handles; the instances past that were imported as empty frames"); + return false; + } + self.work += n; + true + } + + pub(crate) fn work_left(&self) -> u64 { + self.max_work.saturating_sub(self.work) + } } /// Map the pages of `archive` to a REST file JSON named `name`. @@ -54,10 +87,12 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); let shared = super::shared::Shared::read(&archive.document); let mut cx = Cx { - document: &archive.document, symbols, shared, - style_defs: Vec::new(), + style_defs: std::collections::BTreeSet::new(), + work: 0, + max_work: MAX_WORK, + seen: std::collections::BTreeSet::new(), chain: Vec::new(), warnings: Vec::new(), networks: Vec::new(), @@ -319,7 +354,8 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), .flat_map(|(breaks, n)| match drawn_mask(&n) { // Sketch also draws a mask with its own style, under what it clips; a FigCraft mask // only clips, so a plain copy of it goes underneath. - Some(copy) => vec![(false, copy), (breaks, n)], + // The copy ends an earlier mask's chain as the mask itself would. + Some(copy) => vec![(breaks, copy), (breaks, n)], None => vec![(breaks, n)], }) .collect(); diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs index c9f99558..91aeba3a 100644 --- a/crates/format/src/sketch/tree_tests.rs +++ b/crates/format/src/sketch/tree_tests.rs @@ -179,3 +179,55 @@ fn sketch_2025_frames_and_graphics() { assert!(!node(&doc, "Frame").clip); assert!(matches!(node(&doc, "Graphic").kind, NodeKind::Group)); } + +#[test] +fn many_distinct_warnings_are_capped() { + let layers: Vec = (0..5000) + .map(|i| { + let mut l = rect(&format!("R{i}"), 0.0, 0.0, 1.0, 1.0); + l["_class"] = json!(format!("MSOdd{i}")); + l + }) + .collect(); + let (_, warnings) = open(vec![artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, layers)]); + assert!(warnings.len() <= 250, "{}", warnings.len()); +} + +#[test] +fn bomb_pages_are_skipped() { + // 64 MB of `[0,0,…]` packs into a few hundred kilobytes. + let bomb = format!("{{\"_class\":\"page\",\"do_objectID\":\"B\",\"name\":\"Bomb\",\"layers\":[],\"junk\":[{}0]}}", "0,".repeat(32 * 1024 * 1024)); + let mut out = Vec::new(); + { + use std::io::Write; + let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); + let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("document.json", o).unwrap(); + z.write_all(document(&["B", "P"]).to_string().as_bytes()).unwrap(); + z.start_file("meta.json", o).unwrap(); + z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": "50.2", "version": 103}).to_string().as_bytes()).unwrap(); + z.start_file("pages/B.json", o).unwrap(); + z.write_all(bomb.as_bytes()).unwrap(); + z.start_file("pages/P.json", o).unwrap(); + z.write_all(page("P", "Fine", vec![]).to_string().as_bytes()).unwrap(); + z.finish().unwrap(); + } + let (doc, warnings) = import(&out, "f").unwrap(); + let names: Vec = doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect(); + assert!(!names.contains(&"Bomb".to_string()) && names.contains(&"Fine".to_string()), "{names:?}"); + assert!(warnings.iter().any(|w| w.contains("too large")), "{warnings:?}"); +} + +#[test] +fn deep_document_json_still_opens_the_pages() { + let mut deep = json!({"_class": "symbolMaster"}); + for _ in 0..150 { + deep = json!({"_class": "group", "layers": [deep]}); + } + let mut d = document(&["P"]); + d["foreignSymbols"] = json!([{"symbolMaster": deep}]); + let z = sketch_zip("50.2", 103, d, &[page("P", "Kept", vec![])], &[]); + let (doc, warnings) = import(&z, "f").unwrap(); + assert!(doc.pages.iter().any(|p| doc.node(*p).unwrap().name == "Kept")); + assert!(!warnings.is_empty()); +} From 5b9e18317d25736cf89523ce0505c41ccfd3da1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 18:05:36 +0200 Subject: [PATCH 046/208] M6: Sketch import: match Sketch's own rendering Compared artboard by artboard with Sketch's renders of a real Sketch 50 file (sketchtool, black-box). Shape groups whose children have no operation are one compound path filled by the group's winding rule (read from the layer), not a union: rings, map streets and icons keep their holes. Filled open paths close, as Sketch fills them. Flips apply after rotation. Text with a fixed line height sits where Sketch's line spacing behaviour puts it. Edited ovals and rectangles keep their points. Bitmap colour adjustments become image filters. scale 0 means unscaled. A local test compares sample files with sketchtool's renders. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sketch_import_tests.rs | 85 +++++++++ crates/format/src/sketch/path.rs | 199 +++++++++++++++++----- crates/format/src/sketch/path_tests.rs | 68 ++++++++ crates/format/src/sketch/style.rs | 15 ++ crates/format/src/sketch/style_tests.rs | 19 +++ crates/format/src/sketch/symbols.rs | 3 +- crates/format/src/sketch/symbols_tests.rs | 9 + crates/format/src/sketch/text.rs | 23 +++ crates/format/src/sketch/text_tests.rs | 20 +++ crates/format/src/sketch/tree.rs | 23 ++- crates/format/src/sketch/tree_tests.rs | 25 +++ docs/file-format-parity.md | 2 +- 12 files changed, 446 insertions(+), 45 deletions(-) diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index c1ac6a59..cee351b8 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -219,3 +219,88 @@ fn local_sketch_samples_open() { } } } + +/// Local only (`FIGCRAFT_SKETCH_SAMPLES` and `FIGCRAFT_SKETCHTOOL`, the path of Sketch's +/// `sketchtool`): every artboard of each sample (Sketch 48 and newer) drawn by FigCraft is compared +/// with Sketch's own export of it. Prints the share of pixels that differ per artboard (a channel +/// off by more than 40, on white); fails past 10% (a broken artboard, not antialiasing or fonts). +#[test] +fn local_sketch_samples_match_sketch() { + let (Ok(dir), Ok(tool)) = (std::env::var("FIGCRAFT_SKETCH_SAMPLES"), std::env::var("FIGCRAFT_SKETCHTOOL")) else { return }; + // Text draws in the fonts Sketch uses only once the system fonts are known. + figcraft_text::fontdb::scan_system_fonts(); + let mut files: Vec = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect(); + files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch"))); + files.sort(); + let mut worst: Vec<(f64, String)> = Vec::new(); + for path in files { + let mut s = Session::new(); + if s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err() { + continue; + } + let out = std::env::temp_dir().join(format!("figcraft-sketch-ref-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + let status = std::process::Command::new(&tool) + .args(["export", "artboards"]) + .arg(&path) + .arg(format!("--output={}", out.display())) + .args(["--formats=png", "--scales=1", "--use-id-for-name=YES", "--include-symbols=NO"]) + .output() + .unwrap(); + assert!(status.status.success(), "sketchtool failed on {}", path.display()); + // Artboards by page and order, from the file itself. + let archive = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap(); + let mut archive = archive; + let mut read = |name: &str| -> Value { + let mut s = String::new(); + std::io::Read::read_to_string(&mut archive.by_name(name).unwrap(), &mut s).unwrap(); + serde_json::from_str(&s).unwrap() + }; + let document = read("document.json"); + let doc = s.doc().unwrap(); + let refs: Vec = document["pages"].as_array().unwrap().iter().map(|r| r["_ref"].as_str().unwrap().to_string()).collect(); + for (page_ref, page_id) in refs.iter().zip(doc.pages.iter()) { + let page = read(&format!("{page_ref}.json")); + let boards: Vec = page["layers"] + .as_array() + .unwrap() + .iter() + .filter(|l| l["_class"] == "artboard") + .map(|l| l["do_objectID"].as_str().unwrap().to_string()) + .collect(); + let frames: Vec = + doc.node(*page_id).unwrap().children.iter().copied().filter(|c| matches!(doc.node(*c).unwrap().kind, NodeKind::Frame)).collect(); + assert_eq!(boards.len(), frames.len(), "{}: artboards of {page_ref}", path.display()); + for (id, frame) in boards.iter().zip(frames) { + let Ok(reference) = image::open(out.join(format!("{id}.png"))) else { continue }; + let reference = reference.to_rgba8(); + let ours = figcraft_render::render_node(doc, frame, 1.0).unwrap(); + let (w, h) = (reference.width().min(ours.width()), reference.height().min(ours.height())); + let ours_rgba = figcraft_render::to_rgba8(&ours); + // Both on white, straight alpha. + let on_white = |c: &[u8], i: usize| { + let a = f64::from(c[3]) / 255.0; + f64::from(c[i]) * a + 255.0 * (1.0 - a) + }; + let mut bad = 0u64; + for y in 0..h { + for x in 0..w { + let r = reference.get_pixel(x, y).0; + let at = ((y * ours.width() + x) * 4) as usize; + let Some(o) = ours_rgba.get(at..at + 4) else { continue }; + if (0..3).any(|i| (on_white(&r, i) - on_white(o, i)).abs() > 40.0) { + bad += 1; + } + } + } + let share = bad as f64 * 100.0 / (f64::from(w) * f64::from(h)).max(1.0); + let name = doc.node(frame).unwrap().name.clone(); + eprintln!("{share:6.2}% {} {name}", path.file_name().unwrap().to_string_lossy()); + worst.push((share, format!("{}: {name}", path.display()))); + } + } + let _ = std::fs::remove_dir_all(&out); + } + worst.sort_by(|a, b| b.0.total_cmp(&a.0)); + assert!(worst.first().is_none_or(|w| w.0 < 10.0), "{worst:?}"); +} diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 30a9f256..149b4870 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -4,7 +4,10 @@ //! and `curveTo` (the handle arriving at it) are `"{x, y}"` strings in 0–1 of the layer's frame; //! values outside that range are allowed. Sketch 51 and older wrap every shape in a `shapeGroup` //! that carries the style; its children combine left to right, each with its own -//! `booleanOperation` (−1 none and 0 union, 1 subtract, 2 intersect, 3 difference). +//! `booleanOperation` (0 union, 1 subtract, 2 intersect, 3 difference). Children with −1 (none) +//! don't combine: they join the path before them into one compound path, filled by the winding +//! rule (`windingRule` on the layer, 1 even-odd: inner paths are holes). Sketch fills an open path +//! as if it were closed. use figcraft_doc::geom::{Affine, polygon_points, star_points}; use figcraft_doc::{Region, Segment, VectorNetwork, Vertex, WindingRule}; @@ -32,8 +35,26 @@ pub(crate) fn parse_point(s: &str) -> Option<(f64, f64)> { Some((n(x)?, n(y)?)) } -/// The network of a path layer of `w`×`h` (`None` when it has no usable points). -pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option { +/// A layer's winding rule: on the layer (Sketch 51 and older), else in its style. +pub(crate) fn winding(l: &Value) -> WindingRule { + let rule = l.get("windingRule").or_else(|| l.get("style").and_then(|s| s.get("windingRule"))); + match rule.and_then(Value::as_i64) { + Some(1) => WindingRule::EvenOdd, + _ => WindingRule::NonZero, + } +} + +/// Whether a style shows any fill or border. +fn shows(style: Option<&Value>, key: &str) -> bool { + style + .and_then(|s| s.get(key)) + .and_then(Value::as_array) + .is_some_and(|l| l.iter().any(|f| f.get("isEnabled").and_then(Value::as_bool) != Some(false))) +} + +/// The network of a path layer of `w`×`h` (`None` when it has no usable points). `fill_open`: an +/// open path gets a closing segment and a fill region, as Sketch fills it. +pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) -> Option { let pts = l.get("points").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); if pts.len() > MAX_POINTS { cx.warn(format!("paths with more than {MAX_POINTS} points were cut")); @@ -61,20 +82,20 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option 1; + let open = !l.get("isClosed").and_then(Value::as_bool).unwrap_or(false); + let closed = n > 1 && (!open || (fill_open && n > 2)); let count = if closed { n } else { n - 1 }; let segments: Vec = (0..count) .map(|i| { let j = (i + 1) % n; - let ta = handles.get(i).map_or((0.0, 0.0), |h| h.0); - let tb = handles.get(j).map_or((0.0, 0.0), |h| h.1); + // The edge that closes an open path for its fill is straight. + let closing = open && j == 0; + let ta = if closing { (0.0, 0.0) } else { handles.get(i).map_or((0.0, 0.0), |h| h.0) }; + let tb = if closing { (0.0, 0.0) } else { handles.get(j).map_or((0.0, 0.0), |h| h.1) }; Segment { a: i, b: j, ta, tb } }) .collect(); - let winding = match l.get("style").and_then(|s| s.get("windingRule")).and_then(Value::as_i64) { - Some(1) => WindingRule::EvenOdd, - _ => WindingRule::NonZero, - }; + let winding = winding(l); let regions = if closed { vec![Region { loops: vec![(0..segments.len()).collect()], winding }] } else { Vec::new() }; Some(VectorNetwork { vertices, segments, regions }) } @@ -82,7 +103,14 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option Option { - let net = network(cx, l, w, h)?; + // Inside a shape group the group's fill decides; a shape of its own, its style. + let style = l.get("style"); + let filled = cx.operand_fill.unwrap_or_else(|| shows(style, "fills")); + let open = !l.get("isClosed").and_then(Value::as_bool).unwrap_or(false); + if open && filled && (shows(style, "borders") || cx.operand_border) { + cx.warn("open paths with both a fill and a border were closed; the border runs along the closing edge too"); + } + let net = network(cx, l, w, h, filled)?; let class = l.get("_class").and_then(Value::as_str).unwrap_or(""); let count = l.get("numberOfPoints").and_then(Value::as_u64).filter(|c| (3..=120).contains(c)).and_then(|c| u32::try_from(c).ok()); let plain = net.vertices.iter().all(|v| v.radius == 0.0) && net.segments.iter().all(|s| s.ta == (0.0, 0.0) && s.tb == (0.0, 0.0)); @@ -123,41 +151,98 @@ pub(crate) fn put_shape(cx: &mut Cx, shape: Shape, node: &mut JsonMap Option { let (w, h) = size(l); let world = parent * local(l, w, h); let kids = layers(l); - if let [only] = kids { - let mut node = layer(cx, only, world, depth + 1, false)?; - let (cw, ch) = size(only); - let child_world = world * local(only, cw, ch); - let group = base(l, "GROUP", parent, child_world, cw, ch, root); - if let Some(m) = node.as_object_mut() { - for key in ["id", "name", "relativeTransform", "absoluteBoundingBox", "constraints", "locked"] { - match group.get(key) { - Some(v) => m.insert(key.into(), v.clone()), - None => m.remove(key), - }; - } - let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); - m.insert("visible".into(), json!(visible)); - if l.get("style").is_some() { - super::style::clear(m); - m.remove("styles"); - super::tree::styles(cx, l, cw, ch, m); - super::tree::extras(cx, l, ch, m); - } - super::tree::mask(l, m); + let saved = (cx.operand_fill, cx.operand_border); + cx.operand_border = shows(l.get("style"), "borders"); + let out = if let [only] = kids { + cx.operand_fill = Some(shows(l.get("style"), "fills")); + single(cx, l, only, parent, world, depth, root) + } else { + // Operands of a boolean operation (or a compound path) give area: open ones close. + cx.operand_fill = Some(true); + combined(cx, l, kids, parent, world, (w, h), depth, root) + }; + (cx.operand_fill, cx.operand_border) = saved; + out +} + +/// A shape group of one shape: that shape in the group's place, with its name and style. +fn single(cx: &mut Cx, l: &Value, only: &Value, parent: Affine, world: Affine, depth: usize, root: bool) -> Option { + let mut node = layer(cx, only, world, depth + 1, false)?; + let (cw, ch) = size(only); + let child_world = world * local(only, cw, ch); + let group = base(l, "GROUP", parent, child_world, cw, ch, root); + if let Some(m) = node.as_object_mut() { + for key in ["id", "name", "relativeTransform", "absoluteBoundingBox", "constraints", "locked"] { + match group.get(key) { + Some(v) => m.insert(key.into(), v.clone()), + None => m.remove(key), + }; } - return Some(node); + let visible = group.get("visible").and_then(Value::as_bool).unwrap_or(true) && m.get("visible").and_then(Value::as_bool).unwrap_or(true); + m.insert("visible".into(), json!(visible)); + if l.get("style").is_some() { + super::style::clear(m); + m.remove("styles"); + super::tree::styles(cx, l, cw, ch, m); + super::tree::extras(cx, l, ch, m); + } + super::tree::mask(l, m); + } + Some(node) +} + +/// A shape group of several shapes: one compound path, or boolean operations over runs. +fn combined(cx: &mut Cx, l: &Value, kids: &[Value], parent: Affine, world: Affine, (w, h): (f64, f64), depth: usize, root: bool) -> Option { + // Runs: a child with an operation starts one, children without (−1) join it. + let mut runs: Vec> = Vec::new(); + for (i, c) in kids.iter().enumerate() { + match runs.last_mut() { + Some(run) if i > 0 && c.get("booleanOperation").and_then(Value::as_i64).unwrap_or(-1) < 0 => run.push(c), + _ => runs.push(vec![c]), + } + } + let winding = winding(l); + if let [run] = runs.as_slice() + && let Some(net) = compound(cx, run, winding) + { + // No operations at all: the group is one path. + let mut node = base(l, "VECTOR", parent, world, w, h, root); + node.insert("vectorNetworkRef".into(), json!(cx.networks.len())); + cx.networks.push(Some(net)); + super::tree::mask(l, &mut node); + super::tree::styles(cx, l, w, h, &mut node); + super::tree::extras(cx, l, h, &mut node); + return Some(Value::Object(node)); } let mut chain: Vec = Vec::new(); let mut op = "UNION"; - for (i, c) in kids.iter().enumerate() { - let Some(child) = layer(cx, c, world, depth + 2, false) else { continue }; - let this = boolean_op(c); + for (i, run) in runs.iter().enumerate() { + let operands: Vec = match run.as_slice() { + [one] => layer(cx, one, world, depth + 2, false).into_iter().collect(), + more => match compound(cx, more, winding) { + Some(net) => { + let mut node = base(more.first().copied().unwrap_or(l), "VECTOR", world, world, w, h, false); + node.remove("id"); + node.insert("vectorNetworkRef".into(), json!(cx.networks.len())); + cx.networks.push(Some(net)); + vec![Value::Object(node)] + } + // Not all paths: each one an operand of its own. + None => more.iter().filter_map(|c| layer(cx, c, world, depth + 2, false)).collect(), + }, + }; + if operands.is_empty() { + continue; + } + let this = run.first().map_or("UNION", |c| boolean_op(c)); if i > 0 && this != op && chain.len() > 1 { let inner = synthetic(l, op, parent, world, w, h, std::mem::take(&mut chain)); chain.push(inner); @@ -165,7 +250,7 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, if i > 0 { op = this; } - chain.push(child); + chain.extend(operands); } if chain.is_empty() { return None; @@ -179,6 +264,42 @@ pub(crate) fn shape_group(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, Some(Value::Object(node)) } +/// The paths `run` as one network in their group's space, filled by `winding`; `None` when one of +/// them isn't a path (a group, text, …). +fn compound(cx: &mut Cx, run: &[&Value], winding: WindingRule) -> Option { + const PATHS: [&str; 6] = ["shapePath", "rectangle", "oval", "star", "polygon", "triangle"]; + if run.iter().any(|c| !c.get("_class").and_then(Value::as_str).is_some_and(|k| PATHS.contains(&k))) { + return None; + } + let mut out = VectorNetwork::default(); + let mut loops = Vec::new(); + for c in run { + let (cw, ch) = size(c); + let Some(net) = network(cx, c, cw, ch, true) else { continue }; + let xf = local(c, cw, ch); + let [a, b, cc, d, _, _] = xf.as_coeffs(); + let lin = |(x, y): (f64, f64)| (a * x + cc * y, b * x + d * y); + let (v0, s0) = (out.vertices.len(), out.segments.len()); + for v in net.vertices { + let p = xf * figcraft_doc::geom::Point::new(v.x, v.y); + out.vertices.push(Vertex { x: p.x, y: p.y, ..v }); + } + for sg in net.segments { + out.segments.push(Segment { a: sg.a + v0, b: sg.b + v0, ta: lin(sg.ta), tb: lin(sg.tb) }); + } + for r in net.regions { + loops.extend(r.loops.into_iter().map(|lp| lp.into_iter().map(|i| i + s0).collect::>())); + } + } + if out.vertices.is_empty() { + return None; + } + if !loops.is_empty() { + out.regions.push(Region { loops, winding }); + } + Some(out) +} + /// A boolean operation the shape group doesn't have as a layer of its own: placed like the group. fn synthetic(l: &Value, op: &str, parent: Affine, world: Affine, w: f64, h: f64, children: Vec) -> Value { let mut node = base(l, "BOOLEAN_OPERATION", parent, world, w, h, false); diff --git a/crates/format/src/sketch/path_tests.rs b/crates/format/src/sketch/path_tests.rs index fb03d74c..70ad9527 100644 --- a/crates/format/src/sketch/path_tests.rs +++ b/crates/format/src/sketch/path_tests.rs @@ -165,3 +165,71 @@ fn huge_points_stay_finite() { assert!(network.segments.iter().all(|s| [s.ta.0, s.ta.1, s.tb.0, s.tb.1].iter().all(|c| c.is_finite()))); } } + +/// Sketch 51 and older: a shape group whose children have no operation (−1) is one compound path, +/// filled by the group's winding rule (stored on the layer itself). +#[test] +fn compound_shape_group_has_holes() { + let outer = with_op(rect("Outer", 0.0, 0.0, 100.0, 100.0), -1); + let inner = with_op(rect("Inner", 25.0, 25.0, 50.0, 50.0), -1); + let mut g = shape_group("Ring", 10.0, 10.0, 100.0, 100.0, vec![outer, inner]); + g["windingRule"] = json!(1); + let doc = open(vec![g]); + let n = node(&doc, "Ring"); + let NodeKind::Vector { network } = &n.kind else { panic!("not one vector: {:?}", n.kind) }; + assert_eq!(network.regions.len(), 1); + assert_eq!(network.regions[0].loops.len(), 2); + assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); + let b = doc.world_bounds(n.id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.0, 10.0, 100.0, 100.0)); +} + +#[test] +fn compound_runs_are_boolean_operands() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(rect("B", 60.0, 0.0, 20.0, 20.0), -1); + let c = with_op(rect("C", 10.0, 10.0, 10.0, 10.0), 1); + let doc = open(vec![shape_group("Mixed", 0.0, 0.0, 80.0, 50.0, vec![a, b, c])]); + let n = node(&doc, "Mixed"); + assert!(matches!(n.kind, NodeKind::BooleanOp { op: BoolOp::Subtract }), "{:?}", n.kind); + let first = doc.node(n.children[0]).unwrap(); + let NodeKind::Vector { network } = &first.kind else { panic!("first operand isn't the compound path") }; + assert_eq!(network.regions[0].loops.len(), 2); +} + +#[test] +fn layer_winding_rule_counts() { + let mut p = shape_path("Wound", 0.0, 0.0, 10.0, 10.0, &[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], true); + p["windingRule"] = json!(1); + let doc = open(vec![p]); + let NodeKind::Vector { network } = &node(&doc, "Wound").kind else { panic!("not a vector") }; + assert_eq!(network.regions[0].winding, figcraft_doc::WindingRule::EvenOdd); +} + +/// Sketch fills an open path as if it were closed. +#[test] +fn filled_open_paths_are_closed() { + let open_path = shape_path("Lid", 0.0, 0.0, 40.0, 20.0, &[(0.0, 1.0), (0.5, 0.0), (1.0, 1.0)], false); + let filled = styled(shape_group("Filled", 0.0, 0.0, 40.0, 20.0, vec![open_path.clone()]), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let stroked = styled( + shape_group("Stroked", 0.0, 50.0, 40.0, 20.0, vec![open_path]), + "borders", + json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": 0, "thickness": 1}]), + ); + let doc = open(vec![filled, stroked]); + let NodeKind::Vector { network } = &node(&doc, "Filled").kind else { panic!("not a vector") }; + assert_eq!(network.regions.len(), 1); + assert_eq!(network.segments.len(), 3); + let NodeKind::Vector { network } = &node(&doc, "Stroked").kind else { panic!("not a vector") }; + assert!(network.regions.is_empty()); + assert_eq!(network.segments.len(), 2); +} + +#[test] +fn open_operands_of_booleans_are_closed() { + let a = with_op(rect("A", 0.0, 0.0, 50.0, 50.0), -1); + let b = with_op(shape_path("Snout", 40.0, 10.0, 30.0, 20.0, &[(0.0, 0.0), (1.0, 0.5), (0.0, 1.0)], false), 0); + let doc = open(vec![styled(shape_group("Head", 0.0, 0.0, 70.0, 50.0, vec![a, b]), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)]))]); + let NodeKind::Vector { network } = &node(&doc, "Snout").kind else { panic!("not a vector") }; + assert_eq!(network.regions.len(), 1); +} diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index de2529aa..f13515a6 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -243,6 +243,21 @@ pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { out } +/// A bitmap's colour adjustments (`colorControls`: saturation and contrast 0–2 with 1 neutral, +/// brightness −1–1, hue in radians) as REST image filters; hue has no filter (a note). +pub(crate) fn color_controls(cx: &mut Cx, style: Option<&Value>) -> Option { + let c = style?.get("colorControls").filter(|c| c.get("isEnabled").and_then(Value::as_bool) == Some(true))?; + let get = |k: &str, neutral: f64| num(c, k).unwrap_or(neutral); + if get("hue", 0.0).abs() > 1e-6 { + cx.warn("hue changes of bitmaps were skipped"); + } + Some(json!({ + "saturation": (get("saturation", 1.0) - 1.0).clamp(-1.0, 1.0), + "contrast": (get("contrast", 1.0) - 1.0).clamp(-1.0, 1.0), + "exposure": get("brightness", 0.0).clamp(-1.0, 1.0), + })) +} + /// The variable binding of a colour that names a swatch the file has (`{color: alias}`). pub(crate) fn swatch(cx: &Cx, c: Option<&Value>) -> Option { let id = c?.get("swatchID").and_then(Value::as_str).filter(|id| cx.shared.has_swatch(id))?; diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index b1b2801b..242b0478 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -295,3 +295,22 @@ fn two_styled_masks_each_draw() { let first_group = node(&doc, "Mask1").parent.unwrap(); assert_ne!(copy.parent, Some(first_group)); } + +#[test] +fn bitmap_color_controls_become_filters() { + let png = { + let mut out = Vec::new(); + image::RgbaImage::new(2, 2).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + }; + let mut bitmap = r("Photo"); + bitmap["_class"] = json!("bitmap"); + bitmap["image"] = json!({"_class": "MSJSONFileReference", "_ref": "images/a.png"}); + bitmap["style"]["colorControls"] = + json!({"_class": "colorControls", "isEnabled": true, "brightness": 0, "contrast": 1, "hue": 0.5, "saturation": 0}); + let (doc, warnings) = open_doc(document(&["P"]), vec![bitmap], &[("images/a.png", &png)]); + let PaintKind::Image(img) = &node(&doc, "Photo").fills[0].kind else { panic!("no image") }; + assert_eq!(img.filters.saturation, -1.0); + assert_eq!(img.filters.contrast, 0.0); + assert!(warnings.iter().any(|w| w.contains("hue")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index b87b7138..cabc2fd4 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -155,7 +155,8 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo cx.warn(format!("symbol {name} contains itself; the inner copy was left out")); return None; } - if l.get("scale").and_then(Value::as_f64).is_some_and(|s| s.is_finite() && (s - 1.0).abs() > 1e-9) { + // Sketch 50 stores "not scaled" as 0. + if l.get("scale").and_then(Value::as_f64).is_some_and(|s| s.is_finite() && s != 0.0 && (s - 1.0).abs() > 1e-9) { cx.warn("scaled symbol instances were imported at their size, unscaled"); } let mut node = base(l, "INSTANCE", parent, world, w, h, root); diff --git a/crates/format/src/sketch/symbols_tests.rs b/crates/format/src/sketch/symbols_tests.rs index 4c98ebe2..64c7ae7f 100644 --- a/crates/format/src/sketch/symbols_tests.rs +++ b/crates/format/src/sketch/symbols_tests.rs @@ -217,3 +217,12 @@ fn override_fan_out_is_bounded() { let (_, warnings) = import(&z, "f").unwrap(); assert!(warnings.iter().any(|w| w.contains("expand")), "{warnings:?}"); } + +/// Sketch 50 stores "not scaled" as `scale: 0`. +#[test] +fn scale_zero_is_unscaled() { + let mut i = instance("I1", "S1", 0.0, 0.0, 100.0, 40.0, &[]); + i["scale"] = json!(0); + let (_, warnings) = open(vec![i], vec![button()]); + assert!(!warnings.iter().any(|w| w.contains("scaled")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 3cdb2afc..14357cfc 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -93,6 +93,29 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { } } +/// How far a text layer moves so its lines sit where Sketch draws them. With a fixed line height, +/// Sketch puts a line's extra height (line height − the font's own, taken as 1.2 × its size) above +/// the text (`lineSpacingBehaviour` 0) or below it (1); FigCraft, like Sketch's default (2), puts +/// half on each side. Every line gets the same extra, so the whole layer moves. +pub(crate) fn baseline_shift(l: &Value) -> f64 { + let first = l + .get("attributedString") + .and_then(|a| a.get("attributes")) + .and_then(Value::as_array) + .and_then(|r| r.first()) + .and_then(|r| r.get("attributes")); + let Some(a) = first else { return 0.0 }; + let size = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")).and_then(|f| f.get("size")).and_then(Value::as_f64); + let line = a.get("paragraphStyle").and_then(|p| p.get("maximumLineHeight").or_else(|| p.get("minimumLineHeight"))).and_then(Value::as_f64); + let (Some(size), Some(line)) = (size.filter(|s| s.is_finite() && *s > 0.0), line.filter(|v| v.is_finite() && *v > 0.0)) else { return 0.0 }; + let extra = (line - size * 1.2).clamp(-1e4, 1e4); + match l.get("lineSpacingBehaviour").and_then(Value::as_i64) { + Some(0) => extra / 2.0, + Some(1) => -extra / 2.0, + _ => 0.0, + } +} + /// A run's attributes as a REST type style (with its colour as `fills`). pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { let font = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")); diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index a7ee96b0..f6b96738 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -187,3 +187,23 @@ fn embedded_fonts_are_reported() { let w = warnings.iter().find(|w| w.contains("embedded")).expect("a note about embedded fonts"); assert!(w.contains("IBM Plex Mono") && w.contains("IBM Plex Sans"), "{w}"); } + +/// Where Sketch puts a line's extra height: above the text (`lineSpacingBehaviour` 0), below it +/// (1) or around it (2, like FigCraft); the text layer moves by the difference. +#[test] +fn line_spacing_behaviour_moves_text() { + let mk = |name: &str, beh: i64| { + let mut t = text(name, 0.0, 100.0, 100.0, 30.0, "Hg", &[(0, 2, "Helvetica", 14.0, BLACK)]); + t["lineSpacingBehaviour"] = json!(beh); + let a = &mut t["attributedString"]["attributes"][0]["attributes"]["paragraphStyle"]; + a["maximumLineHeight"] = json!(30); + a["minimumLineHeight"] = json!(30); + t + }; + let (doc, _) = open(vec![mk("B0", 0), mk("B1", 1), mk("B2", 2)]); + let y = |n: &str| doc.world_transform(doc.nodes().find(|x| x.name == n).unwrap().id).as_coeffs()[5]; + // Extra height: 30 − 14 × 1.2 = 13.2; half of it either way. + assert!((y("B2") - 100.0).abs() < 1e-6, "{}", y("B2")); + assert!((y("B0") - 106.6).abs() < 1e-6, "{}", y("B0")); + assert!((y("B1") - 93.4).abs() < 1e-6, "{}", y("B1")); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index c1bf0432..6642eb9f 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -43,6 +43,10 @@ pub(crate) struct Cx { pub max_work: u64, /// Notes given so far (each once). pub seen: std::collections::BTreeSet, + /// Inside a shape group: whether its shapes are filled (open ones then close) and whether the + /// group has a border. Outside one: `None` / false (a shape's own style decides). + pub operand_fill: Option, + pub operand_border: bool, /// The symbols being expanded, outermost first (a symbol inside itself stops there). pub chain: Vec, pub warnings: Vec, @@ -93,6 +97,8 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { work: 0, max_work: MAX_WORK, seen: std::collections::BTreeSet::new(), + operand_fill: None, + operand_border: false, chain: Vec::new(), warnings: Vec::new(), networks: Vec::new(), @@ -154,6 +160,8 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: let ty = match class { "artboard" | "frame" => "FRAME", "group" | "graphic" => "GROUP", + // Shapes whose points were moved are paths. + "rectangle" | "oval" if bool_field(l, "edited") == Some(true) => "VECTOR", "rectangle" | "bitmap" => "RECTANGLE", "oval" => "ELLIPSE", "slice" => "SLICE", @@ -169,7 +177,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: return None; } let (w, h) = size(l); - let world = parent * local(l, w, h); + let mut world = parent * local(l, w, h); + if ty == "TEXT" { + world *= Affine::translate((0.0, super::text::baseline_shift(l))); + } let mut node = base(l, ty, parent, world, w, h, root); if ty == "TEXT" { super::text::node(cx, l, &mut node); @@ -192,7 +203,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: "rectangle" => corner_radii(l, &mut node), "bitmap" => match l.get("image").and_then(|i| str_field(i, "_ref")) { Some(r) => { - let paint = json!({"type": "IMAGE", "scaleMode": "STRETCH", "imageRef": r, "imageTransform": [[1, 0, 0], [0, 1, 0]]}); + let mut paint = json!({"type": "IMAGE", "scaleMode": "STRETCH", "imageRef": r, "imageTransform": [[1, 0, 0], [0, 1, 0]]}); + if let Some(f) = super::style::color_controls(cx, l.get("style")) { + paint["filters"] = f; + } node.insert("fills".into(), json!([paint])); } None => cx.warn("images stored inside the JSON were skipped"), @@ -281,7 +295,8 @@ pub(crate) fn size(l: &Value) -> (f64, f64) { } /// A layer's transform within its parent: its frame's position, then its rotation (degrees, -/// counter-clockwise on screen) and flips about its centre. +/// counter-clockwise on screen) and flips about its centre. Sketch flips on screen, after rotating +/// (a quarter-turned layer flipped left to right mirrors its own up and down). pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { let f = l.get("frame"); let n = |k: &str| f.and_then(|f| f.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-MAX_COORD, MAX_COORD); @@ -289,8 +304,8 @@ pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { let sx = if bool_field(l, "isFlippedHorizontal") == Some(true) { -1.0 } else { 1.0 }; let sy = if bool_field(l, "isFlippedVertical") == Some(true) { -1.0 } else { 1.0 }; Affine::translate((n("x") + w / 2.0, n("y") + h / 2.0)) - * Affine::rotate(-rot.to_radians()) * Affine::scale_non_uniform(sx, sy) + * Affine::rotate(-rot.to_radians()) * Affine::translate((-w / 2.0, -h / 2.0)) } diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs index 91aeba3a..4b0ab511 100644 --- a/crates/format/src/sketch/tree_tests.rs +++ b/crates/format/src/sketch/tree_tests.rs @@ -231,3 +231,28 @@ fn deep_document_json_still_opens_the_pages() { assert!(doc.pages.iter().any(|p| doc.node(*p).unwrap().name == "Kept")); assert!(!warnings.is_empty()); } + +/// Sketch flips a rotated layer on screen, after rotating it. +#[test] +fn flip_applies_after_rotation() { + let r = with(with(rect("Turned", 0.0, 0.0, 40.0, 20.0), "rotation", json!(90)), "isFlippedHorizontal", json!(true)); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![r])]); + let t = doc.world_transform(node(&doc, "Turned").id).as_coeffs(); + // Rotated a quarter turn counter-clockwise (x → up, y → right), then mirrored left to right. + assert!(t[0].abs() < 1e-9 && (t[1] + 1.0).abs() < 1e-9 && (t[2] + 1.0).abs() < 1e-9 && t[3].abs() < 1e-9, "{t:?}"); +} + +#[test] +fn edited_shapes_keep_their_points() { + let mut drop = rect("Drop", 0.0, 0.0, 20.0, 30.0); + drop["_class"] = json!("oval"); + drop["edited"] = json!(true); + drop["points"] = json!([point(0.5, 0.0), point(1.0, 0.7), point(0.5, 1.0), point(0.0, 0.7)]); + let mut box_ = rect("Slanted", 50.0, 0.0, 20.0, 20.0); + box_["edited"] = json!(true); + box_["points"][1]["point"] = json!("{1, 0.3}"); + let (doc, _) = open(vec![artboard("A", "Board", 0.0, 0.0, 300.0, 300.0, vec![drop, box_])]); + let NodeKind::Vector { network } = &node(&doc, "Drop").kind else { panic!("not a vector") }; + assert_eq!((network.vertices[0].x, network.vertices[0].y), (10.0, 0.0)); + assert!(matches!(node(&doc, "Slanted").kind, NodeKind::Vector { .. })); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ed2d3157..508cd12a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape or as boolean operations (mixed operations nested from the left), bitmaps, slices, hotspots; transforms (rotation, flips), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), resized by their constraints and still live; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (80,000 layers open in ~4 s, debug build); Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), resized by their constraints and still live; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%; Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From ad78ee94c383e1f86ea6ca689879a3a6be88359f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 21:55:34 +0200 Subject: [PATCH 047/208] M6: Sketch import: instances, artboards and Apple fonts like Sketch Compared with Sketch's renders of a Sketch 64 file. Resized instances lay out their layers the way Sketch does (measured: pins share the change in proportion, a fixed size keeps its centre's share, and files before format 110 just scale); masters' backgrounds show in instances only when Sketch includes them; swaps to a missing symbol keep the original; artboards don't draw a style they carry. Apple's system fonts are found under the names designs use (SF Pro, SF UI Text and Display, SF Compact), the optical size follows the text size for named styles too, and imported text keeps the platform's trak tracking as letter spacing. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 39 ++++++++ crates/engine/src/sketch_import_tests.rs | 26 ++++- crates/format/src/sketch/mod.rs | 25 +---- crates/format/src/sketch/symbols.rs | 75 +++++++++++++-- crates/format/src/sketch/symbols_tests.rs | 79 ++++++++++++++- crates/format/src/sketch/text.rs | 2 + crates/format/src/sketch/text_tests.rs | 4 + crates/format/src/sketch/tree.rs | 10 ++ crates/format/src/sketch/tree_tests.rs | 18 +++- crates/text/src/fontdb.rs | 112 ++++++++++++++++++++-- crates/text/src/fontdb_tests.rs | 61 ++++++++++++ docs/file-format-parity.md | 2 +- 12 files changed, 413 insertions(+), 40 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 179f1c06..c6f892c1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -558,7 +558,11 @@ fn load_with( fn load_sketch(bytes: &[u8], name: &str) -> Option> { let r = figcraft_format::sketch::try_import(bytes, name)?; Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + // Both read the installed fonts: the system's must be known first (the desktop app scans + // them in the background; this waits for that scan). + figcraft_text::fontdb::scan_system_fonts(); match_font_families(&mut doc); + platform_tracking(&mut doc); link_instances(&mut doc); figcraft_format::Loaded { issues: warnings, ..doc.into() } })) @@ -587,6 +591,41 @@ fn match_font_families(doc: &mut Document) { } } +/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine +/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so +/// lines measure and break as they did there. +fn platform_tracking(doc: &mut Document) { + let mut cache: std::collections::BTreeMap<(String, String), Option>> = + std::collections::BTreeMap::new(); + let mut face = |style: &figcraft_doc::TextStyle| { + cache + .entry((style.font_family.clone(), style.font_style.clone())) + .or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + }) + .clone() + }; + let mut track = |style: &mut figcraft_doc::TextStyle| { + let Some(f) = face(style) else { return }; + let t = f.tracking(style.font_size); + if t != 0.0 && t.is_finite() { + let px = style.letter_spacing.resolve(style.font_size); + style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + track(&mut td.style); + for r in &mut td.runs { + track(&mut r.style); + } + } + } +} + /// The installed family `family` means when it isn't installed under that exact name: the one /// whose name is the same without spaces, ignoring case. pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index cee351b8..d78db77c 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -25,7 +25,9 @@ fn sketch_with(app_version: &str, pages: &[(&str, &str)], layers: &[Value]) -> V z.start_file("document.json", o).unwrap(); z.write_all(json!({"_class": "document", "pages": refs}).to_string().as_bytes()).unwrap(); z.start_file("meta.json", o).unwrap(); - z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": 103}).to_string().as_bytes()).unwrap(); + // The format version that Sketch writes (103 for 50; 110 and up from 52). + let version = if app_version.split('.').next().and_then(|v| v.parse::().ok()).is_some_and(|v| v >= 52) { 125 } else { 103 }; + z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": version}).to_string().as_bytes()).unwrap(); for (i, (id, name)) in pages.iter().enumerate() { let ls = if i == 0 { layers.to_vec() } else { Vec::new() }; z.start_file(format!("pages/{id}.json"), o).unwrap(); @@ -154,7 +156,7 @@ fn sketch_instances_stay_live() { let tag = shape("rectangle", "Tag", [80.0, 0.0, 20.0, 40.0], json!({"resizingConstraint": 60, "points": []})); let master = shape("symbolMaster", "Bar", [0.0, 0.0, 100.0, 40.0], json!({"symbolID": "S", "layers": [tag]})); let inst = shape("symbolInstance", "Wide", [0.0, 100.0, 200.0, 40.0], json!({"symbolID": "S", "overrideValues": []})); - let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("50.2", &[("A", "Page")], &[master, inst])); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[master, inst])); let mut s = Session::new(); s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); let find = |s: &Session, inst: bool| { @@ -304,3 +306,23 @@ fn local_sketch_samples_match_sketch() { worst.sort_by(|a, b| b.0.total_cmp(&a.0)); assert!(worst.first().is_none_or(|w| w.0 < 10.0), "{worst:?}"); } + +/// Sketch draws Apple's fonts with the tracking their `trak` table gives at each size (the +/// platform does it on its own); the imported text carries it as letter spacing (macOS only). +#[test] +fn sketch_text_keeps_the_platform_tracking() { + if !std::path::Path::new("/System/Library/Fonts/SFNS.ttf").exists() { + return; + } + figcraft_text::fontdb::scan_system_fonts(); + let font = json!({"name": "SFUIText-Regular", "size": 13}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let t = shape("text", "Label", [0.0, 0.0, 100.0, 20.0], json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[t])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") }; + let ls = td.style.letter_spacing.resolve(13.0); + assert!((ls - (-12.0 * 13.0 / 2048.0)).abs() < 1e-6, "{ls}"); +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 3c64b409..576dbe06 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -120,7 +120,6 @@ fn import_parts(bytes: &[u8], name: &str, max_member: u64, max_nodes: usize) -> if archive.document.get("colorSpace").and_then(Value::as_i64) == Some(2) { doc.color_profile = figcraft_doc::color::ColorProfile::DisplayP3; } - fit_instances(&mut doc); warnings.extend(more); Ok((doc, warnings)) } @@ -138,25 +137,6 @@ fn embedded_fonts(document: &Value) -> Option { Some(format!("the file has embedded fonts FigCraft doesn't use ({}); install them to see the text as designed", list.join(", "))) } -/// Instances come in with their master's layers laid out at the master's size; resized ones lay -/// them out for their own size by their constraints, innermost first. -fn fit_instances(doc: &mut Document) { - let mut insts: Vec<(usize, figcraft_doc::NodeId, f64, f64)> = doc - .nodes() - .filter_map(|n| match n.kind { - figcraft_doc::NodeKind::Instance { component, .. } => { - let c = doc.node(component)?; - Some((doc.ancestors(n.id).len(), n.id, c.width, c.height)) - } - _ => None, - }) - .collect(); - insts.sort_by_key(|i| std::cmp::Reverse(i.0)); - for (_, id, w, h) in insts { - figcraft_layout::apply_constraints(doc, id, w, h); - } -} - fn corrupt(msg: impl Into) -> FormatError { FormatError::Corrupt(format!("Sketch file: {}", msg.into())) } @@ -164,6 +144,8 @@ fn corrupt(msg: impl Into) -> FormatError { /// The parts of a `.sketch` archive: the document, the pages in document order and the images. pub(crate) struct Archive<'b> { pub document: Value, + /// `meta.json`'s format version (0 when missing). + pub version: u64, /// `(id, page JSON)` in the document's order. pub pages: Vec<(String, Value)>, /// Problems met while reading (pages skipped). @@ -220,7 +202,8 @@ impl<'b> Archive<'b> { if refs.len() > MAX_PAGES { warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped")); } - Ok(Archive { document, pages, warnings, zip: z }) + let version = meta.get("version").and_then(Value::as_u64).unwrap_or(0); + Ok(Archive { document, version, pages, warnings, zip: z }) } /// The bytes of the image a layer references (`images/`). diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index cabc2fd4..1292c025 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -162,7 +162,10 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo let mut node = base(l, "INSTANCE", parent, world, w, h, root); node.insert("componentId".into(), json!(str_field(&m, "do_objectID").unwrap_or(sid))); node.insert("clipsContent".into(), json!(true)); - if let Some(f) = master_fills(&m) { + // Instances show the master's background only when it says so. + if bool_field(&m, "includeBackgroundColorInInstance") == Some(true) + && let Some(f) = master_fills(&m) + { node.insert("fills".into(), f); } if let Some(st) = l.get("style") { @@ -176,7 +179,7 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo let mut copy = m; let mut budget = cx.work_left(); if let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { - apply(layers, &overrides, &nested, &cx.shared, 0, &mut budget); + apply(layers, &overrides, &nested, &cx.shared, &cx.symbols, 0, &mut budget); } let used = cx.work_left().saturating_sub(budget); cx.spend(used); @@ -184,9 +187,15 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo // The budget ran out handing overrides on: noted. cx.spend(1); } + // The master's layers laid out for the instance's size, as Sketch does it. let (mw, mh) = size(©); + if ((mw - w).abs() > 1e-9 || (mh - h).abs() > 1e-9) + && let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) + { + resize(layers, (mw, mh), (w, h), cx.legacy_resize, 0); + } cx.chain.push(sid.to_string()); - let mut kids = children(cx, ©, world, (mw, mh), depth, false); + let mut kids = children(cx, ©, world, (w, h), depth, false); cx.chain.pop(); let prefix = str_field(l, "do_objectID").unwrap_or("I").to_string(); for k in &mut kids { @@ -196,6 +205,58 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo Some(Value::Object(node)) } +/// Lay `layers` (the children of a box of `old` size) out for the box at `new` size, the way Sketch +/// resizes a symbol instance's layers: groups pass the change on to their own layers. Files older +/// than format 110 scale everything; newer ones follow each layer's `resizingConstraint`. +fn resize(layers: &mut [Value], old: (f64, f64), new: (f64, f64), legacy: bool, depth: usize) { + if depth > 64 { + return; + } + for l in layers { + let mask = if legacy { 63 } else { l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63) }; + let Some(f) = l.get_mut("frame").and_then(Value::as_object_mut) else { continue }; + let n = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0); + let (x, y, w, h) = (n("x"), n("y"), n("width").max(0.0), n("height").max(0.0)); + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + let pinned = |bit: i64| mask & bit == 0; + let (nx, nw) = resize_axis(x, w, old.0, new.0, (pinned(4), pinned(2), pinned(1))); + let (ny, nh) = resize_axis(y, h, old.1, new.1, (pinned(32), pinned(16), pinned(8))); + for (k, v) in [("x", nx), ("y", ny), ("width", nw), ("height", nh)] { + f.insert(k.into(), json!(v)); + } + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + resize(kids, (w, h), (nw, nh), legacy, depth + 1); + } + } +} + +/// One axis of [`resize`]: a layer at `pos` of `size` in a parent going from `old` to `new`, with +/// its start, size and end pinned or not. Nothing pinned (or everything): it scales. One side +/// pinned: the size and the free side share the change, in proportion to their lengths. Only the +/// size fixed: its centre scales. Both sides pinned: the size takes the change. +fn resize_axis(pos: f64, size: f64, old: f64, new: f64, (start, fixed, end): (bool, bool, bool)) -> (f64, f64) { + if old <= 1e-9 || !new.is_finite() { + return (pos, size); + } + let k = new / old; + let d = new - old; + let far = old - pos - size; + let share = |part: f64, other: f64| if part + other > 1e-9 { d * part / (part + other) } else { d }; + let (p, s) = match (start, fixed, end) { + (true, false, false) => (pos, size + share(size, far)), + (false, false, true) => { + let s = size + share(size, pos); + (new - far - s, s) + } + (false, true, false) => ((pos + size / 2.0) * k - size / 2.0, size), + (true, false, true) => (pos, size + d), + (true, true, false) => (pos, size), + (false, true, true) => (new - far - size, size), + _ => (pos * k, size * k), + }; + (if p.is_finite() { p } else { pos }, if s.is_finite() { s.max(0.0) } else { size }) +} + /// An instance's overrides by name (`_` → value). fn overrides(l: &Value) -> BTreeMap { l.get("overrideValues") @@ -229,6 +290,7 @@ fn apply( ov: &BTreeMap, nested: &BTreeMap<&str, Vec<(&str, &Value)>>, shared: &Shared, + symbols: &Symbols, depth: usize, budget: &mut u64, ) { @@ -242,8 +304,9 @@ fn apply( Some("symbolInstance") => { match get("symbolID").map(|v| v.as_str().unwrap_or("")) { Some("") => l["isVisible"] = json!(false), - Some(other) => l["symbolID"] = json!(other), - None => {} + // A swap to a symbol the file doesn't have keeps the original, as in Sketch. + Some(other) if symbols.master(other).is_some() => l["symbolID"] = json!(other), + _ => {} } let inner = nested.get(id.as_str()).map(Vec::as_slice).unwrap_or_default(); let take = usize::try_from(*budget / OVERRIDE_COST).unwrap_or(usize::MAX).min(inner.len()); @@ -289,7 +352,7 @@ fn apply( } } if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { - apply(kids, ov, nested, shared, depth + 1, budget); + apply(kids, ov, nested, shared, symbols, depth + 1, budget); } } } diff --git a/crates/format/src/sketch/symbols_tests.rs b/crates/format/src/sketch/symbols_tests.rs index 64c7ae7f..7c2d73db 100644 --- a/crates/format/src/sketch/symbols_tests.rs +++ b/crates/format/src/sketch/symbols_tests.rs @@ -14,6 +14,12 @@ fn open_doc(doc_json: Value, layers: Vec, masters: Vec) -> (Docume import(&z, "f").unwrap() } +/// [`open`] for a file saved by another Sketch. +fn open_version(app: &str, version: u64, layers: Vec, masters: Vec) -> (Document, Vec) { + let z = sketch_zip(app, version, document(&["P", "S"]), &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]); + import(&z, "f").unwrap() +} + fn open(layers: Vec, masters: Vec) -> (Document, Vec) { let mut d = document(&["P", "S"]); d["do_objectID"] = json!("DOC"); @@ -154,10 +160,10 @@ fn instance_explosion_is_bounded() { #[test] fn resized_instance_follows_constraints() { - // Pinned right with a fixed width (bits 1 and 2 cleared). + // Pinned right with a fixed width (bits 1 and 2 cleared); a Sketch 64 file. let pinned = with(rect("Tag", 80.0, 0.0, 20.0, 40.0), "resizingConstraint", json!(60)); let m = master("M", "S", "Bar", 100.0, 40.0, vec![pinned]); - let (doc, _) = open(vec![instance("Wide", "S", 0.0, 0.0, 200.0, 40.0, &[])], vec![m]); + let (doc, _) = open_version("64", 125, vec![instance("Wide", "S", 0.0, 0.0, 200.0, 40.0, &[])], vec![m]); let (inst, _) = instance_of(&doc, "Wide"); let tag = inside(&doc, inst, "Tag"); let b = doc.world_bounds(tag.id).unwrap(); @@ -226,3 +232,72 @@ fn scale_zero_is_unscaled() { let (_, warnings) = open(vec![i], vec![button()]); assert!(!warnings.iter().any(|w| w.contains("scaled")), "{warnings:?}"); } + +/// A master's background shows in its instances only when Sketch says so. +#[test] +fn master_background_in_instances_only_when_included() { + let bg = |id: &str, sid: &str, include: bool| { + let mut m = master(id, sid, id, 20.0, 20.0, vec![]); + m["hasBackgroundColor"] = json!(true); + m["backgroundColor"] = json!({"_class": "color", "red": 0, "green": 0, "blue": 0, "alpha": 1}); + m["includeBackgroundColorInInstance"] = json!(include); + m + }; + let (doc, _) = open( + vec![instance("Hidden", "SH", 0.0, 0.0, 20.0, 20.0, &[]), instance("Shown", "SS", 50.0, 0.0, 20.0, 20.0, &[])], + vec![bg("MH", "SH", false), bg("MS", "SS", true)], + ); + let (hidden, main) = instance_of(&doc, "Hidden"); + assert!(hidden.fills.is_empty(), "{:?}", hidden.fills); + assert_eq!(main.fills.len(), 1, "the master shows its background"); + assert_eq!(instance_of(&doc, "Shown").0.fills.len(), 1); +} + +/// How Sketch lays out a resized instance's layers, measured on its renders: a master of 200×100 +/// with a 100×40 rectangle at (20, 30), shown at 320×60. +#[test] +fn resized_instances_lay_out_like_sketch() { + let case = |mask: i64, version: u64| { + let r = with(rect("R", 20.0, 30.0, 100.0, 40.0), "resizingConstraint", json!(mask)); + let m = master("M", "S", "Box", 200.0, 100.0, vec![r]); + let (doc, _) = open_version("64", version, vec![instance("I", "S", 0.0, 0.0, 320.0, 60.0, &[])], vec![m]); + let (inst, _) = instance_of(&doc, "I"); + let b = doc.world_bounds(inside(&doc, inst, "R").id).unwrap(); + (b.x0, b.y0, b.width(), b.height()) + }; + let near = |a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)| { + (a.0 - b.0).abs() < 0.5 && (a.1 - b.1).abs() < 0.5 && (a.2 - b.2).abs() < 0.5 && (a.3 - b.3).abs() < 0.5 + }; + // Sketch 52 and newer (format 110+). + for (mask, want) in [ + (63, (32.0, 18.0, 160.0, 24.0)), + (59, (20.0, 18.0, 166.67, 24.0)), + (62, (40.0, 18.0, 200.0, 24.0)), + (61, (62.0, 18.0, 100.0, 24.0)), + (58, (20.0, 18.0, 220.0, 24.0)), + (57, (20.0, 18.0, 100.0, 24.0)), + (60, (140.0, 18.0, 100.0, 24.0)), + (54, (40.0, 12.86, 200.0, 17.14)), + (0, (32.0, 18.0, 160.0, 24.0)), + (47, (32.0, 10.0, 160.0, 40.0)), + ] { + let got = case(mask, 125); + assert!(near(got, want), "mask {mask}: {got:?} vs {want:?}"); + } + // Older files: everything scales. + for mask in [59, 62, 61, 0] { + let got = case(mask, 103); + assert!(near(got, (32.0, 18.0, 160.0, 24.0)), "legacy mask {mask}: {got:?}"); + } +} + +/// A nested swap to a symbol the file doesn't have keeps the original, as Sketch shows it. +#[test] +fn swap_to_a_missing_symbol_keeps_the_original() { + let icon = master("MA", "SA", "Icon A", 20.0, 20.0, vec![rect("Dot A", 0.0, 0.0, 20.0, 20.0)]); + let card = master("MC", "SC", "Card", 100.0, 40.0, vec![instance("N", "SA", 0.0, 0.0, 20.0, 20.0, &[])]); + let (doc, warnings) = open(vec![instance("I", "SC", 0.0, 0.0, 100.0, 40.0, &[("N_symbolID", json!("GONE"))])], vec![icon, card]); + let (inst, _) = instance_of(&doc, "I"); + assert!(doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).any(|n| n.name == "Dot A")); + assert!(!warnings.iter().any(|w| w.contains("empty frames")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 14357cfc..e1f0c263 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -170,6 +170,8 @@ pub(crate) fn font_name(ps: &str) -> (String, String) { ".SFNSDisplay" | ".SFUIDisplay" | "SanFranciscoDisplay" | ".SFProDisplay" => "SF Pro Display".into(), // Apple's CJK families whose names don't split at case changes. "PingFangSC" | "PingFangTC" | "PingFangHK" => format!("PingFang {}", family.get(8..).unwrap_or("")), + // Apple's SF families: "SF" and then words ("SFUIText" → "SF UI Text"). + f if f.starts_with("SF") && f.chars().nth(2).is_some_and(char::is_uppercase) => format!("SF {}", words(f.get(2..).unwrap_or(""))), f => words(f.trim_start_matches('.')), }; (family, style) diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index f6b96738..41b86f68 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -152,6 +152,10 @@ fn font_names() { (".SFNSDisplay-Bold", "SF Pro Display", "Bold"), ("SanFranciscoText-Semibold", "SF Pro Text", "Semi Bold"), ("Impact", "Impact", "Regular"), + ("SFUIText-Semibold", "SF UI Text", "Semi Bold"), + ("SFUIDisplay-Regular", "SF UI Display", "Regular"), + ("SFCompactDisplay-Semibold", "SF Compact Display", "Semi Bold"), + ("SFProText-Bold", "SF Pro Text", "Bold"), ("IBMPlexSans-MediumItalic", "IBM Plex Sans", "Medium Italic"), ("Roboto-Italic", "Roboto", "Italic"), ("", "Helvetica", "Regular"), diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 6642eb9f..9a5bfaa3 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -47,6 +47,9 @@ pub(crate) struct Cx { /// group has a border. Outside one: `None` / false (a shape's own style decides). pub operand_fill: Option, pub operand_border: bool, + /// The file is older than format 110 (Sketch 52): Sketch scales a resized instance's layers + /// instead of following their pins. + pub legacy_resize: bool, /// The symbols being expanded, outermost first (a symbol inside itself stops there). pub chain: Vec, pub warnings: Vec, @@ -99,6 +102,7 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { seen: std::collections::BTreeSet::new(), operand_fill: None, operand_border: false, + legacy_resize: archive.version > 0 && archive.version < 110, chain: Vec::new(), warnings: Vec::new(), networks: Vec::new(), @@ -228,6 +232,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: /// Layer `l`'s style into `node`, and its links to the styles made from its shared style. pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap) { + // An artboard shows its background colour only; Sketch doesn't draw a style it carries. + if str_field(l, "_class") == Some("artboard") { + return; + } if let Some(st) = l.get("style") { super::style::apply(cx, st, w, h, node); } @@ -315,6 +323,8 @@ pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { fn constraints(mask: i64) -> Value { let pinned = |bit: i64| mask & bit == 0; let axis = |low: i64, size: i64, high: i64, low_name: &str, high_name: &str, both: &str| match (pinned(low), pinned(high)) { + // Both sides and the size pinned can't all hold: Sketch scales such a layer. + (true, true) if pinned(size) => "SCALE".into(), (true, true) => both.to_string(), (true, false) => low_name.to_string(), (false, true) => high_name.to_string(), diff --git a/crates/format/src/sketch/tree_tests.rs b/crates/format/src/sketch/tree_tests.rs index 4b0ab511..5399a3df 100644 --- a/crates/format/src/sketch/tree_tests.rs +++ b/crates/format/src/sketch/tree_tests.rs @@ -82,7 +82,8 @@ fn resizing_constraint_bits() { (63 - 8 - 32, H::Scale, V::TopBottom), (63 - 2 - 16, H::Center, V::Center), (63 - 1 - 2, H::Right, V::Scale), - (0, H::LeftRight, V::TopBottom), + // Everything pinned is over-constrained: Sketch scales it. + (0, H::Scale, V::Scale), ]; let layers: Vec = cases .iter() @@ -256,3 +257,18 @@ fn edited_shapes_keep_their_points() { assert_eq!((network.vertices[0].x, network.vertices[0].y), (10.0, 0.0)); assert!(matches!(node(&doc, "Slanted").kind, NodeKind::Vector { .. })); } + +/// Sketch draws an artboard's background colour only; a style it may carry (borders, shadows) isn't +/// drawn. Sketch 2025 frames do draw theirs. +#[test] +fn artboards_ignore_their_style() { + let border = json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": {"_class": "color", "red": 0, "green": 0, "blue": 0, "alpha": 1}, "position": 2, "thickness": 1}]); + let mut a = artboard("A", "Board", 0.0, 0.0, 100.0, 100.0, vec![]); + a["style"]["borders"] = border.clone(); + let mut f = artboard("F", "Frame", 200.0, 0.0, 100.0, 100.0, vec![]); + f["_class"] = json!("frame"); + f["style"]["borders"] = border; + let (doc, _) = open(vec![a, f]); + assert!(node(&doc, "Board").strokes.is_empty()); + assert_eq!(node(&doc, "Frame").strokes.len(), 1); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 604f3eee..22ca4b84 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -106,6 +106,8 @@ pub struct Face { /// Variation axes (empty for static fonts) and named instances. pub axes: Vec, pub instances: Vec, + /// The `trak` table's normal track: (size in points, tracking in font units), by size. + track: Vec<(f64, f64)>, charmap: HashMap, outlines: Mutex>>, advances: Mutex>, @@ -123,6 +125,29 @@ impl std::fmt::Debug for Face { } impl Face { + /// The tracking the font's `trak` table gives text of `size` (pixels per glyph; 0 without a + /// table): Apple's platform text engine adds it on its own, FigCraft's doesn't. + pub fn tracking(&self, size: f64) -> f64 { + let (Some(first), Some(last)) = (self.track.first(), self.track.last()) else { return 0.0 }; + if !size.is_finite() || self.upem <= 0.0 { + return 0.0; + } + let units = if size <= first.0 { + first.1 + } else if size >= last.0 { + last.1 + } else { + self.track + .windows(2) + .find_map(|w| match w { + [a, b] if size >= a.0 && size <= b.0 && b.0 > a.0 => Some(a.1 + (b.1 - a.1) * (size - a.0) / (b.0 - a.0)), + _ => None, + }) + .unwrap_or(0.0) + }; + units * size / self.upem + } + pub fn from_bytes(bytes: FontBytes, index: u32, family_override: Option<&str>, style_override: Option<&str>) -> Option { let data = bytes.data(); let f = skrifa::FontRef::from_index(data, index).ok()?; @@ -147,6 +172,7 @@ impl Face { let charmap: HashMap = f.charmap().mappings().map(|(c, g)| (c, g.to_u32())).collect(); let (axes, instances) = variation_info(&f); let has_table = |tag: &[u8; 4]| f.data_for_tag(skrifa::Tag::new(tag)).is_some(); + let track = f.data_for_tag(skrifa::Tag::new(b"trak")).map(|t| normal_track(t.as_bytes())).unwrap_or_default(); let bitmap_strikes = has_table(b"sbix") || has_table(b"CBDT") || has_table(b"EBDT"); let colr = has_table(b"COLR"); let under = m.underline.map(|d| (f64::from(d.offset), f64::from(d.thickness))).unwrap_or((-upem * 0.1, upem * 0.06)); @@ -173,6 +199,7 @@ impl Face { strike_pos: strike, shaper, gsub_features, + track, axes, instances, charmap, @@ -287,6 +314,12 @@ impl Face { for (slot, c) in v.iter_mut().zip(&inst.coords) { *slot = *c; } + // The optical size follows the text size for a named style too, as on the platforms. + for (slot, a) in v.iter_mut().zip(&self.axes) { + if a.tag == "opsz" && style.font_size.is_finite() { + *slot = style.font_size; + } + } } else { let own_style = norm_style(&self.style) == norm_style(&style.font_style); let italic = style.font_style.to_ascii_lowercase().contains("italic") && !self.italic; @@ -398,6 +431,31 @@ fn name_string(f: &skrifa::FontRef, id: StringId) -> Option { (!s.is_empty()).then_some(s) } +/// The normal (0) horizontal track of a `trak` table: (size, tracking in font units) by size. Empty +/// when the table is missing, damaged or has no normal track. +fn normal_track(t: &[u8]) -> Vec<(f64, f64)> { + let u16_at = |o: usize| t.get(o..o + 2).map(|b| u16::from_be_bytes([b[0], b[1]])); + let i16_at = |o: usize| t.get(o..o + 2).map(|b| i16::from_be_bytes([b[0], b[1]])); + let fixed_at = |o: usize| t.get(o..o + 4).map(|b| f64::from(i32::from_be_bytes([b[0], b[1], b[2], b[3]])) / 65536.0); + let Some(horiz) = u16_at(6).map(usize::from).filter(|o| *o > 0) else { return Vec::new() }; + let (Some(tracks), Some(sizes)) = (u16_at(horiz).map(usize::from), u16_at(horiz + 2).map(usize::from)) else { return Vec::new() }; + let Some(size_table) = t.get(horiz + 4..horiz + 8).map(|b| u32::from_be_bytes([b[0], b[1], b[2], b[3]]) as usize) else { return Vec::new() }; + let sizes = sizes.min(256); + for k in 0..tracks.min(64) { + let entry = horiz + 8 + k * 8; + if fixed_at(entry) != Some(0.0) { + continue; + } + let Some(values) = u16_at(entry + 6).map(usize::from) else { return Vec::new() }; + let out: Vec<(f64, f64)> = (0..sizes) + .filter_map(|i| Some((fixed_at(size_table + i * 4)?, f64::from(i16_at(values + i * 2)?)))) + .filter(|(s, _)| s.is_finite()) + .collect(); + return if out.windows(2).all(|w| w[0].0 < w[1].0) { out } else { Vec::new() }; + } + Vec::new() +} + /// (family, style) preferring the typographic names. fn names(f: &skrifa::FontRef) -> (Option, Option) { let family = name_string(f, StringId::TYPOGRAPHIC_FAMILY_NAME).or_else(|| name_string(f, StringId::FAMILY_NAME)); @@ -528,7 +586,7 @@ impl FontDb { /// Style names of a family, by weight then italic. pub fn styles(&mut self, family: &str) -> Vec { self.load_family(family, None); - let Some((_, idx)) = self.families.get(&family.to_lowercase()) else { return vec![] }; + let Some((_, idx)) = self.families.get(&self.key(family)) else { return vec![] }; // (weight, italic, order in the font, name): named instances keep the font's order. let mut v: Vec<(u16, bool, usize, String)> = Vec::new(); for f in idx.iter().filter_map(|i| self.faces.get(*i)) { @@ -549,15 +607,31 @@ impl FontDb { v.into_iter().map(|x| x.3).collect() } - pub fn has_family(&self, family: &str) -> bool { + /// The key `family` is known under: itself, or for a name designs use for Apple's system font + /// (SF Pro, SF UI Text, …) the system font when no font of that name is installed. + pub fn key(&self, family: &str) -> String { let k = family.to_lowercase(); + let known = |k: &str| self.families.contains_key(k) || self.pending.contains_key(k); + if known(&k) { + return k; + } + match apple_alias(&k) { + Some(target) if known(target) => target.to_string(), + _ => k, + } + } + + pub fn has_family(&self, family: &str) -> bool { + let k = self.key(family); self.families.contains_key(&k) || self.pending.contains_key(&k) } /// Read a system family's faces from disk; with `only`, just the face nearest that style (the /// rest stay pending). fn load_family(&mut self, family: &str, only: Option<&str>) { - let key = family.to_lowercase(); + let key = self.key(family); + // Faces load under the family name the scan gave them (a hidden family's public name). + let display = self.pending.get(&key).map(|(d, _)| d.clone()); let entries = match only { None => match self.pending.remove(&key) { Some((_, entries)) => entries, @@ -591,7 +665,7 @@ impl FontDb { Err(_) => continue, }, }; - if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) { + if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, display.as_deref(), Some(&e.style)) { self.add(face); self.loaded_files.insert((e.path, e.index)); } @@ -608,7 +682,7 @@ impl FontDb { /// `style`: a fallback for missing characters needs one face, not every file of a CJK family /// (10–20 MB each). The family's other faces load when it is used by name. pub fn find_one(&mut self, family: &str, style: &str) -> Option> { - if !self.families.contains_key(&family.to_lowercase()) { + if !self.families.contains_key(&self.key(family)) { self.load_family(family, Some(style)); } self.best(family, style) @@ -616,7 +690,7 @@ impl FontDb { /// The loaded face of `family` nearest `style`. fn best(&self, family: &str, style: &str) -> Option> { - let (_, idx) = self.families.get(&family.to_lowercase())?; + let (_, idx) = self.families.get(&self.key(family))?; let faces: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect(); let want = norm_style(style); if let Some(f) = faces.iter().find(|f| norm_style(&f.style) == want).or_else(|| faces.iter().find(|f| f.instance(style).is_some())) { @@ -884,12 +958,36 @@ fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { .any(|t| f.data_for_tag(skrifa::Tag::new(t)).is_some()) .then_some(())?; let (family, style) = names(&f); - let family = family.filter(|f| !f.starts_with('.'))?; + let family = public_family(&family?)?; Some((i, family, style.unwrap_or_else(|| "Regular".into()))) }) .collect() } +/// The name a font's family is offered under: its own, a public name for Apple's hidden system +/// families that designs use (`.SF Compact` → SF Compact), none for other hidden (`.`) families. +fn public_family(family: &str) -> Option { + let Some(hidden) = family.strip_prefix('.') else { return Some(family.to_string()) }; + match hidden { + "SF Compact" | "SF Compact Rounded" => Some(hidden.to_string()), + "SF NS Rounded" => Some("SF Pro Rounded".into()), + _ => None, + } +} + +/// The family Apple's system fonts are installed under, for the names designs use for them +/// (lowercase): SF Pro and SF UI are the variable `SFNS.ttf` ("System Font"), its text and display +/// cuts its optical sizes. +fn apple_alias(key: &str) -> Option<&'static str> { + Some(match key.trim_start_matches('.') { + "sf pro" | "sf pro text" | "sf pro display" | "sf ui text" | "sf ui display" | "sf ns" | "sf ns text" | "sf ns display" | "san francisco" => { + "system font" + } + "sf compact text" | "sf compact display" => "sf compact", + _ => return None, + }) +} + /// Convenience: find a face with the global db. pub fn find_face(family: &str, style: &str) -> Option> { let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index f2456d28..0b83e1d3 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -263,3 +263,64 @@ fn font_bytes_load_into_the_db() { assert_eq!(db().read().unwrap().face_count(), faces); } } + +/// Apple's system font answers to the names designs use for it (SF Pro, SF UI Text, …), unless a +/// font of that name is installed. +#[test] +fn apple_system_font_names_find_the_system_font() { + let mut db = FontDb::default(); + db.pending.insert( + "system font".into(), + ("System Font".into(), vec![Pending { path: "no-such-dir/SFNS.ttf".into(), index: 0, style: "Regular".into() }]), + ); + for name in ["SF Pro", "SF Pro Text", "SF Pro Display", "SF UI Text", "SF UI Display", ".SF NS Text", "San Francisco"] { + assert!(db.has_family(name), "{name}"); + } + assert!(!db.has_family("SF Mono")); + db.pending.insert("sf pro".into(), ("SF Pro".into(), Vec::new())); + assert_eq!(db.key("SF Pro"), "sf pro", "an installed SF Pro is itself"); + assert_eq!(db.key("SF UI Text"), "system font"); +} + +#[test] +fn hidden_apple_families_take_public_names() { + assert_eq!(public_family(".SF Compact").as_deref(), Some("SF Compact")); + assert_eq!(public_family(".SF Compact Rounded").as_deref(), Some("SF Compact Rounded")); + assert_eq!(public_family(".SF NS Rounded").as_deref(), Some("SF Pro Rounded")); + assert_eq!(public_family(".Keyboard"), None); + assert_eq!(public_family("Inter").as_deref(), Some("Inter")); +} + +/// Like the platforms, a variable font's optical size follows the text size, also for a named +/// style (macOS only: the system font is the one variable font with an optical size axis at hand). +#[test] +fn optical_size_follows_the_text_size() { + let Ok(bytes) = std::fs::read("/System/Library/Fonts/SFNS.ttf") else { return }; + let face = Face::from_bytes(FontBytes::Shared(std::sync::Arc::new(bytes)), 0, None, None).unwrap(); + let opsz = face.axes.iter().position(|a| a.tag == "opsz").unwrap(); + let at = |size: f64| { + let style = + figcraft_doc::TextStyle { font_family: "System Font".into(), font_style: "Semibold".into(), font_size: size, ..Default::default() }; + face.axis_values(&style)[opsz] + }; + assert_eq!(at(12.0), 17.0); + assert_eq!(at(40.0), 40.0); +} + +/// The tracking a font's `trak` table gives at a size (Apple's fonts carry one; the platform text +/// engine applies it on its own), in pixels, interpolated between the sizes the table lists. +#[test] +fn trak_tracking_by_size() { + let Ok(bytes) = std::fs::read("/System/Library/Fonts/SFNS.ttf") else { return }; + let face = Face::from_bytes(FontBytes::Shared(std::sync::Arc::new(bytes)), 0, None, None).unwrap(); + // Track 0 of SF: −12 units at 13 pt, 0 at 12 pt, −40 at 16 pt (2048 units per em). + assert!((face.tracking(13.0) - (-12.0 * 13.0 / 2048.0)).abs() < 1e-9, "{}", face.tracking(13.0)); + assert!(face.tracking(12.0).abs() < 1e-9); + assert!((face.tracking(12.5) - (-6.0 * 12.5 / 2048.0)).abs() < 1e-9); + // Past the table's ends: its last values. + assert!(face.tracking(500.0).is_finite()); + let plain = crate::fontdb::db().write().unwrap().default_face(); + if let Some(f) = plain.filter(|f| f.family != "System Font") { + let _ = f.tracking(12.0); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 508cd12a..8759a359 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), resized by their constraints and still live; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%; Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%; Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From c639cc2b76de3e5e81daaaccab281298efea8190 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 22:51:11 +0200 Subject: [PATCH 048/208] M6: Sketch import: Sketch 2025 frames, stacks, tints and corners Groups as frames and graphics, stacks as auto layout (instances laid out by their stack), corners and per-side borders from the style, pins and sizing, tints (layeringType, fillColor overrides), text colour and visibility overrides, blur lists (progressive, glass), inner shadows in the shadow list, angular gradients starting on the right, text sizing, and texts an override changed measured again on open. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 39 +++ crates/engine/src/sketch_import_tests.rs | 75 +++++ crates/format/src/sketch/fixture.rs | 18 ++ crates/format/src/sketch/frames_tests.rs | 385 +++++++++++++++++++++++ crates/format/src/sketch/mod.rs | 3 + crates/format/src/sketch/path.rs | 59 ++++ crates/format/src/sketch/proto.rs | 7 +- crates/format/src/sketch/stack.rs | 99 ++++++ crates/format/src/sketch/style.rs | 140 ++++++++- crates/format/src/sketch/style_tests.rs | 8 +- crates/format/src/sketch/symbols.rs | 115 ++++++- crates/format/src/sketch/text.rs | 19 +- crates/format/src/sketch/tree.rs | 107 ++++++- docs/file-format-parity.md | 4 +- 14 files changed, 1044 insertions(+), 34 deletions(-) create mode 100644 crates/format/src/sketch/frames_tests.rs create mode 100644 crates/format/src/sketch/stack.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c6f892c1..5de7e0fd 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -564,10 +564,49 @@ fn load_sketch(bytes: &[u8], name: &str) -> Option = doc + .nodes() + .filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) { + (NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters, + _ => false, + }) + .map(|n| n.id) + .collect(); + let m = crate::measure::TextMeasure; + // Instances of a stack at another size than their main: the stack at their size, innermost first. + let mut resized: Vec<(usize, NodeId)> = doc + .nodes() + .filter(|n| n.layout.is_some()) + .filter(|n| match &n.kind { + NodeKind::Instance { component, .. } => { + doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6) + } + _ => false, + }) + .map(|n| (doc.ancestors(n.id).len(), n.id)) + .collect(); + resized.sort_by_key(|r| std::cmp::Reverse(r.0)); + for (_, id) in resized { + figcraft_layout::layout_frame(doc, id, &m); + } + for id in changed { + if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { + figcraft_layout::set_size(doc, id, w, h); + } + figcraft_layout::layout_up(doc, id, &m); + } +} + /// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from /// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and /// case. diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index d78db77c..5dc4b31a 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -326,3 +326,78 @@ fn sketch_text_keeps_the_platform_tracking() { let ls = td.style.letter_spacing.resolve(13.0); assert!((ls - (-12.0 * 13.0 / 2048.0)).abs() < 1e-6, "{ls}"); } + +/// A text an override changed is measured again, and the stack around it re-flows: a hugging chip +/// grows to its new label. +#[test] +fn sketch_overridden_text_reflows_its_stack() { + let font = json!({"name": "Helvetica", "size": 12}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let label = shape( + "text", + "Label", + [20.0, 4.0, 14.0, 16.0], + json!({"attributedString": {"string": "Go", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}), + ); + let dot = shape("oval", "Dot", [8.0, 9.0, 6.0, 6.0], json!({"points": []})); + let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 0, "alignItems": 1, "allGuttersGap": 6, "stackingOrder": 0}); + let master = shape( + "symbolMaster", + "Chip", + [0.0, 0.0, 44.0, 24.0], + json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1, + "leftPadding": 8, "rightPadding": 10, "topPadding": 4, "bottomPadding": 4, "layers": [label, dot]}), + ); + let ov = json!([{"_class": "overrideValue", "overrideName": "Label_stringValue", "value": "A much longer label here"}]); + let inst = shape( + "symbolInstance", + "Long", + [0.0, 100.0, 160.0, 24.0], + json!({"symbolID": "S", "overrideValues": ov, "horizontalSizing": 1, "verticalSizing": 1}), + ); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let inst = doc.nodes().find(|n| n.name == "Long").unwrap().id; + let label = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Label")).unwrap(); + let dot = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Dot")).unwrap(); + let (i, l, d) = (doc.world_bounds(inst).unwrap(), doc.world_bounds(label).unwrap(), doc.world_bounds(dot).unwrap()); + assert!(l.x1 - l.x0 > 60.0, "the label was measured: {l:?}"); + assert!((d.x0 - i.x0 - 8.0).abs() < 0.5, "the dot after the padding: {d:?} in {i:?}"); + assert!((l.x0 - d.x1 - 6.0).abs() < 0.5, "the label after the gap: {l:?}"); + assert!((i.x1 - l.x1 - 10.0).abs() < 0.5, "the chip hugs: {i:?} {l:?}"); +} + +/// An instance sized apart from its master lays its master's stack out at its own size: a wide +/// button keeps its label and icon together in the middle. +#[test] +fn sketch_resized_stack_instances_reflow() { + let a = shape("rectangle", "A", [22.0, 12.0, 40.0, 20.0], json!({"points": []})); + let b = shape("rectangle", "B", [70.0, 12.0, 20.0, 20.0], json!({"points": []})); + let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 1, "alignItems": 1, "allGuttersGap": 8, "stackingOrder": 0}); + let master = shape( + "symbolMaster", + "Button", + [0.0, 0.0, 112.0, 44.0], + json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1, + "leftPadding": 22, "rightPadding": 22, "topPadding": 12, "bottomPadding": 12, "layers": [b, a]}), + ); + let inst = shape( + "symbolInstance", + "Wide", + [0.0, 100.0, 342.0, 44.0], + json!({"symbolID": "S", "overrideValues": [], "horizontalSizing": 0, "verticalSizing": 1}), + ); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let inst = doc.nodes().find(|n| n.name == "Wide").unwrap().id; + let at = |name: &str| doc.world_bounds(doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == name)).unwrap()).unwrap(); + let (a, b) = (at("A"), at("B")); + // 40 + 8 + 20 = 68 wide, centred in 342: from 137. + assert!((a.x0 - 137.0).abs() < 0.5, "{a:?}"); + assert!((b.x0 - a.x1 - 8.0).abs() < 0.5, "{a:?} {b:?}"); + assert!((doc.world_bounds(inst).unwrap().x1 - 342.0).abs() < 1e-6); +} diff --git a/crates/format/src/sketch/fixture.rs b/crates/format/src/sketch/fixture.rs index ac426f0e..19d23eca 100644 --- a/crates/format/src/sketch/fixture.rs +++ b/crates/format/src/sketch/fixture.rs @@ -186,3 +186,21 @@ pub(crate) fn instance(id: &str, symbol_id: &str, x: f64, y: f64, w: f64, h: f64 Value::Array(overrides.iter().map(|(name, value)| json!({"_class": "overrideValue", "overrideName": name, "value": value})).collect()); l } + +/// A Sketch 2025+ frame (a group that behaves as a frame) holding `layers`. +pub(crate) fn frame_group(id: &str, x: f64, y: f64, w: f64, h: f64, layers: Vec) -> Value { + let mut l = group(id, x, y, w, h, layers); + l["groupBehavior"] = json!(1); + l["clippingBehavior"] = json!(0); + l +} + +/// `l` as a stack (Sketch 2025+): `direction` 0 row, 1 column. +pub(crate) fn stack(mut l: Value, direction: i64, justify: i64, align: i64, gap: f64, padding: [f64; 4]) -> Value { + l["groupLayout"] = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": direction, "justifyContent": justify, "alignItems": align, + "allGuttersGap": gap, "crossAxisGutterGap": 0, "wrappingEnabled": false, "alignContent": 0, "stackingOrder": 0, "bordersAffectLayout": false}); + for (k, v) in ["leftPadding", "topPadding", "rightPadding", "bottomPadding"].iter().zip(padding) { + l[*k] = json!(v); + } + l +} diff --git a/crates/format/src/sketch/frames_tests.rs b/crates/format/src/sketch/frames_tests.rs new file mode 100644 index 00000000..aadc4b5f --- /dev/null +++ b/crates/format/src/sketch/frames_tests.rs @@ -0,0 +1,385 @@ +//! Sketch 2025 and newer: frames and graphics (groups with a `groupBehavior`), stacks, corners in +//! the style, pins and sizing, border sides. + +use figcraft_doc::{Document, LayoutMode, Node, NodeKind}; +use serde_json::{Value, json}; + +use super::fixture::*; +use super::import; + +fn open(layers: Vec) -> (Document, Vec) { + let z = sketch_zip("2026.3.1", 196, document(&["P"]), &[page("P", "Page", layers)], &[]); + import(&z, "f").unwrap() +} + +fn node<'d>(doc: &'d Document, name: &str) -> &'d Node { + doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no {name}")) +} + +#[test] +fn frames_graphics_and_clipping() { + let mut clip_none = frame_group("Loose", 0.0, 0.0, 50.0, 50.0, vec![]); + clip_none["clippingBehavior"] = json!(2); + let mut graphic = frame_group("Icon", 100.0, 0.0, 24.0, 24.0, vec![]); + graphic["groupBehavior"] = json!(2); + let mut plain = group("Plain", 200.0, 0.0, 10.0, 10.0, vec![rect("Inside", 0.0, 0.0, 10.0, 10.0)]); + plain["groupBehavior"] = json!(0); + let mut filled = frame_group("Filled", 300.0, 0.0, 50.0, 50.0, vec![]); + filled["style"]["fills"] = json!([solid_fill(1.0, 0.0, 0.0, 1.0)]); + let (doc, _) = open(vec![frame_group("Screen", 0.0, 100.0, 390.0, 844.0, vec![]), clip_none, graphic, plain, filled]); + assert!(matches!(node(&doc, "Screen").kind, NodeKind::Frame) && node(&doc, "Screen").clip); + assert!(!node(&doc, "Loose").clip); + assert!(matches!(node(&doc, "Icon").kind, NodeKind::Frame) && node(&doc, "Icon").clip); + assert!(matches!(node(&doc, "Plain").kind, NodeKind::Group)); + assert_eq!(node(&doc, "Filled").fills.len(), 1); + // Frames show what their style says: no white background of their own. + assert!(node(&doc, "Screen").fills.is_empty()); +} + +#[test] +fn corners_from_the_style() { + let mut a = rect("Each", 0.0, 0.0, 40.0, 40.0); + a["style"]["corners"] = + json!({"_class": "MSImmutableStyleCorners", "radii": [1, 2, 3, 4], "style": 0, "smoothing": 0.6, "prefersConcentric": false}); + let mut b = rect("Smooth", 50.0, 0.0, 40.0, 40.0); + b["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [12], "style": 1, "smoothing": 0.6, "prefersConcentric": false}); + let mut f = frame_group("Card", 100.0, 0.0, 100.0, 100.0, vec![]); + f["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [16, 16, 16, 16], "style": 0, "smoothing": 0.6}); + let (doc, _) = open(vec![a, b, f]); + assert_eq!(node(&doc, "Each").corners.radii, [1.0, 2.0, 3.0, 4.0]); + assert_eq!(node(&doc, "Smooth").corners.radii, [12.0; 4]); + assert!((node(&doc, "Smooth").corners.smoothing - 0.6).abs() < 1e-9); + assert_eq!(node(&doc, "Each").corners.smoothing, 0.0); + assert_eq!(node(&doc, "Card").corners.radii, [16.0; 4]); +} + +#[test] +fn stacks_become_auto_layout() { + // A row: Sketch lists the first item last (on top); justify evenly, centred, gap 8. + let a = rect("First", 0.0, 0.0, 20.0, 20.0); + let b = with(rect("Second", 0.0, 0.0, 30.0, 10.0), "horizontalSizing", json!(2)); + let mut free = rect("Free", 0.0, 0.0, 5.0, 5.0); + free["flexItem"] = json!({"_class": "MSImmutableFlexItem", "ignoreLayout": true, "alignSelf": 5, "preserveSpaceWhenHidden": false}); + let mut row = stack(frame_group("Row", 0.0, 0.0, 200.0, 40.0, vec![free, b, a]), 0, 5, 1, 8.0, [4.0, 6.0, 10.0, 2.0]); + row["horizontalSizing"] = json!(0); + row["verticalSizing"] = json!(1); + let col = stack(frame_group("Column", 0.0, 100.0, 100.0, 100.0, vec![rect("One", 0.0, 0.0, 10.0, 10.0)]), 1, 3, 3, 0.0, [0.0; 4]); + let (doc, _) = open(vec![row, col]); + let r = node(&doc, "Row"); + let l = r.layout.as_ref().expect("auto layout"); + assert_eq!(l.mode, LayoutMode::Horizontal); + assert_eq!(l.gap, 8.0); + assert_eq!(l.padding, [6.0, 10.0, 2.0, 4.0], "top, right, bottom, left"); + assert!(l.reverse_z, "the first item is on top, as in Sketch"); + let text = format!("{l:?}"); + assert!(text.contains("SpaceEvenly") || text.contains("Evenly"), "{text}"); + let order: Vec<&str> = r.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(order, ["First", "Second", "Free"], "flow order is the reverse of Sketch's list"); + assert!(node(&doc, "Free").child_layout.absolute); + assert_eq!(node(&doc, "Second").child_layout.sizing_h, figcraft_doc::Sizing::Fill); + assert_eq!(r.child_layout.sizing_v, figcraft_doc::Sizing::Hug); + let c = node(&doc, "Column").layout.as_ref().unwrap(); + assert_eq!(c.mode, LayoutMode::Vertical); + assert!(format!("{c:?}").contains("SpaceBetween"), "{c:?}"); +} + +/// A frame resized as an instance's layer follows its pins and sizing (Sketch 2025+ keep them in +/// `horizontalPins`/`horizontalSizing`, `resizingConstraint` stays 63). +#[test] +fn pins_and_sizing_resize_instances() { + let mut r = rect("R", 20.0, 30.0, 100.0, 40.0); + r["horizontalPins"] = json!(1); // left + r["horizontalSizing"] = json!(0); // fixed + r["verticalPins"] = json!(0); + r["verticalSizing"] = json!(3); // relative + let mut m = master("M", "S", "Box", 200.0, 100.0, vec![r]); + m["groupBehavior"] = json!(1); + let (doc, _) = { + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 320.0, 60.0, &[])]), page("S", "Symbols", vec![m])], + &[], + ); + import(&z, "f").unwrap() + }; + let inst = doc.nodes().find(|n| n.name == "I").unwrap(); + let r = doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "R").unwrap(); + let b = doc.world_bounds(r.id).unwrap(); + assert!((b.x0 - 20.0).abs() < 0.5 && (b.width() - 100.0).abs() < 0.5, "{b:?}"); + assert!((b.y0 - 18.0).abs() < 0.5 && (b.height() - 24.0).abs() < 0.5, "{b:?}"); +} + +#[test] +fn graphics_scale_their_layers() { + let mut r = rect("R", 20.0, 30.0, 100.0, 40.0); + r["horizontalPins"] = json!(5); + r["horizontalSizing"] = json!(0); + let mut m = master("M", "S", "Glyph", 200.0, 100.0, vec![r]); + m["groupBehavior"] = json!(2); + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 400.0, 200.0, &[])]), page("S", "Symbols", vec![m])], + &[], + ); + let (doc, _) = import(&z, "f").unwrap(); + let inst = doc.nodes().find(|n| n.name == "I").unwrap(); + let r = doc.subtree(inst.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "R").unwrap(); + let b = doc.world_bounds(r.id).unwrap(); + assert!((b.x0 - 40.0).abs() < 0.5 && (b.width() - 200.0).abs() < 0.5, "{b:?}"); +} + +#[test] +fn border_sides() { + let r = styled( + rect("Sided", 0.0, 0.0, 40.0, 40.0), + "borders", + json!([{"_class": "border", "isEnabled": true, "fillType": 0, "color": color(0.0, 0.0, 0.0, 1.0), "position": 1, "thickness": 8, + "sides": {"_class": "MSImmutableStyleBorderSides", "leftInset": 7, "rightInset": 7, "topInset": 0, "bottomInset": 5}}]), + ); + // A side is as thick as the border less its inset. + let (doc, _) = open(vec![r]); + assert_eq!(node(&doc, "Sided").stroke.sides, Some([8.0, 1.0, 3.0, 1.0])); +} + +#[test] +fn swatches_bind_solid_fills_only() { + let mut d = document(&["P"]); + d["sharedSwatches"] = json!({"_class": "swatchContainer", "objects": [{"_class": "swatch", "do_objectID": "SW", "name": "Brand", "value": color(0.0, 0.0, 1.0, 1.0)}]}); + let mut g = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 1.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(1.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 1.0, 1.0)}]}}); + g["color"]["swatchID"] = json!("SW"); + let z = sketch_zip("2026.3.1", 196, d, &[page("P", "Page", vec![styled(rect("G", 0.0, 0.0, 10.0, 10.0), "fills", json!([g]))])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(!warnings.iter().any(|w| w.contains("not supported")), "{warnings:?}"); +} + +/// Sketch 2025+ masters keep their background, corners and stack in their style and layout, and +/// their instances show the same. +#[test] +fn masters_and_instances_keep_frame_styles_and_stacks() { + let mut m = master("M", "S", "Button", 120.0, 44.0, vec![rect("Icon", 0.0, 0.0, 20.0, 20.0), rect("Label", 0.0, 0.0, 60.0, 20.0)]); + m["groupBehavior"] = json!(1); + m["clippingBehavior"] = json!(2); + m["style"]["fills"] = json!([solid_fill(0.0, 0.0, 1.0, 1.0)]); + m["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [8], "style": 1, "smoothing": 0.6}); + let m = stack(m, 0, 1, 1, 10.0, [16.0, 12.0, 16.0, 12.0]); + let z = sketch_zip( + "2026.3.1", + 196, + document(&["P", "S"]), + &[page("P", "Page", vec![instance("I", "S", 0.0, 0.0, 120.0, 44.0, &[])]), page("S", "Symbols", vec![m])], + &[], + ); + let (doc, _) = import(&z, "f").unwrap(); + let main = doc.nodes().find(|n| matches!(n.kind, NodeKind::Component { .. })).unwrap(); + let inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap(); + for n in [main, inst] { + assert_eq!(n.fills.len(), 1, "{}", n.name); + assert_eq!(n.corners.radii, [8.0; 4], "{}", n.name); + assert!(n.layout.is_some(), "{}", n.name); + assert!(!n.clip, "{}", n.name); + let order: Vec<&str> = n.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect(); + assert_eq!(order, ["Label", "Icon"], "{}", n.name); + } +} + +fn solid_of(n: &Node) -> Option<[f32; 3]> { + n.fills.iter().find_map(|p| match &p.kind { + figcraft_doc::color::PaintKind::Solid { color } => Some([color.r, color.g, color.b]), + _ => None, + }) +} + +fn open_symbols(layers: Vec, masters: Vec) -> Document { + let z = sketch_zip("2026.3.1", 196, document(&["P", "S"]), &[page("P", "Page", layers), page("S", "Symbols", masters)], &[]); + import(&z, "f").unwrap().0 +} + +fn in_instance<'d>(doc: &'d Document, inst: &str, name: &str) -> &'d Node { + let i = doc.nodes().find(|n| n.name == inst && matches!(n.kind, NodeKind::Instance { .. })).unwrap_or_else(|| panic!("no {inst}")); + doc.subtree(i.id).into_iter().filter_map(|id| doc.node(id)).find(|n| n.name == name).unwrap_or_else(|| panic!("no {name} in {inst}")) +} + +/// A graphic's fill tints its layers (it's no background); an instance's fill, or a `fillColor` +/// override, tints the instance's layers. +#[test] +fn fills_of_graphics_and_instances_are_tints() { + let glyph = styled(rect("Glyph", 0.0, 0.0, 24.0, 24.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut icon = master("MI", "SI", "Icon", 24.0, 24.0, vec![glyph]); + icon["groupBehavior"] = json!(2); + let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0); + tint["layeringType"] = json!(1); + icon["style"]["fills"] = json!([tint]); + let nested = instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[]); + let mut button = master("MB", "SB", "Button", 100.0, 40.0, vec![nested]); + button["groupBehavior"] = json!(1); + let own = instance("Own", "SI", 0.0, 0.0, 24.0, 24.0, &[("Own_fillColor", color(0.0, 0.0, 1.0, 1.0))]); + let via = instance("Via", "SB", 0.0, 100.0, 100.0, 40.0, &[("N_fillColor", color(0.0, 1.0, 0.0, 1.0))]); + let doc = open_symbols(vec![own, via], vec![icon, button]); + let main = doc.nodes().find(|n| n.name == "Icon" && matches!(n.kind, NodeKind::Component { .. })).unwrap(); + assert!(main.fills.is_empty(), "a graphic's fill isn't its background"); + let in_main = doc.subtree(main.id).into_iter().filter_map(|i| doc.node(i)).find(|n| n.name == "Glyph").unwrap(); + assert_eq!(solid_of(in_main), Some([1.0, 0.0, 0.0])); + assert_eq!(solid_of(in_instance(&doc, "Own", "Glyph")), Some([0.0, 0.0, 1.0])); + assert!(doc.nodes().find(|n| n.name == "Own").unwrap().fills.is_empty()); + assert_eq!(solid_of(in_instance(&doc, "Via", "Glyph")), Some([0.0, 1.0, 0.0])); +} + +#[test] +fn text_color_overrides() { + let label = text("Label", 0.0, 0.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + let m = master("M", "S", "Button", 100.0, 40.0, vec![label]); + let doc = open_symbols(vec![instance("I", "S", 0.0, 0.0, 100.0, 40.0, &[("Label_textColor", color(1.0, 0.0, 0.0, 1.0))])], vec![m]); + let n = in_instance(&doc, "I", "Label"); + let red = |p: &figcraft_doc::color::Paint| matches!(p.kind, figcraft_doc::color::PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0); + let NodeKind::Text(td) = &n.kind else { panic!("not text") }; + assert!(n.fills.iter().any(red) || td.style.fills.as_ref().is_some_and(|f| f.iter().any(red)), "{:?} {:?}", n.fills, td.style.fills); +} + +#[test] +fn blurs_list() { + let blur = |ty: i64, r: f64| json!({"_class": "blur", "isEnabled": true, "type": ty, "radius": r, "center": "{0.5, 0.5}", "motionAngle": 25}); + let g = styled(rect("Glow", 0.0, 0.0, 40.0, 40.0), "blurs", json!([blur(0, 90.0)])); + let m = styled(rect("Comet", 50.0, 0.0, 40.0, 4.0), "blurs", json!([blur(1, 6.0)])); + let b = styled(rect("Glass", 100.0, 0.0, 40.0, 40.0), "blurs", json!([blur(3, 10.0)])); + let (doc, warnings) = open(vec![g, m, b]); + assert!(matches!(node(&doc, "Glow").effects[0].kind, figcraft_doc::EffectKind::LayerBlur(_))); + assert!(matches!(node(&doc, "Glass").effects[0].kind, figcraft_doc::EffectKind::BackgroundBlur(_))); + assert!(matches!(node(&doc, "Comet").effects[0].kind, figcraft_doc::EffectKind::LayerBlur(_)), "motion blur drawn as a layer blur"); + assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}"); +} + +#[test] +fn text_sizing_2025() { + let mut fixed = text("Fixed", 0.0, 0.0, 100.0, 40.0, "Wraps here", &[(0, 10, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + fixed.as_object_mut().unwrap().remove("textBehaviour"); + fixed["horizontalSizing"] = json!(0); + fixed["verticalSizing"] = json!(1); + let mut fit = text("Fit", 0.0, 50.0, 100.0, 20.0, "Grows", &[(0, 5, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + fit.as_object_mut().unwrap().remove("textBehaviour"); + fit["horizontalSizing"] = json!(1); + fit["verticalSizing"] = json!(1); + let (doc, _) = open(vec![fixed, fit]); + let auto = |n: &str| match &node(&doc, n).kind { + NodeKind::Text(td) => td.auto_resize, + _ => panic!("not text"), + }; + assert_eq!(auto("Fixed"), figcraft_doc::TextAutoResize::Height); + assert_eq!(auto("Fit"), figcraft_doc::TextAutoResize::WidthAndHeight); +} + +#[test] +fn stacks_are_no_smart_layout_note() { + let (_, warnings) = open(vec![stack(frame_group("Row", 0.0, 0.0, 100.0, 40.0, vec![rect("A", 0.0, 0.0, 10.0, 10.0)]), 0, 0, 0, 0.0, [0.0; 4])]); + assert!(!warnings.iter().any(|w| w.contains("Smart Layout")), "{warnings:?}"); +} + +/// Sketch 2025+: a fill with `layeringType` 1 is a tint (of a frame or master too), 0 a background. +#[test] +fn layering_type_decides_tint_or_background() { + let dot = styled(rect("Dot", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut tint = solid_fill(0.0, 0.0, 1.0, 1.0); + tint["layeringType"] = json!(1); + let mut bar = master("MB", "SB", "Bar", 100.0, 20.0, vec![dot]); + bar["groupBehavior"] = json!(1); + bar["style"]["fills"] = json!([tint]); + let mut bg = solid_fill(1.0, 0.0, 0.0, 1.0); + bg["layeringType"] = json!(0); + let mut btn = master("MC", "SC", "Btn", 100.0, 20.0, vec![]); + btn["groupBehavior"] = json!(1); + btn["style"]["fills"] = json!([bg]); + let doc = open_symbols(vec![instance("B", "SB", 0.0, 0.0, 100.0, 20.0, &[]), instance("C", "SC", 0.0, 50.0, 100.0, 20.0, &[])], vec![bar, btn]); + let b = doc.nodes().find(|n| n.name == "B").unwrap(); + assert!(b.fills.is_empty(), "a tint isn't a background"); + assert_eq!(solid_of(in_instance(&doc, "B", "Dot")), Some([0.0, 0.0, 1.0])); + assert_eq!(solid_of(doc.nodes().find(|n| n.name == "C").unwrap()), Some([1.0, 0.0, 0.0])); +} + +#[test] +fn inner_shadows_in_the_shadow_list() { + let mut sh = + json!({"_class": "shadow", "isEnabled": true, "blurRadius": 4, "offsetX": 0, "offsetY": 2, "spread": 0, "color": color(0.0, 0.0, 0.0, 0.5)}); + sh["isInnerShadow"] = json!(true); + let (doc, _) = open(vec![styled(rect("Inset", 0.0, 0.0, 40.0, 40.0), "shadows", json!([sh]))]); + assert!(matches!(node(&doc, "Inset").effects[0].kind, figcraft_doc::EffectKind::InnerShadow(_))); +} + +#[test] +fn visibility_overrides() { + let m = master("M", "S", "Input", 100.0, 40.0, vec![rect("Label", 0.0, 0.0, 50.0, 10.0)]); + let doc = open_symbols(vec![instance("I", "S", 0.0, 0.0, 100.0, 40.0, &[("Label_isVisible", json!("0"))])], vec![m]); + assert!(!in_instance(&doc, "I", "Label").visible); +} + +/// An instance that fits its content hugs it even when its master has a fixed size. +#[test] +fn instance_sizing_is_its_own() { + let m = stack(master("M", "S", "Chip", 60.0, 24.0, vec![rect("A", 0.0, 0.0, 20.0, 20.0)]), 0, 0, 1, 0.0, [0.0; 4]); + let mut i = instance("I", "S", 0.0, 0.0, 60.0, 24.0, &[]); + i["horizontalSizing"] = json!(1); + let doc = open_symbols(vec![i], vec![m]); + let n = doc.nodes().find(|n| n.name == "I").unwrap(); + assert_eq!(n.layout.as_ref().map(|_| format!("{:?}", n.child_layout.sizing_h)), Some("Hug".to_string())); +} + +/// A tint keeps each layer's own opacity. +#[test] +fn tints_keep_opacity() { + let faint = styled(rect("Faint", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 0.25)])); + let mut g = group("Tinted", 0.0, 0.0, 10.0, 10.0, vec![faint]); + let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0); + tint["layeringType"] = json!(1); + g["groupBehavior"] = json!(0); + g["style"] = json!({"_class": "style", "fills": [tint]}); + let (doc, _) = open(vec![g]); + let f = &node(&doc, "Faint").fills[0]; + let figcraft_doc::color::PaintKind::Solid { color } = &f.kind else { panic!("not solid") }; + assert_eq!((color.r, color.g), (1.0, 0.0)); + assert!((color.a * f.opacity - 0.25).abs() < 1e-3, "{color:?} {}", f.opacity); + assert!(node(&doc, "Tinted").fills.is_empty()); +} + +#[test] +fn glass_and_progressive_blurs() { + let glass = json!({"_class": "blur", "isEnabled": true, "type": 4, "radius": 10, "distortion": 0.6, "depth": 0.7, "center": "{0.5, 0.5}"}); + let prog = json!({"_class": "blur", "isEnabled": true, "type": 3, "radius": 24, "isProgressive": true, "center": "{0.5, 0.5}", + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}}); + let (doc, warnings) = open(vec![ + styled(rect("Glass", 0.0, 0.0, 40.0, 40.0), "blurs", json!([glass])), + styled(rect("Prog", 50.0, 0.0, 40.0, 40.0), "blurs", json!([prog])), + ]); + assert!(matches!(node(&doc, "Glass").effects[0].kind, figcraft_doc::EffectKind::Glass(_)), "{warnings:?}"); + let figcraft_doc::EffectKind::BackgroundBlur(b) = &node(&doc, "Prog").effects[0].kind else { panic!("not a background blur") }; + assert_eq!(b.blur_type, figcraft_doc::BlurType::Progressive); + assert_eq!((b.start_radius, b.radius), (24.0, 0.0)); +} + +/// Angled (2), inside-square (3) and inside-arc (4) corners make the rectangle a path of that outline. +#[test] +fn cut_corners_become_paths() { + let cut = |name: &str, style: i64| { + let mut r = rect(name, 0.0, 0.0, 100.0, 80.0); + r["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [10], "style": style, "smoothing": 0.6}); + r + }; + let (doc, _) = open(vec![cut("Angled", 2), cut("Square", 3), cut("Arc", 4)]); + let net = |n: &str| match &node(&doc, n).kind { + NodeKind::Vector { network } => network.clone(), + other => panic!("{n}: {other:?}"), + }; + let a = net("Angled"); + assert_eq!(a.vertices.len(), 8); + assert!(a.vertices.iter().any(|v| (v.x, v.y) == (0.0, 10.0)) && a.vertices.iter().any(|v| (v.x, v.y) == (10.0, 0.0))); + let s = net("Square"); + assert_eq!(s.vertices.len(), 12); + assert!(s.vertices.iter().any(|v| (v.x, v.y) == (10.0, 10.0)) && s.vertices.iter().any(|v| (v.x, v.y) == (90.0, 70.0))); + let arc = net("Arc"); + assert_eq!(arc.vertices.len(), 8); + assert!(arc.segments.iter().any(|g| g.ta != (0.0, 0.0)), "the corners are arcs"); +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 576dbe06..8d8a465e 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -13,6 +13,8 @@ #[cfg(test)] mod fixture; +#[cfg(test)] +mod frames_tests; mod path; #[cfg(test)] mod path_tests; @@ -22,6 +24,7 @@ mod proto_tests; mod shared; #[cfg(test)] mod shared_tests; +mod stack; mod style; #[cfg(test)] mod style_tests; diff --git a/crates/format/src/sketch/path.rs b/crates/format/src/sketch/path.rs index 149b4870..24ceca15 100644 --- a/crates/format/src/sketch/path.rs +++ b/crates/format/src/sketch/path.rs @@ -100,6 +100,65 @@ pub(crate) fn network(cx: &mut Cx, l: &Value, w: f64, h: f64, fill_open: bool) - Some(VectorNetwork { vertices, segments, regions }) } +/// Rectangle `l` with angled (corner style 2), inside-square (3) or inside-arc (4) corners in its +/// style (Sketch 2025 and newer) as an edited rectangle whose points draw that outline, the +/// corners' `radii` (top left, top right, bottom right, bottom left; repeating) being how far +/// each cut reaches along both edges. `None` for any other layer. +pub(crate) fn cut_corners(l: &Value) -> Option { + if l.get("_class").and_then(Value::as_str) != Some("rectangle") || l.get("edited").and_then(Value::as_bool) == Some(true) { + return None; + } + let c = l.get("style")?.get("corners")?; + let style = c.get("style").and_then(Value::as_i64).filter(|s| (2..=4).contains(s))?; + let radii: Vec = c.get("radii")?.as_array()?.iter().filter_map(Value::as_f64).filter(|r| r.is_finite()).take(4).collect(); + if radii.iter().all(|r| *r <= 0.0) { + return None; + } + let (w, h) = size(l); + if w <= 0.0 || h <= 0.0 { + return None; + } + // Arc handles: a quarter circle's. + const K: f64 = 0.552_284_749_8; + let fmt = |(x, y): (f64, f64)| format!("{{{}, {}}}", x / w, y / h); + let curve = |p: (f64, f64), from: Option<(f64, f64)>, to: Option<(f64, f64)>| { + json!({"_class": "curvePoint", "cornerRadius": 0, "point": fmt(p), "curveMode": if from.or(to).is_some() { 4 } else { 1 }, + "hasCurveFrom": from.is_some(), "hasCurveTo": to.is_some(), "curveFrom": fmt(from.unwrap_or(p)), "curveTo": fmt(to.unwrap_or(p))}) + }; + // Corners clockwise from the top left, each with the directions to the edge before and after. + let corners = [ + ((0.0, 0.0), (0.0, 1.0), (1.0, 0.0)), + ((w, 0.0), (-1.0, 0.0), (0.0, 1.0)), + ((w, h), (0.0, -1.0), (-1.0, 0.0)), + ((0.0, h), (1.0, 0.0), (0.0, -1.0)), + ]; + let mut points = Vec::new(); + for (i, ((cx, cy), (px, py), (nx, ny))) in corners.into_iter().enumerate() { + let r = radii.get(i % radii.len()).copied().unwrap_or(0.0).clamp(0.0, w.min(h) / 2.0); + if r <= 0.0 { + points.push(curve((cx, cy), None, None)); + continue; + } + let a = (cx + r * px, cy + r * py); + let b = (cx + r * nx, cy + r * ny); + match style { + 2 => points.extend([curve(a, None, None), curve(b, None, None)]), + 3 => points.extend([curve(a, None, None), curve((cx + r * (px + nx), cy + r * (py + ny)), None, None), curve(b, None, None)]), + // A quarter circle about the corner, bulging into the shape. + _ => points + .extend([curve(a, Some((a.0 + K * r * nx, a.1 + K * r * ny)), None), curve(b, None, Some((b.0 + K * r * px, b.1 + K * r * py)))]), + } + } + let mut out = l.clone(); + out["edited"] = json!(true); + out["isClosed"] = json!(true); + out["points"] = Value::Array(points); + if let Some(st) = out.get_mut("style").and_then(Value::as_object_mut) { + st.remove("corners"); + } + Some(out) +} + /// A path, star, polygon or triangle layer: a star or polygon while its points are still the /// generated ones (same as FigCraft's, within half a percent), else an editable vector. pub(crate) fn shape(cx: &mut Cx, l: &Value, w: f64, h: f64) -> Option { diff --git a/crates/format/src/sketch/proto.rs b/crates/format/src/sketch/proto.rs index afa6df48..1a08d268 100644 --- a/crates/format/src/sketch/proto.rs +++ b/crates/format/src/sketch/proto.rs @@ -104,11 +104,12 @@ pub(crate) fn layout_grids(l: &Value, h: f64, node: &mut JsonMap) } } -/// Notes about what the layer has that FigCraft keeps only as laid out: Smart Layout and Stacks, +/// Notes about what the layer has that FigCraft keeps only as laid out: Smart Layout, /// and overrides a master locks. pub(crate) fn notes(cx: &mut Cx, l: &Value) { - if l.get("groupLayout").and_then(|g| str_field(g, "_class")).is_some_and(|c| c != "MSImmutableFreeformGroupLayout") { - cx.warn("Smart Layout and stacks were imported as laid out; they don't resize with their content"); + // Stacks become auto layout; the older Smart Layout doesn't. + if l.get("groupLayout").and_then(|g| str_field(g, "_class")) == Some("MSImmutableInferredGroupLayout") { + cx.warn("Smart Layout was imported as laid out; it doesn't resize with its content"); } let locked = l.get("overrideProperties").and_then(Value::as_array).is_some_and(|p| p.iter().any(|o| bool_field(o, "canOverride") == Some(false))); if locked { diff --git a/crates/format/src/sketch/stack.rs b/crates/format/src/sketch/stack.rs new file mode 100644 index 00000000..a6b2ddb2 --- /dev/null +++ b/crates/format/src/sketch/stack.rs @@ -0,0 +1,99 @@ +//! Sketch 2025+ stacks (`groupLayout` `MSImmutableFlexGroupLayout`) → REST auto layout. +//! +//! The enums follow the order Sketch's plugin API documents them in (checked against files): +//! direction row, column; justify start, center, end, between, around, evenly; align items start, +//! center, end, stretch, none; stacking order first on top, last on top. Sizing (`horizontalSizing`, +//! `verticalSizing`) is fixed, fit (hug), fill, relative. Sketch lists a stack's first item last +//! (on top in the layer list), so the flow order is the reverse of its `layers`. + +use serde_json::{Map as JsonMap, Value, json}; + +/// Whether `l` is a stack. +pub(crate) fn is_stack(l: &Value) -> bool { + l.get("groupLayout").and_then(|g| g.get("_class")).and_then(Value::as_str) == Some("MSImmutableFlexGroupLayout") +} + +/// Make `node` (the REST node of frame `l`, its children already built in Sketch's order) an auto +/// layout frame when `l` is a stack. +pub(crate) fn apply(l: &Value, node: &mut JsonMap) { + if !is_stack(l) || !matches!(node.get("type").and_then(Value::as_str), Some("FRAME" | "COMPONENT" | "INSTANCE")) { + return; + } + let Some(g) = l.get("groupLayout") else { return }; + let int = |k: &str| g.get(k).and_then(Value::as_i64).unwrap_or(0); + let num = |v: &Value, k: &str| v.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(0.0).clamp(0.0, 1e5); + let row = int("flexDirection") == 0; + node.insert("layoutMode".into(), json!(if row { "HORIZONTAL" } else { "VERTICAL" })); + node.insert("itemSpacing".into(), json!(num(g, "allGuttersGap"))); + node.insert( + "primaryAxisAlignItems".into(), + json!(match int("justifyContent") { + 1 => "CENTER", + 2 => "MAX", + 3 => "SPACE_BETWEEN", + 4 => "SPACE_AROUND", + 5 => "SPACE_EVENLY", + _ => "MIN", + }), + ); + let stretch = int("alignItems") == 3; + node.insert( + "counterAxisAlignItems".into(), + json!(match int("alignItems") { + 1 => "CENTER", + 2 => "MAX", + _ => "MIN", + }), + ); + if g.get("wrappingEnabled").and_then(Value::as_bool) == Some(true) { + node.insert("layoutWrap".into(), json!("WRAP")); + node.insert("counterAxisSpacing".into(), json!(num(g, "crossAxisGutterGap"))); + } + for (k, rest) in + [("leftPadding", "paddingLeft"), ("topPadding", "paddingTop"), ("rightPadding", "paddingRight"), ("bottomPadding", "paddingBottom")] + { + node.insert(rest.into(), json!(num(l, k))); + } + node.insert("itemReverseZIndex".into(), json!(int("stackingOrder") == 0)); + node.insert("strokesIncludedInLayout".into(), json!(g.get("bordersAffectLayout").and_then(Value::as_bool) == Some(true))); + // The stack's own sizing: fit hugs its content. + let (main, cross) = if row { ("horizontalSizing", "verticalSizing") } else { ("verticalSizing", "horizontalSizing") }; + let mode = |k: &str| if l.get(k).and_then(Value::as_i64) == Some(1) { "AUTO" } else { "FIXED" }; + node.insert("primaryAxisSizingMode".into(), json!(mode(main))); + node.insert("counterAxisSizingMode".into(), json!(mode(cross))); + // Items: flow order, sizing, absolute ones. + let sources: Vec<&Value> = super::tree::layers(l).iter().collect(); + if let Some(kids) = node.get_mut("children").and_then(Value::as_array_mut) { + // Children were built one per source layer, skipped ones aside: match them by id. + for kid in kids.iter_mut() { + let id = kid.get("id").and_then(Value::as_str).map(str::to_string); + let Some(src) = sources.iter().find(|s| id.is_some() && s.get("do_objectID").and_then(Value::as_str) == id.as_deref()) else { continue }; + let Some(m) = kid.as_object_mut() else { continue }; + item(src, m, row, stretch); + } + kids.reverse(); + } +} + +/// An item's place in its stack: absolute, or sized fixed, hugging or filling. +fn item(src: &Value, m: &mut JsonMap, row: bool, stretch: bool) { + if src.get("flexItem").and_then(|f| f.get("ignoreLayout")).and_then(Value::as_bool) == Some(true) { + m.insert("layoutPositioning".into(), json!("ABSOLUTE")); + return; + } + let sizing = |k: &str| match src.get(k).and_then(Value::as_i64) { + Some(1) => "HUG", + Some(2) => "FILL", + _ => "FIXED", + }; + let (h, v) = (sizing("horizontalSizing"), sizing("verticalSizing")); + m.insert("layoutSizingHorizontal".into(), json!(h)); + m.insert("layoutSizingVertical".into(), json!(v)); + let (main, cross) = if row { (h, v) } else { (v, h) }; + if main == "FILL" { + m.insert("layoutGrow".into(), json!(1)); + } + if cross == "FILL" || stretch { + m.insert("layoutAlign".into(), json!("STRETCH")); + } +} diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index f13515a6..13320673 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -38,7 +38,8 @@ pub(crate) fn apply(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonM /// A style's fills as REST paints. pub(crate) fn fills(cx: &mut Cx, style: &Value, w: f64, h: f64) -> Vec { - list(style, "fills").iter().filter_map(|f| paint(cx, f, w, h)).collect() + // Tints (`layeringType` 1) colour the layers inside; they aren't paints of the layer. + list(style, "fills").iter().filter(|f| f.get("layeringType").and_then(Value::as_i64) != Some(1)).filter_map(|f| paint(cx, f, w, h)).collect() } /// A style's borders as REST paints. @@ -101,7 +102,10 @@ fn paint(cx: &mut Cx, f: &Value, w: f64, h: f64) -> Option { } }; p["visible"] = json!(f.get("isEnabled").and_then(Value::as_bool).unwrap_or(true)); - if let Some(var) = swatch(cx, f.get("color")) { + // A colour variable binds a plain colour (a gradient's or image's `color` is unused). + if f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0 + && let Some(var) = swatch(cx, f.get("color")) + { p["boundVariables"] = var; } if let Some(ctx) = f.get("contextSettings") { @@ -131,7 +135,8 @@ fn gradient(g: &Value, w: f64, h: f64) -> Option { let across = if w > 0.0 && h > 0.0 { (from.0 - dy * e / w, from.1 + dx * e / h) } else { from }; ("GRADIENT_RADIAL", json!([pt(from), pt(to), pt(across)])) } - 2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((0.5, 0.0))])), + // Sketch starts on the right and turns clockwise; from/to don't change it. + 2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((1.0, 0.5)), pt((0.5, 1.0))])), _ => ("GRADIENT_LINEAR", json!([pt(from), pt(to)])), }; Some(json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops})) @@ -167,6 +172,18 @@ fn borders(cx: &mut Cx, style: &Value, w: f64, h: f64, node: &mut JsonMap = o .get("dashPattern") @@ -218,6 +235,8 @@ pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { let mut out = Vec::new(); for (key, ty) in [("shadows", "DROP_SHADOW"), ("innerShadows", "INNER_SHADOW")] { for s in list(style, key) { + // Sketch 2025 and newer keep inner shadows in the one list, marked. + let ty = if s.get("isInnerShadow").and_then(Value::as_bool) == Some(true) { "INNER_SHADOW" } else { ty }; let mut c = s.get("color").and_then(color).unwrap_or_else(|| json!({"r": 0.0, "g": 0.0, "b": 0.0, "a": 0.5})); let o = s.get("contextSettings").and_then(|c| num(c, "opacity")).unwrap_or(1.0).clamp(0.0, 1.0); c["a"] = json!(c["a"].as_f64().unwrap_or(1.0) * o); @@ -231,18 +250,54 @@ pub(crate) fn effects(cx: &mut Cx, style: &Value) -> Vec { })); } } - if let Some(b) = style.get("blur").filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) { + // One `blur` (older files) or a list of `blurs` (Sketch 2025 and newer). + let one = style.get("blur").into_iter(); + let many = style.get("blurs").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default().iter().take(MAX_LIST); + for b in one.chain(many).filter(|b| b.get("isEnabled").and_then(Value::as_bool) == Some(true)) { let radius = num(b, "radius").unwrap_or(0.0).clamp(0.0, 1e6); match b.get("type").and_then(Value::as_i64).unwrap_or(0) { 0 => out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius})), - 3 => out.push(json!({"type": "BACKGROUND_BLUR", "visible": true, "radius": radius})), - _ => cx.warn("motion and zoom blurs were skipped"), + 3 => out.push(background_blur(b, radius)), + 4 => out.push(json!({ + "type": "GLASS", + "visible": true, + "radius": radius, + "refraction": num(b, "distortion").unwrap_or(0.5).clamp(0.0, 1.0), + "depth": num(b, "depth").unwrap_or(0.5).clamp(0.0, 1.0), + })), + _ => { + // Motion and zoom blurs: a plain blur of half the radius softens about as much. + cx.warn("motion and zoom blurs were drawn as plain blurs"); + out.push(json!({"type": "LAYER_BLUR", "visible": true, "radius": radius / 2.0})); + } } } out.truncate(MAX_LIST); out } +/// A background blur; a progressive one (`isProgressive`) ramps along its gradient, whose stops' +/// opacity scales the radius at each end. +fn background_blur(b: &Value, radius: f64) -> Value { + let g = b.get("gradient").filter(|_| b.get("isProgressive").and_then(Value::as_bool) == Some(true)); + let Some(g) = g else { return json!({"type": "BACKGROUND_BLUR", "visible": true, "radius": radius}) }; + let stops = g.get("stops").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let at = |s: Option<&Value>| s.and_then(|s| s.get("color")).and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0) * radius; + let end = |k: &str, d: (f64, f64)| { + let (x, y) = g.get(k).and_then(Value::as_str).and_then(super::path::parse_point).unwrap_or(d); + json!({"x": x.clamp(-1e3, 1e3), "y": y.clamp(-1e3, 1e3)}) + }; + json!({ + "type": "BACKGROUND_BLUR", + "visible": true, + "blurType": "PROGRESSIVE", + "startRadius": at(stops.first()), + "radius": at(stops.last()), + "startOffset": end("from", (0.5, 0.0)), + "endOffset": end("to", (0.5, 1.0)), + }) +} + /// A bitmap's colour adjustments (`colorControls`: saturation and contrast 0–2 with 1 neutral, /// brightness −1–1, hue in radians) as REST image filters; hue has no filter (a note). pub(crate) fn color_controls(cx: &mut Cx, style: Option<&Value>) -> Option { @@ -264,6 +319,79 @@ pub(crate) fn swatch(cx: &Cx, c: Option<&Value>) -> Option { Some(json!({"color": {"type": "VARIABLE_ALIAS", "id": id}})) } +/// The tint a style gives the layers inside: its first enabled plain fill with `layeringType` 1 +/// (Sketch 2025 and newer), or with no `layeringType` when `legacy` (older files: a group's or +/// instance's fill was its tint). Sketch draws a tint as the colour of everything inside. +pub(crate) fn tint_of(style: Option<&Value>, legacy: bool) -> Option { + let fills = style?.get("fills")?.as_array()?; + let f = fills.iter().filter(|f| f.get("isEnabled").and_then(Value::as_bool) != Some(false)).find(|f| { + match f.get("layeringType").and_then(Value::as_i64) { + Some(t) => t == 1, + None => legacy, + } + })?; + (f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0).then(|| f.get("color").cloned()).flatten() +} + +/// Recolour `layers` (Sketch JSON) with tint `color`: every enabled fill and border takes it, +/// text takes it, nested instances take it as their own tint. Opacity stays as it was. +pub(crate) fn tint_layers(layers: &mut [Value], color: &Value, depth: usize) { + if depth > 64 { + return; + } + for l in layers { + if let Some(st) = l.get_mut("style").and_then(Value::as_object_mut) { + for key in ["fills", "borders"] { + for p in st.get_mut(key).and_then(Value::as_array_mut).into_iter().flatten() { + if let Some(m) = p.as_object_mut() + && m.get("isEnabled").and_then(Value::as_bool) != Some(false) + && !matches!(m.get("fillType").and_then(Value::as_i64), Some(4)) + { + let c = tinted(m.get("color"), color); + m.insert("fillType".into(), json!(0)); + m.insert("color".into(), c); + } + } + } + if let Some(enc) = st.get_mut("textStyle").and_then(|t| t.get_mut("encodedAttributes")).and_then(Value::as_object_mut) { + let c = tinted(enc.get("MSAttributedStringColorAttribute"), color); + enc.insert("MSAttributedStringColorAttribute".into(), c); + } + } + if let Some(runs) = l.get_mut("attributedString").and_then(|a| a.get_mut("attributes")).and_then(Value::as_array_mut) { + for r in runs.iter_mut().filter_map(|r| r.get_mut("attributes")).filter_map(Value::as_object_mut) { + let c = tinted(r.get("MSAttributedStringColorAttribute"), color); + r.insert("MSAttributedStringColorAttribute".into(), c); + } + } + if l.get("_class").and_then(Value::as_str) == Some("symbolInstance") { + set_tint(l, color); + } + if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { + tint_layers(kids, color, depth + 1); + } + } +} + +/// Colour `old` (Sketch JSON) tinted with `tint`: the tint's colour, with both opacities. +fn tinted(old: Option<&Value>, tint: &Value) -> Value { + let alpha = |c: Option<&Value>| c.and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0); + let mut c = tint.clone(); + c["alpha"] = json!(alpha(Some(tint)) * alpha(old)); + c +} + +/// Give layer `l` (Sketch JSON) tint `color`: as its one fill. +pub(crate) fn set_tint(l: &mut Value, color: &Value) { + let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 0, "color": color}); + if let Some(m) = l.as_object_mut() { + let st = m.entry("style").or_insert_with(|| json!({"_class": "style"})); + if let Some(st) = st.as_object_mut() { + st.insert("fills".into(), json!([fill])); + } + } +} + /// Sketch's blend modes (0 normal … 17 plus lighter) by REST name. fn blend(mode: i64) -> Option<&'static str> { const MODES: [&str; 18] = [ diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index 242b0478..b5d24bdf 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -78,7 +78,10 @@ fn gradients() { assert_eq!(lin.stops.len(), 2); assert_eq!(lin.stops[1].color.b, 1.0); assert_eq!(g("Radial").kind, GradientKind::Radial); - assert_eq!(g("Angular").kind, GradientKind::Angular); + // Sketch starts an angular gradient on the right and turns clockwise, whatever its from/to. + let ang = g("Angular"); + assert_eq!(ang.kind, GradientKind::Angular); + assert_eq!((ang.from, ang.to, ang.width), ((0.5, 0.5), (1.0, 0.5), (0.5, 1.0))); // The radial gradient's width handle: half the length, across the main axis (in pixels). let rad = g("Radial"); let (dx, dy) = ((rad.width.0 - rad.from.0) * 100.0, (rad.width.1 - rad.from.1) * 50.0); @@ -160,7 +163,8 @@ fn shadows_and_blurs() { assert!(matches!(fx[2].kind, EffectKind::InnerShadow(_))); assert!(matches!(node(&doc, "Blurred").effects[0].kind, EffectKind::LayerBlur(_))); assert!(matches!(node(&doc, "Frosted").effects[0].kind, EffectKind::BackgroundBlur(_))); - assert!(node(&doc, "Moving").effects.is_empty()); + // Motion blur: drawn as a plain blur of half the radius. + assert!(matches!(node(&doc, "Moving").effects[0].kind, EffectKind::LayerBlur(_))); assert!(warnings.iter().any(|w| w.contains("motion")), "{warnings:?}"); } diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index 1292c025..06a39f68 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -114,21 +114,49 @@ pub(crate) fn master_fills(m: &Value) -> Option { Some(json!([{"type": "SOLID", "color": c}])) } +/// What a master shows as a frame (its instances the same): clipping, and in Sketch 2025 and newer +/// its style (fills, borders, effects) and corners. +fn frame_look(cx: &mut Cx, m: &Value, w: f64, h: f64, node: &mut JsonMap) { + // `clippingBehavior` 2 doesn't clip. + node.insert("clipsContent".into(), json!(m.get("clippingBehavior").and_then(Value::as_i64) != Some(2))); + if m.get("groupBehavior").is_some() + && let Some(st) = m.get("style") + { + // Tint fills aren't painted (see `style::fills`). + super::style::apply(cx, st, w, h, node); + } + super::tree::style_corners(m, node); +} + /// A symbol master as a COMPONENT node. pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: bool) -> Option { let (w, h) = size(l); let world = parent * local(l, w, h); let mut node = base(l, "COMPONENT", parent, world, w, h, root); - node.insert("clipsContent".into(), json!(true)); + frame_look(cx, l, w, h, &mut node); if let Some(f) = master_fills(l) { node.insert("fills".into(), f); } super::tree::extras(cx, l, h, &mut node); let sid = str_field(l, "symbolID").unwrap_or("").to_string(); + // A graphic's fill tints its layers. + let tinted; + let l = match super::style::tint_of(l.get("style"), false) { + Some(t) => { + let mut c = l.clone(); + if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) { + super::style::tint_layers(kids, &t, 0); + } + tinted = c; + &tinted + } + _ => l, + }; cx.chain.push(sid); let kids = children(cx, l, world, (w, h), depth, false); cx.chain.pop(); node.insert("children".into(), Value::Array(kids)); + super::stack::apply(l, &mut node); Some(Value::Object(node)) } @@ -161,15 +189,20 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo } let mut node = base(l, "INSTANCE", parent, world, w, h, root); node.insert("componentId".into(), json!(str_field(&m, "do_objectID").unwrap_or(sid))); - node.insert("clipsContent".into(), json!(true)); + frame_look(cx, &m, w, h, &mut node); // Instances show the master's background only when it says so. if bool_field(&m, "includeBackgroundColorInInstance") == Some(true) && let Some(f) = master_fills(&m) { node.insert("fills".into(), f); } + // An instance's fill is a tint for its layers, not a paint of its own. if let Some(st) = l.get("style") { - super::style::apply(cx, st, w, h, &mut node); + let mut st = st.clone(); + if let Some(o) = st.as_object_mut() { + o.remove("fills"); + } + super::style::apply(cx, &st, w, h, &mut node); } super::tree::extras(cx, l, h, &mut node); // An instance shows its master's grids (else they'd differ, and count as an override). @@ -187,21 +220,45 @@ pub(crate) fn instance(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, roo // The budget ran out handing overrides on: noted. cx.spend(1); } - // The master's layers laid out for the instance's size, as Sketch does it. - let (mw, mh) = size(©); - if ((mw - w).abs() > 1e-9 || (mh - h).abs() > 1e-9) + // The tint: the instance's own (a colour override on it, or its fill), else a graphic master's. + let own = str_field(l, "do_objectID").map(|id| format!("{id}_fillColor")); + let tint = own + .and_then(|k| overrides.get(&k).filter(|c| c.is_object()).cloned()) + .or_else(|| super::style::tint_of(l.get("style"), true)) + .or_else(|| super::style::tint_of(copy.get("style"), false)); + if let Some(t) = tint && let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) { - resize(layers, (mw, mh), (w, h), cx.legacy_resize, 0); + super::style::tint_layers(layers, &t, 0); + } + // The master's layers laid out for the instance's size, as Sketch does it. + let (mw, mh) = size(©); + // A graphic's layers resize in proportion, whatever their pins. + let graphic = copy.get("groupBehavior").and_then(Value::as_i64) == Some(2); + // A stack's layers are placed by its auto layout (when the file is opened), not by their pins. + let stacked = !graphic && super::stack::is_stack(©); + if ((mw - w).abs() > 1e-9 || (mh - h).abs() > 1e-9) + && !stacked + && let Some(layers) = copy.get_mut("layers").and_then(Value::as_array_mut) + { + resize(layers, (mw, mh), (w, h), cx.legacy_resize || graphic, 0); } cx.chain.push(sid.to_string()); let mut kids = children(cx, ©, world, (w, h), depth, false); cx.chain.pop(); + node.insert("children".into(), Value::Array(std::mem::take(&mut kids))); + // The master's stack lays the instance out too (matched by the master's layer ids), sized as + // the instance says. + for k in ["horizontalSizing", "verticalSizing"] { + if let Some(v) = l.get(k) { + copy[k] = v.clone(); + } + } + super::stack::apply(©, &mut node); let prefix = str_field(l, "do_objectID").unwrap_or("I").to_string(); - for k in &mut kids { + for k in node.get_mut("children").and_then(Value::as_array_mut).into_iter().flatten() { prefix_ids(k, &prefix, 0); } - node.insert("children".into(), Value::Array(kids)); Some(Value::Object(node)) } @@ -213,7 +270,7 @@ fn resize(layers: &mut [Value], old: (f64, f64), new: (f64, f64), legacy: bool, return; } for l in layers { - let mask = if legacy { 63 } else { l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63) }; + let mask = if legacy { 63 } else { super::tree::pin_mask(l) }; let Some(f) = l.get_mut("frame").and_then(Value::as_object_mut) else { continue }; let n = |k: &str| f.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0); let (x, y, w, h) = (n("x"), n("y"), n("width").max(0.0), n("height").max(0.0)); @@ -224,8 +281,10 @@ fn resize(layers: &mut [Value], old: (f64, f64), new: (f64, f64), legacy: bool, for (k, v) in [("x", nx), ("y", ny), ("width", nw), ("height", nh)] { f.insert(k.into(), json!(v)); } + // A graphic's layers resize in proportion, whatever their pins. + let graphic = l.get("groupBehavior").and_then(Value::as_i64) == Some(2); if let Some(kids) = l.get_mut("layers").and_then(Value::as_array_mut) { - resize(kids, (w, h), (nw, nh), legacy, depth + 1); + resize(kids, (w, h), (nw, nh), legacy || graphic, depth + 1); } } } @@ -316,19 +375,46 @@ fn apply( own.extend(inner.iter().take(take).map(|(rest, v)| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))); l["overrideValues"] = Value::Array(own); } + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + // A colour override on an instance is its tint. + if let Some(c) = get("fillColor").filter(|c| c.is_object()) { + super::style::set_tint(l, c); + } continue; } Some("text") => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } if let Some(s) = get("stringValue").and_then(Value::as_str) { set_string(l, s); } + if let Some(c) = get("textColor").filter(|c| c.is_object()) { + super::style::tint_layers(std::slice::from_mut(l), c, 0); + } } Some("bitmap") => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } if let Some(img) = get("image").filter(|v| v.get("_ref").is_some()) { l["image"] = img.clone(); } } - _ => {} + _ => { + if let Some(v) = get("isVisible") { + l["isVisible"] = json!(visible(v)); + } + // A colour override on a shape or group: its first fill's colour. + if let Some(c) = get("fillColor").filter(|c| c.is_object()) + && let Some(f) = l.get_mut("style").and_then(|s| s.get_mut("fills")).and_then(Value::as_array_mut).and_then(|f| f.first_mut()) + { + f["fillType"] = json!(0); + f["color"] = c.clone(); + } + } } for (prop, text) in [("layerStyle", false), ("textStyle", true)] { let sid = get(prop).and_then(Value::as_str); @@ -357,6 +443,11 @@ fn apply( } } +/// A visibility override's value: "0", 0 or false hide. +fn visible(v: &Value) -> bool { + !matches!(v, Value::Bool(false)) && v.as_str() != Some("0") && v.as_i64() != Some(0) +} + /// A text layer's string replaced, in the style of its first run. fn set_string(l: &mut Value, s: &str) { let units = s.encode_utf16().count(); diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index e1f0c263..0dd8d596 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -60,14 +60,17 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { _ => "TOP", }), ); - m.insert( - "textAutoResize".into(), - json!(match l.get("textBehaviour").and_then(Value::as_i64) { - Some(1) => "HEIGHT", - Some(2) => "NONE", - _ => "WIDTH_AND_HEIGHT", - }), - ); + // Sketch 2025 and newer size text by `horizontalSizing`/`verticalSizing` (1 fits). + let fits = |k: &str| l.get(k).and_then(Value::as_i64) == Some(1); + let resize = match l.get("textBehaviour").and_then(Value::as_i64) { + Some(1) => "HEIGHT", + Some(2) => "NONE", + Some(_) => "WIDTH_AND_HEIGHT", + None if l.get("horizontalSizing").is_none() || fits("horizontalSizing") => "WIDTH_AND_HEIGHT", + None if fits("verticalSizing") => "HEIGHT", + None => "NONE", + }; + m.insert("textAutoResize".into(), json!(resize)); } node.insert("characters".into(), json!(string)); node.insert("style".into(), style); diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 9a5bfaa3..3d6a48d3 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -151,6 +151,15 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: cx.too_old = true; return None; } + // A rectangle with angled or inside corners is a path of that outline. + let outlined; + let l = match super::path::cut_corners(l) { + Some(p) => { + outlined = p; + &outlined + } + None => l, + }; let class = str_field(l, "_class")?; if class == "shapeGroup" { return count(cx).then(|| super::path::shape_group(cx, l, parent, depth, root)).flatten(); @@ -163,6 +172,8 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: } let ty = match class { "artboard" | "frame" => "FRAME", + // Sketch 2025 and newer: a group that behaves as a frame (1) or a graphic (2), or clips. + "group" if is_frame(l) => "FRAME", "group" | "graphic" => "GROUP", // Shapes whose points were moved are paths. "rectangle" | "oval" if bool_field(l, "edited") == Some(true) => "VECTOR", @@ -204,6 +215,10 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: node.insert("fills".into(), json!([{"type": "SOLID", "color": c}])); } } + "group" if ty == "FRAME" => { + // `clippingBehavior`: 0 the default (frames clip), 1 clip, 2 don't. + node.insert("clipsContent".into(), json!(l.get("clippingBehavior").and_then(Value::as_i64) != Some(2))); + } "rectangle" => corner_radii(l, &mut node), "bitmap" => match l.get("image").and_then(|i| str_field(i, "_ref")) { Some(r) => { @@ -219,17 +234,80 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: } styles(cx, l, w, h, &mut node); extras(cx, l, h, &mut node); + style_corners(l, &mut node); if class == "MSImmutableHotspotLayer" || class == "hotspot" { node.insert("clipsContent".into(), json!(false)); node.remove("fills"); } + // A group's or graphic's fill tints its layers (a frame's is its background). + let tinted; + let gb = l.get("groupBehavior").and_then(Value::as_i64); + let l = match (class, super::style::tint_of(l.get("style"), gb != Some(1))) { + ("group", Some(t)) => { + // A plain group's legacy fill is all tint; a frame keeps its background fills. + if gb != Some(1) { + node.remove("fills"); + } + let mut c = l.clone(); + if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) { + super::style::tint_layers(kids, &t, 0); + } + tinted = c; + &tinted + } + _ => l, + }; if matches!(ty, "FRAME" | "GROUP") { let kids = children(cx, l, world, (w, h), depth, false); node.insert("children".into(), Value::Array(kids)); + super::stack::apply(l, &mut node); } Some(Value::Object(node)) } +/// A Sketch 2025+ group that is a frame: it behaves as a frame or a graphic, or clips. +pub(crate) fn is_frame(l: &Value) -> bool { + matches!(l.get("groupBehavior").and_then(Value::as_i64), Some(1 | 2)) || l.get("clippingBehavior").and_then(Value::as_i64) == Some(1) +} + +/// Corners kept in the style (Sketch 2025+: one radius for all, or one per corner from the top +/// left, clockwise) on shapes and frames that have corners; smooth corners keep their smoothing. +pub(crate) fn style_corners(l: &Value, node: &mut JsonMap) { + let Some(c) = l.get("style").and_then(|s| s.get("corners")) else { return }; + if !matches!(node.get("type").and_then(Value::as_str), Some("RECTANGLE" | "FRAME" | "COMPONENT" | "INSTANCE")) { + return; + } + let radii: Vec = c + .get("radii") + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(Value::as_f64) + .filter(|r| r.is_finite()) + .map(|r| r.clamp(0.0, MAX_SIZE)) + .take(4) + .collect(); + match radii.as_slice() { + [] => return, + [r] => { + node.insert("cornerRadius".into(), json!(r)); + node.remove("rectangleCornerRadii"); + } + more => { + // Values repeat to fill the four corners. + let all: Vec = (0..4).map(|i| more.get(i % more.len()).copied().unwrap_or(0.0)).collect(); + node.insert("rectangleCornerRadii".into(), json!(all)); + } + } + // Style 1 is smooth: its smoothing applies. + if c.get("style").and_then(Value::as_i64) == Some(1) + && let Some(sm) = c.get("smoothing").and_then(Value::as_f64).filter(|v| v.is_finite()) + { + node.insert("cornerSmoothing".into(), json!(sm.clamp(0.0, 1.0))); + } +} + /// Layer `l`'s style into `node`, and its links to the styles made from its shared style. pub(crate) fn styles(cx: &mut Cx, l: &Value, w: f64, h: f64, node: &mut JsonMap) { // An artboard shows its background colour only; Sketch doesn't draw a style it carries. @@ -291,7 +369,7 @@ pub(crate) fn base(l: &Value, ty: &str, parent: Affine, world: Affine, w: f64, h let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); node.insert("absoluteBoundingBox".into(), json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()})); } - node.insert("constraints".into(), constraints(l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63))); + node.insert("constraints".into(), constraints(pin_mask(l))); node } @@ -317,6 +395,33 @@ pub(crate) fn local(l: &Value, w: f64, h: f64) -> Affine { * Affine::translate((-w / 2.0, -h / 2.0)) } +/// A layer's pins as a `resizingConstraint` mask. Sketch 2025 and newer keep them in +/// `horizontalPins`/`verticalPins` (bits: 1 the start, 4 the end) and `horizontalSizing`/ +/// `verticalSizing` (fixed and fit keep the size; fill and relative don't), with +/// `resizingConstraint` left at 63. +pub(crate) fn pin_mask(l: &Value) -> i64 { + if l.get("horizontalPins").is_none() && l.get("horizontalSizing").is_none() { + return l.get("resizingConstraint").and_then(Value::as_i64).unwrap_or(63); + } + let int = |k: &str| l.get(k).and_then(Value::as_i64).unwrap_or(0); + let fixed = |k: &str| matches!(int(k), 0 | 1); + let mut mask = 63; + // A cleared bit pins: right 1, width 2, left 4, bottom 8, height 16, top 32. + for (on, bit) in [ + (int("horizontalPins") & 1 != 0, 4), + (int("horizontalPins") & 4 != 0, 1), + (fixed("horizontalSizing"), 2), + (int("verticalPins") & 1 != 0, 32), + (int("verticalPins") & 4 != 0, 8), + (fixed("verticalSizing"), 16), + ] { + if on { + mask &= !bit; + } + } + mask +} + /// REST constraints from Sketch's `resizingConstraint`: six bits, a cleared bit pins that side or /// fixes that size (right 1, width 2, left 4, bottom 8, height 16, top 32). A layer pinned on /// neither side keeps its place in proportion (scale) unless its size is fixed (centre). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 8759a359..4d7b92ed 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards (and Sketch 2025 frames) as frames with their background, groups, rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Warnings: noise fills, motion and zoom blurs, embedded fonts (not used), Smart Layout and stacks (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%; Sketch 2025 files on fixtures only | 5–10 (Smart Layout as auto layout, real Sketch 2025 files) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), embedded fonts (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -166,7 +166,7 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Checked on fixtures and locally on one real Sketch 50 file and the IBM Carbon Design Kit (Sketch 47, 68, 83). Numbers unchanged | +| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Sketch 2025 and newer: frames, graphics, stacks as auto layout, corners, pins and sizing, tints, per-side borders, blur lists. Checked on fixtures and locally on real Sketch 50, 64 and 2026 files and the IBM Carbon Design Kit (Sketch 47, 68, 83). Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | From 41ff790d779d4755df693aa8c9a14d4054b25940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 22:58:19 +0200 Subject: [PATCH 049/208] M6: Sketch import: variable font axes; no false embedded-font note Sketch keys a font's variation axes by the tag read as a number; they map onto the text style's variations (a fontVariations key in the REST mapping). Font references without font data only name the fonts a document uses and no longer raise the embedded-font note. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/rest/text.rs | 9 +++++++++ crates/format/src/sketch/mod.rs | 15 +++++++++++---- crates/format/src/sketch/text.rs | 20 ++++++++++++++++++++ crates/format/src/sketch/text_tests.rs | 24 ++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 367677a6..99ceb500 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -102,6 +102,15 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { let weight = num(s, "fontWeight").map_or_else(|| t.weight(), |w| w.clamp(1.0, 1000.0) as u16); t.font_style = style_name(weight, flag(s, "italic").unwrap_or_else(|| t.italic())); } + // Variable font axes, `{tag: value}` (FigCraft's own key; the Sketch importer writes it). + if let Some(axes) = s.get("fontVariations").and_then(Value::as_object) { + t.variations = axes + .iter() + .take(64) + .filter(|(k, _)| k.len() == 4 && k.is_ascii()) + .filter_map(|(k, v)| Some((k.clone(), v.as_f64().filter(|x| x.is_finite())?.clamp(-1e5, 1e5)))) + .collect(); + } if let Some(size) = num(s, "fontSize").filter(|x| *x > 0.0) { t.font_size = size.clamp(0.5, 10_000.0); } diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 8d8a465e..15df9d22 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -127,12 +127,19 @@ fn import_parts(bytes: &[u8], name: &str, max_member: u64, max_nodes: usize) -> Ok((doc, warnings)) } -/// The note about fonts the file carries (Sketch 7x and newer can embed them): FigCraft doesn't -/// use fonts from inside documents, so their text shows in installed fonts. +/// The note about fonts the file carries (Sketch 7x and newer can embed them, as `fontReferences` +/// with `fontData`): FigCraft doesn't use fonts from inside documents, so their text shows in +/// installed fonts. fn embedded_fonts(document: &Value) -> Option { let refs = document.get("fontReferences").and_then(Value::as_array)?; - let families: BTreeSet = - refs.iter().filter_map(|r| r.get("fontFamilyName").and_then(Value::as_str)).map(|f| f.chars().take(64).collect()).take(20).collect(); + // A reference without font data only names a font the document uses. + let families: BTreeSet = refs + .iter() + .filter(|r| r.get("fontData").is_some()) + .filter_map(|r| r.get("fontFamilyName").and_then(Value::as_str)) + .map(|f| f.chars().take(64).collect()) + .take(20) + .collect(); if families.is_empty() { return None; } diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 0dd8d596..76692790 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -129,6 +129,9 @@ pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { cx.warn("text without a font uses Helvetica"); } let mut s = json!({"fontFamily": family, "fontStyle": style, "fontSize": size.min(10_000.0)}); + if let Some(v) = font.and_then(|f| f.get("variation")).and_then(variations) { + s["fontVariations"] = v; + } let p = a.get("paragraphStyle"); let line = |k: &str| p.and_then(|p| p.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); if let Some(lh) = line("maximumLineHeight").or_else(|| line("minimumLineHeight")) { @@ -160,6 +163,23 @@ pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { s } +/// A font's `variation` (axis values keyed by the axis tag read as a big-endian number, as Apple's +/// text engine keys them: 2003265652 is `wght`) as `{tag: value}`; `None` when it has none. +fn variations(v: &Value) -> Option { + let axes: JsonMap = v + .as_object()? + .iter() + .take(64) + .filter_map(|(k, val)| { + let tag = u32::try_from(k.parse::().ok()?).ok()?.to_be_bytes(); + let tag = std::str::from_utf8(&tag).ok().filter(|t| t.chars().all(|c| c.is_ascii_graphic()))?.to_string(); + let val = val.as_f64().filter(|x| x.is_finite())?; + Some((tag, json!(val.clamp(-1e5, 1e5)))) + }) + .collect(); + (!axes.is_empty()).then_some(Value::Object(axes)) +} + /// Family and style of a font from its PostScript name: "HelveticaNeue-Bold" → ("Helvetica Neue", /// "Bold"). Words of the family are split at case changes, which can't always match the real name /// (a CJK family would split as "Ping Fang SC"); the engine matches families against the installed diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index 41b86f68..53ca5235 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -211,3 +211,27 @@ fn line_spacing_behaviour_moves_text() { assert!((y("B0") - 106.6).abs() < 1e-6, "{}", y("B0")); assert!((y("B1") - 93.4).abs() < 1e-6, "{}", y("B1")); } + +/// A variable font's axes (Sketch keys them by the tag as a number: 2003265652 is `wght`). +#[test] +fn font_variations() { + let mut t = text("Wide", 0.0, 0.0, 200.0, 50.0, "Orbit", &[(0, 5, "Skia", 40.0, BLACK)]); + t["attributedString"]["attributes"][0]["attributes"]["MSAttributedStringFontAttribute"]["attributes"]["variation"] = + json!({"2003265652": 3, "2003072104": 0.65, "junk": 1, "1": "x"}); + let (doc, _) = open(vec![t]); + let v = &data(&doc, "Wide").style.variations; + assert_eq!(v.get("wght"), Some(&3.0)); + assert_eq!(v.get("wdth"), Some(&0.65)); + assert_eq!(v.len(), 2, "{v:?}"); +} + +/// Font references without font data are only the fonts the document uses: no note. +#[test] +fn font_references_without_data_are_no_embedded_fonts() { + let mut d = document(&["P"]); + d["fontReferences"] = + json!([{"_class": "fontReference", "fontFamilyName": "Skia", "fontFileName": "Skia.ttf", "postscriptNames": ["Skia-Regular"]}]); + let z = sketch_zip("2026.3.1", 196, d, &[page("P", "Page", vec![])], &[]); + let (_, warnings) = import(&z, "f").unwrap(); + assert!(!warnings.iter().any(|w| w.contains("embedded")), "{warnings:?}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4d7b92ed..998b6091 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), embedded fonts (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families, variable font axes; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From f02557e8f9af96ebda90a44e94d428a1d8e823e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 23:26:27 +0200 Subject: [PATCH 050/208] M6: Sketch import: fonts by PostScript name, Sketch's natural line The font database knows each face's PostScript name; the Sketch import finds the installed face a file's font names (CourierNewPSMT is Courier New Regular) and reads the family from the name only when it isn't installed. Text without a fixed line height takes the line Sketch draws: ascent and descent rounded to whole pixels, the line gap left out, a fifth more for classic Mac fonts, the baseline where Sketch puts it (measured on sketchtool renders of 80 fonts). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 81 ++++++++++++++++++++++-- crates/engine/src/sketch_import_tests.rs | 40 ++++++++++++ crates/format/src/sketch/mod.rs | 32 ++++++++-- crates/format/src/sketch/text.rs | 19 +++++- crates/format/src/sketch/text_tests.rs | 15 +++++ crates/format/src/sketch/tree.rs | 7 +- crates/text/src/fontdb.rs | 38 +++++++++-- crates/text/src/fontdb_tests.rs | 31 ++++++++- docs/file-format-parity.md | 2 +- 9 files changed, 244 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 5de7e0fd..8db15cee 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -556,13 +556,18 @@ fn load_with( /// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try. fn load_sketch(bytes: &[u8], name: &str) -> Option> { - let r = figcraft_format::sketch::try_import(bytes, name)?; + if !figcraft_format::sketch::is_sketch(bytes) { + return None; + } + // The import and what follows read the installed fonts: the system's must be known first (the + // desktop app scans them in the background; this waits for that scan). + figcraft_text::fontdb::scan_system_fonts(); + let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face); + let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?; Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { - // Both read the installed fonts: the system's must be known first (the desktop app scans - // them in the background; this waits for that scan). - figcraft_text::fontdb::scan_system_fonts(); match_font_families(&mut doc); platform_tracking(&mut doc); + sketch_line_heights(&mut doc); link_instances(&mut doc); reflow_overridden_text(&mut doc); figcraft_format::Loaded { issues: warnings, ..doc.into() } @@ -665,6 +670,74 @@ fn platform_tracking(doc: &mut Document) { } } +/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their +/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this. +const TALL_LINE_FAMILIES: [&str; 11] = + ["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"]; + +/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its +/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch +/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth +/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres +/// the font's own ascent and descent in the line. `(0, 0)` without metrics. +pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) { + let k = size / upem; + let (a, d) = (ascent * k, descent * k); + if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 { + return (0.0, 0.0); + } + let natural = a.round() + d.round(); + let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural }; + let sketch_baseline = line - d.round(); + let figcraft_baseline = (line - (a + d)) / 2.0 + a; + (line, sketch_baseline - figcraft_baseline) +} + +/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line, +/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a +/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text +/// styles take the line too. +fn sketch_line_heights(doc: &mut Document) { + // Per family and style: the face's ascent, descent and units per em. + type Metrics = Option<(f64, f64, f64)>; + let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new(); + let mut set = |style: &mut TextStyle| -> f64 { + if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 { + return 0.0; + } + let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + .map(|f| (f.ascent, f.descent, f.upem)) + }); + let Some((a, d, upem)) = m else { return 0.0 }; + let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size); + if line > 0.0 { + style.line_height = LineHeight::Percent(line / style.font_size * 100.0); + } + down + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + let Some(n) = doc.node_mut(id) else { continue }; + let NodeKind::Text(td) = &mut n.kind else { continue }; + let down = set(&mut td.style); + for r in &mut td.runs { + set(&mut r.style); + } + // Down in the layer's own frame (a rotated text moves along its own axis). + if down.is_finite() && down != 0.0 { + n.transform *= figcraft_doc::geom::Affine::translate((0.0, down)); + } + } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + set(style); + } + } +} + /// The installed family `family` means when it isn't installed under that exact name: the one /// whose name is the same without spaces, ignoring case. pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index 5dc4b31a..36b200a5 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -401,3 +401,43 @@ fn sketch_resized_stack_instances_reflow() { assert!((b.x0 - a.x1 - 8.0).abs() < 0.5, "{a:?} {b:?}"); assert!((doc.world_bounds(inst).unwrap().x1 - 342.0).abs() < 1e-6); } + +/// Sketch's natural line: the font's ascent and descent each rounded to whole pixels, without its +/// line gap, the baseline at the rounded ascent; the classic Mac fonts get a fifth more, all of it +/// above the text. +#[test] +fn sketch_natural_line_rule() { + use crate::cmd::file::sketch_line; + // Helvetica Neue Medium (975 + 217 of 1000, a gap of 29 left out) at 17 px: 16.575 → 17 and + // 3.689 → 4, a line of 21 with the baseline at 17. FigCraft puts the baseline at + // (21 − 20.264) / 2 + 16.575 = 16.943, so the text moves down 0.057. + let (line, down) = sketch_line(975.0, 217.0, 1000.0, "Helvetica Neue", 17.0); + assert!((line - 21.0).abs() < 1e-9 && (down - 0.057).abs() < 1e-9, "{line} {down}"); + // Helvetica at 100 px: 77 + 23 and a fifth more, 120, all of it above: the baseline at 97 + // where FigCraft centres it at 87. + let (line, down) = sketch_line(1577.0, 471.0, 2048.0, "helvetica", 100.0); + assert!((line - 120.0).abs() < 1e-9 && (down - 10.0).abs() < 0.05, "{line} {down}"); + assert_eq!(sketch_line(0.0, 0.0, 0.0, "X", 12.0), (0.0, 0.0), "no metrics, no change"); + assert_eq!(sketch_line(900.0, 200.0, 1000.0, "X", f64::NAN), (0.0, 0.0)); +} + +/// Text without a fixed line height gets Sketch's natural line as a share of its size, in the +/// layer and in shared text styles. +#[test] +fn sketch_text_gets_sketchs_natural_line() { + if !std::path::Path::new("/System/Library/Fonts/HelveticaNeue.ttc").exists() { + return; + } + figcraft_text::fontdb::scan_system_fonts(); + let font = json!({"name": "HelveticaNeue", "size": 100}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let t = shape("text", "Label", [0.0, 0.0, 200.0, 120.0], json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0})); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[t])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") }; + // 95.2 → 95 and 21.3 → 21: 116 px, as a share of the size. + let figcraft_doc::LineHeight::Percent(p) = td.style.line_height else { panic!("{:?}", td.style.line_height) }; + assert!((p - 116.0).abs() < 1e-9, "{p}"); +} diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 15df9d22..18332afd 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -81,35 +81,55 @@ pub fn is_sketch(bytes: &[u8]) -> bool { })) } +/// Finds the installed face a PostScript name belongs to: its family and style. +pub type FontLookup = Box Option<(String, String)>>; + /// Import `bytes` when they are a `.sketch` file; `None` when they are something else. pub fn try_import(bytes: &[u8], name: &str) -> Option), FormatError>> { - is_sketch(bytes).then(|| import(bytes, name)) + try_import_with_fonts(bytes, name, None) +} + +/// [`try_import`] with `fonts` finding the installed faces the file's fonts (PostScript names) +/// belong to; families of fonts it doesn't find are read from their names. +pub fn try_import_with_fonts(bytes: &[u8], name: &str, fonts: Option) -> Option), FormatError>> { + is_sketch(bytes).then(|| import_with_fonts(bytes, name, fonts)) } /// Build a document from a `.sketch` file. Returns it with the warnings about what was skipped or /// approximated. `name`: the document name. pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), FormatError> { - import_parts(bytes, name, MAX_JSON_MEMBER, crate::fig::layer_budget(bytes.len())) + import_with_fonts(bytes, name, None) +} + +/// [`import`] with a font lookup (see [`try_import_with_fonts`]). +pub fn import_with_fonts(bytes: &[u8], name: &str, fonts: Option) -> Result<(Document, Vec), FormatError> { + import_parts(bytes, name, MAX_JSON_MEMBER, crate::fig::layer_budget(bytes.len()), fonts) } /// [`import`] reading pages of at most `max_member` bytes of JSON each. #[cfg(test)] pub(crate) fn import_within(bytes: &[u8], name: &str, max_member: u64) -> Result<(Document, Vec), FormatError> { - import_parts(bytes, name, max_member, crate::fig::layer_budget(bytes.len())) + import_parts(bytes, name, max_member, crate::fig::layer_budget(bytes.len()), None) } /// [`import`] with at most `max_nodes` layers. #[cfg(test)] pub(crate) fn import_within_nodes(bytes: &[u8], name: &str, max_nodes: usize) -> Result<(Document, Vec), FormatError> { - import_parts(bytes, name, MAX_JSON_MEMBER, max_nodes) + import_parts(bytes, name, MAX_JSON_MEMBER, max_nodes, None) } -fn import_parts(bytes: &[u8], name: &str, max_member: u64, max_nodes: usize) -> Result<(Document, Vec), FormatError> { +fn import_parts( + bytes: &[u8], + name: &str, + max_member: u64, + max_nodes: usize, + fonts: Option, +) -> Result<(Document, Vec), FormatError> { if bytes.starts_with(b"SQLite format 3\0") { return Err(FormatError::Io(TOO_OLD.into())); } let archive = Archive::open(bytes, max_member)?; - let map = tree::to_rest(&archive, name, max_nodes); + let map = tree::to_rest(&archive, name, max_nodes, fonts); if map.too_old { return Err(FormatError::Io(TOO_OLD.into())); } diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 76692790..027de660 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -124,7 +124,7 @@ pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { let font = a.get("MSAttributedStringFontAttribute").and_then(|f| f.get("attributes")); let ps = font.and_then(|f| f.get("name")).and_then(Value::as_str).unwrap_or(""); let size = font.and_then(|f| f.get("size")).and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).unwrap_or(12.0); - let (family, style) = font_name(ps); + let (family, style) = installed(cx, ps).unwrap_or_else(|| font_name(ps)); if ps.is_empty() { cx.warn("text without a font uses Helvetica"); } @@ -180,6 +180,23 @@ fn variations(v: &Value) -> Option { (!axes.is_empty()).then_some(Value::Object(axes)) } +/// The installed family and style of PostScript name `ps`, when the import has a font lookup and +/// the font is installed. +fn installed(cx: &mut Cx, ps: &str) -> Option<(String, String)> { + if ps.is_empty() { + return None; + } + if let Some(found) = cx.font_cache.get(ps) { + return found.clone(); + } + let found = cx.fonts.as_ref().and_then(|f| f(ps)); + // A file names a few hundred fonts at most; junk ones don't grow the cache past that. + if cx.font_cache.len() < 4096 { + cx.font_cache.insert(ps.to_string(), found.clone()); + } + found +} + /// Family and style of a font from its PostScript name: "HelveticaNeue-Bold" → ("Helvetica Neue", /// "Bold"). Words of the family are split at case changes, which can't always match the real name /// (a CJK family would split as "Ping Fang SC"); the engine matches families against the installed diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index 53ca5235..8adef1ea 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -235,3 +235,18 @@ fn font_references_without_data_are_no_embedded_fonts() { let (_, warnings) = import(&z, "f").unwrap(); assert!(!warnings.iter().any(|w| w.contains("embedded")), "{warnings:?}"); } + +/// With a font lookup, a PostScript name gives the installed family and style it belongs to (the +/// name alone can't always tell: "CourierNewPSMT" is Courier New Regular); without one, or for a +/// font that isn't installed, the family is read from the name. +#[test] +fn postscript_names_find_installed_fonts() { + let mk = |name: &str, ps: &str| text(name, 0.0, 0.0, 100.0, 20.0, "Hi", &[(0, 2, ps, 14.0, BLACK)]); + let z = sketch_zip("83.2", 139, document(&["P"]), &[page("P", "Page", vec![mk("A", "CourierNewPSMT"), mk("B", "BrandSans-Bold")])], &[]); + let lookup: crate::sketch::FontLookup = Box::new(|ps: &str| (ps == "CourierNewPSMT").then(|| ("Courier New".to_string(), "Regular".to_string()))); + let (doc, _) = crate::sketch::import_with_fonts(&z, "f", Some(lookup)).unwrap(); + assert_eq!((data(&doc, "A").style.font_family.as_str(), data(&doc, "A").style.font_style.as_str()), ("Courier New", "Regular")); + assert_eq!(data(&doc, "B").style.font_family, "Brand Sans"); + let (doc, _) = import(&z, "f").unwrap(); + assert_eq!(data(&doc, "A").style.font_family, "Courier New PSMT"); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 3d6a48d3..e651463d 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -58,6 +58,9 @@ pub(crate) struct Cx { /// Layers mapped so far, and the most the file may give. pub nodes: usize, pub max_nodes: usize, + /// Finds installed faces by PostScript name, and what it found (or didn't) so far. + pub fonts: Option, + pub font_cache: std::collections::BTreeMap>, } impl Cx { @@ -90,7 +93,7 @@ impl Cx { } /// Map the pages of `archive` to a REST file JSON named `name`. -pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { +pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize, fonts: Option) -> Map { let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); let shared = super::shared::Shared::read(&archive.document); let mut cx = Cx { @@ -109,6 +112,8 @@ pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize) -> Map { too_old: false, nodes: 0, max_nodes, + fonts, + font_cache: std::collections::BTreeMap::new(), }; let defs = super::shared::Shared::definitions(&mut cx); cx.style_defs = defs.iter().filter_map(|d| d.get("id").and_then(Value::as_str).map(str::to_string)).collect(); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 22ca4b84..34d76b12 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -86,6 +86,8 @@ pub struct Face { pub id: u32, pub family: String, pub style: String, + /// The face's PostScript name ("HelveticaNeue-Bold"). + pub postscript: Option, pub weight: u16, pub italic: bool, pub bytes: FontBytes, @@ -185,6 +187,7 @@ impl Face { id: NEXT_FACE.fetch_add(1, Ordering::Relaxed), italic: !matches!(attrs.style, skrifa::attribute::Style::Normal) || style.to_ascii_lowercase().contains("italic"), weight, + postscript: name_string(&f, StringId::POSTSCRIPT_NAME), family, style, index, @@ -493,6 +496,8 @@ struct Pending { path: std::path::PathBuf, index: u32, style: String, + /// The face's PostScript name. + postscript: Option, } #[derive(Default)] @@ -570,6 +575,16 @@ impl FontDb { (0..count).filter_map(|i| Face::from_bytes(FontBytes::Shared(shared.clone()), i, None, None)).map(|f| self.add(f)).collect() } + /// The family and style of the face whose PostScript name is `ps` (ignoring case), loaded or + /// only scanned. + pub fn postscript_face(&self, ps: &str) -> Option<(String, String)> { + let is = |p: &Option| p.as_deref().is_some_and(|p| p.eq_ignore_ascii_case(ps)); + if let Some(f) = self.faces.iter().find(|f| is(&f.postscript)) { + return Some((f.family.clone(), f.style.clone())); + } + self.pending.values().find_map(|(display, entries)| entries.iter().find(|e| is(&e.postscript)).map(|e| (display.clone(), e.style.clone()))) + } + /// Family display names, sorted (loaded and known system families). pub fn families(&self) -> Vec { let mut v: Vec = self.families.values().map(|(d, _)| d.clone()).collect(); @@ -897,8 +912,13 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap Option> { Some(buf) } -/// (index, family, style) of each face in a font file: outline fonts and colour fonts (Apple Color +/// (index, family, style, PostScript name) of each face in a font file: outline fonts and colour fonts (Apple Color /// Emoji draws most emoji from `sbix`, Noto Color Emoji has no outlines at all). -fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { +fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String, Option)> { let count = match FileRef::new(bytes) { Ok(FileRef::Collection(c)) => c.len(), Ok(FileRef::Font(_)) => 1, @@ -959,7 +979,7 @@ fn faces_in(bytes: &[u8]) -> Vec<(u32, String, String)> { .then_some(())?; let (family, style) = names(&f); let family = public_family(&family?)?; - Some((i, family, style.unwrap_or_else(|| "Regular".into()))) + Some((i, family, style.unwrap_or_else(|| "Regular".into()), name_string(&f, StringId::POSTSCRIPT_NAME))) }) .collect() } @@ -989,6 +1009,14 @@ fn apple_alias(key: &str) -> Option<&'static str> { } /// Convenience: find a face with the global db. +/// The family and style of the installed face whose PostScript name is `ps`. +pub fn postscript_face(ps: &str) -> Option<(String, String)> { + if ps.is_empty() { + return None; + } + db().read().unwrap_or_else(std::sync::PoisonError::into_inner).postscript_face(ps) +} + pub fn find_face(family: &str, style: &str) -> Option> { let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); db.find(family, style).or_else(|| db.default_face()) diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 0b83e1d3..5ff1e2e6 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -2,7 +2,8 @@ use super::*; fn pending(styles: &[&str]) -> FontDb { let mut db = FontDb::default(); - let entries = styles.iter().map(|s| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: (*s).into() }).collect(); + let entries = + styles.iter().map(|s| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: (*s).into(), postscript: None }).collect(); db.pending.insert("cjk ui".into(), ("CJK UI".into(), entries)); db } @@ -221,7 +222,7 @@ fn user_font_dirs_preserve_non_utf8_paths() { /// A rescan offers new families and the styles installed since, and never a file twice. #[test] fn a_rescan_offers_fonts_installed_since() { - let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() }; + let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into(), postscript: None }; let mut db = pending(&["Regular"]); let found = |fams: &[(&str, &[&str])]| -> BTreeMap)> { fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect() @@ -271,7 +272,7 @@ fn apple_system_font_names_find_the_system_font() { let mut db = FontDb::default(); db.pending.insert( "system font".into(), - ("System Font".into(), vec![Pending { path: "no-such-dir/SFNS.ttf".into(), index: 0, style: "Regular".into() }]), + ("System Font".into(), vec![Pending { path: "no-such-dir/SFNS.ttf".into(), index: 0, style: "Regular".into(), postscript: None }]), ); for name in ["SF Pro", "SF Pro Text", "SF Pro Display", "SF UI Text", "SF UI Display", ".SF NS Text", "San Francisco"] { assert!(db.has_family(name), "{name}"); @@ -324,3 +325,27 @@ fn trak_tracking_by_size() { let _ = f.tracking(12.0); } } + +/// A face is found by its PostScript name (ignoring case), loaded or only scanned: the family and +/// style it is offered under. +#[test] +fn faces_by_postscript_name() { + let mut db = FontDb::default(); + db.pending.insert( + "brand sans".into(), + ( + "Brand Sans".into(), + vec![Pending { path: "no-such-dir/b.ttf".into(), index: 0, style: "Bold".into(), postscript: Some("BrandSans-Bold".into()) }], + ), + ); + assert_eq!(db.postscript_face("brandsans-bold"), Some(("Brand Sans".to_string(), "Bold".to_string()))); + assert_eq!(db.postscript_face("BrandSans-Light"), None); + if let Some(f) = CRAFT_FONTS.first() + && let Some(face) = Face::from_bytes(FontBytes::Static(f.bytes), 0, Some(f.family), Some(f.style)) + && let Some(ps) = face.postscript.clone() + { + db.add(face); + assert_eq!(db.postscript_face(&ps), Some((f.family.to_string(), f.style.to_string()))); + } + assert!(postscript_face("").is_none()); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 998b6091..d1038576 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts from PostScript names matched to installed families, variable font axes; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From a8bade1446063e2ffefe9194bb622af98642b49b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 23:37:28 +0200 Subject: [PATCH 051/208] M6: Sketch import: alpha masks, Fade, linear gradient geometry Alpha masks only mask (no drawn copy of their fill); a progressive opacity (Fade) becomes an alpha mask of its gradient over the layer; linear gradients run square in pixels as Sketch draws them. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/style.rs | 7 +++- crates/format/src/sketch/style_tests.rs | 46 +++++++++++++++++++++++++ crates/format/src/sketch/tree.rs | 45 ++++++++++++++++++++++-- docs/file-format-parity.md | 2 +- 4 files changed, 95 insertions(+), 5 deletions(-) diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index 13320673..45c739e6 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -137,7 +137,12 @@ fn gradient(g: &Value, w: f64, h: f64) -> Option { } // Sketch starts on the right and turns clockwise; from/to don't change it. 2 => ("GRADIENT_ANGULAR", json!([pt((0.5, 0.5)), pt((1.0, 0.5)), pt((0.5, 1.0))])), - _ => ("GRADIENT_LINEAR", json!([pt(from), pt(to)])), + // Square to the line in pixels, as Sketch draws it (FigCraft's default is square in 0–1). + _ => { + let (dx, dy) = ((to.0 - from.0) * w, (to.1 - from.1) * h); + let across = if w > 0.0 && h > 0.0 { (from.0 - dy / w, from.1 + dx / h) } else { (from.0 - (to.1 - from.1), from.1 + (to.0 - from.0)) }; + ("GRADIENT_LINEAR", json!([pt(from), pt(to), pt(across)])) + } }; Some(json!({"type": ty, "gradientHandlePositions": handles, "gradientStops": stops})) } diff --git a/crates/format/src/sketch/style_tests.rs b/crates/format/src/sketch/style_tests.rs index b5d24bdf..e553ab6b 100644 --- a/crates/format/src/sketch/style_tests.rs +++ b/crates/format/src/sketch/style_tests.rs @@ -318,3 +318,49 @@ fn bitmap_color_controls_become_filters() { assert_eq!(img.filters.contrast, 0.0); assert!(warnings.iter().any(|w| w.contains("hue")), "{warnings:?}"); } + +/// An alpha mask (`clippingMaskMode` 1) only masks: Sketch doesn't draw its fill under the content. +#[test] +fn alpha_masks_draw_no_copy() { + let fill = json!([solid_fill(0.0, 0.0, 0.0, 1.0)]); + let m = styled(with(with(r("Fade"), "hasClippingMask", json!(true)), "clippingMaskMode", json!(1)), "fills", fill); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 100.0, 50.0, vec![m, r("Photo")])]); + assert!(!doc.nodes().any(|n| n.name == "Fade (drawn)")); + assert_eq!(node(&doc, "Fade").mask_type, figcraft_doc::MaskType::Alpha); +} + +/// A progressive opacity (Fade: `contextSettings.isProgressive` with a gradient) becomes an alpha +/// mask of that gradient over the layer, in a group in the layer's place. +#[test] +fn fades_become_alpha_masks() { + let fade = json!({"_class": "graphicsContextSettings", "blendMode": 0, "opacity": 1, "isProgressive": true, + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}}); + let l = styled(rect("Photo", 30.0, 40.0, 100.0, 50.0), "contextSettings", fade); + let (doc, _) = open(vec![group("G", 0.0, 0.0, 200.0, 200.0, vec![l])]); + // The group takes the layer's name too. + let photo = doc.nodes().find(|n| n.name == "Photo" && matches!(n.kind, NodeKind::Rectangle)).unwrap(); + let wrap = doc.node(photo.parent.unwrap()).unwrap(); + assert_eq!(wrap.name, "Photo"); + assert!(matches!(wrap.kind, NodeKind::Group), "{:?}", wrap.kind); + let mask = doc.node(wrap.children[0]).unwrap(); + assert!(mask.is_mask && mask.mask_type == figcraft_doc::MaskType::Alpha); + assert!(matches!(mask.fills[0].kind, PaintKind::Gradient(_))); + assert_eq!(doc.world_bounds(mask.id), doc.world_bounds(photo.id)); + let b = doc.world_bounds(photo.id).unwrap(); + assert_eq!((b.x0, b.y0, b.width(), b.height()), (30.0, 40.0, 100.0, 50.0)); +} + +/// Sketch's linear gradients run square in pixels: on a 200×100 layer, from the top left to the +/// bottom right, the stripes are at right angles to that line in pixels, not in the layer's 0–1. +#[test] +fn linear_gradients_are_square_in_pixels() { + let g = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0, 0}", "to": "{1, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(1.0, 1.0, 1.0, 1.0)}]}}); + let (doc, _) = open(vec![styled(rect("Wide", 0.0, 0.0, 200.0, 100.0), "fills", json!([g]))]); + let PaintKind::Gradient(g) = &node(&doc, "Wide").fills[0].kind else { panic!("not a gradient") }; + // The line runs (200, 100) in pixels; square to it, (−100, 200), is (−0.5, 2) in 0–1. + let (wx, wy) = ((g.width.0 - g.from.0) * 200.0, (g.width.1 - g.from.1) * 100.0); + assert!((wx * 200.0 + wy * 100.0).abs() < 1e-6, "{g:?}"); +} diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index e651463d..841b4daf 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -267,7 +267,45 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: node.insert("children".into(), Value::Array(kids)); super::stack::apply(l, &mut node); } - Some(Value::Object(node)) + Some(fade(cx, l, node, world, w, h)) +} + +/// Layer `node` with a progressive opacity (Fade: the style's `contextSettings` with +/// `isProgressive` and a gradient whose stops' opacity is the layer's) as a group in its place +/// holding an alpha mask of that gradient over it; FigCraft has no progressive opacity. Other +/// layers as they are. +fn fade(cx: &mut Cx, l: &Value, node: JsonMap, world: Affine, w: f64, h: f64) -> Value { + let ctx = l.get("style").and_then(|s| s.get("contextSettings")); + let gradient = ctx.filter(|c| c.get("isProgressive").and_then(Value::as_bool) == Some(true)).and_then(|c| c.get("gradient")); + let Some(gradient) = gradient else { return Value::Object(node) }; + let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "gradient": gradient}); + let fills = super::style::fills(cx, &json!({"fills": [fill]}), w, h); + if fills.is_empty() { + return Value::Object(node); + } + let id = str_field(l, "do_objectID").unwrap_or("layer"); + let identity = json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]); + let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + let abs = json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()}); + let mut inner = node.clone(); + let mut group = JsonMap::new(); + for k in ["relativeTransform", "absoluteBoundingBox", "constraints", "visible", "locked"] { + if let Some(v) = node.get(k) { + group.insert(k.into(), v.clone()); + } + } + group.insert("type".into(), json!("GROUP")); + group.insert("id".into(), json!(format!("{id}-fade"))); + group.insert("name".into(), node.get("name").cloned().unwrap_or_else(|| json!("Fade"))); + group.insert("size".into(), json!({"x": w, "y": h})); + group.insert("absoluteBoundingBox".into(), abs.clone()); + inner.insert("relativeTransform".into(), identity.clone()); + inner.insert("absoluteBoundingBox".into(), abs.clone()); + inner.insert("visible".into(), json!(true)); + let mask = json!({"type": "RECTANGLE", "id": format!("{id}-fade-mask"), "name": "Fade", "visible": true, "size": {"x": w, "y": h}, + "relativeTransform": identity, "absoluteBoundingBox": abs, "fills": fills, "isMask": true, "maskType": "ALPHA"}); + group.insert("children".into(), json!([mask, Value::Object(inner)])); + Value::Object(group) } /// A Sketch 2025+ group that is a frame: it behaves as a frame or a graphic, or clips. @@ -524,9 +562,10 @@ pub(crate) fn children(cx: &mut Cx, l: &Value, world: Affine, size: (f64, f64), out } -/// A plain copy of mask `n` when its style shows something (fills, borders or effects). +/// A plain copy of outline mask `n` when its style shows something (fills, borders or effects). +/// An alpha mask only masks. fn drawn_mask(n: &Value) -> Option { - if n.get("isMask").and_then(Value::as_bool) != Some(true) { + if n.get("isMask").and_then(Value::as_bool) != Some(true) || n.get("maskType").and_then(Value::as_str) == Some("ALPHA") { return None; } let shows = diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d1038576..38d94c51 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (a mask that shows its own style draws a copy under itself, as Sketch does; broken mask chains wrapped); text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 1.2% on average (at most 3.2%) | 5–10 (Smart Layout as auto layout, progressive layer opacity, motion and zoom blurs) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From de0650e3f7d0a44b79d40bc89bc074c412b047ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 23:48:19 +0200 Subject: [PATCH 052/208] M6: Sketch import: layout grids come in hidden Sketch draws an artboard's layout and square grids only while its layout view is on; they now import hidden (still there, shown with the eye in the Layout grid section). Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/proto.rs | 9 +++++---- crates/format/src/sketch/proto_tests.rs | 2 ++ docs/file-format-parity.md | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/format/src/sketch/proto.rs b/crates/format/src/sketch/proto.rs index 1a08d268..8574ab34 100644 --- a/crates/format/src/sketch/proto.rs +++ b/crates/format/src/sketch/proto.rs @@ -74,7 +74,8 @@ pub(crate) fn export_settings(cx: &mut Cx, l: &Value, node: &mut JsonMap) { let mut out = Vec::new(); if let Some(g) = l.get("layout").filter(|g| bool_field(g, "isEnabled") != Some(false)) { @@ -83,21 +84,21 @@ pub(crate) fn layout_grids(l: &Value, h: f64, node: &mut JsonMap) && let (Some(count), Some(width)) = (num("numberOfColumns"), num("columnWidth")) { out.push(json!({"pattern": "COLUMNS", "alignment": "MIN", "count": count.min(1000.0), "sectionSize": width.min(1e5), - "gutterSize": num("gutterWidth").unwrap_or(0.0).min(1e5), "offset": num("horizontalOffset").unwrap_or(0.0).min(1e5), "visible": true})); + "gutterSize": num("gutterWidth").unwrap_or(0.0).min(1e5), "offset": num("horizontalOffset").unwrap_or(0.0).min(1e5), "visible": false})); } if bool_field(g, "drawHorizontal") == Some(true) { let gutter = num("gutterHeight").unwrap_or(0.0).min(1e5); let section = (gutter * num("rowHeightMultiplication").unwrap_or(1.0)).min(1e5); if section > 0.0 { let count = (h / (section + gutter)).floor().clamp(1.0, 1000.0); - out.push(json!({"pattern": "ROWS", "alignment": "MIN", "count": count, "sectionSize": section, "gutterSize": gutter, "offset": 0, "visible": true})); + out.push(json!({"pattern": "ROWS", "alignment": "MIN", "count": count, "sectionSize": section, "gutterSize": gutter, "offset": 0, "visible": false})); } } } if let Some(g) = l.get("grid").filter(|g| bool_field(g, "isEnabled") != Some(false)) && let Some(size) = g.get("gridSize").and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0) { - out.push(json!({"pattern": "GRID", "sectionSize": size.min(1e5), "visible": true})); + out.push(json!({"pattern": "GRID", "sectionSize": size.min(1e5), "visible": false})); } if !out.is_empty() { node.insert("layoutGrids".into(), Value::Array(out)); diff --git a/crates/format/src/sketch/proto_tests.rs b/crates/format/src/sketch/proto_tests.rs index be9b835d..facef17a 100644 --- a/crates/format/src/sketch/proto_tests.rs +++ b/crates/format/src/sketch/proto_tests.rs @@ -101,6 +101,8 @@ fn layout_and_square_grids() { let grids = format!("{:?}", node(&doc, "Board").grids); assert_eq!(node(&doc, "Board").grids.len(), 2, "{grids}"); assert!(grids.contains("12") && grids.contains("16"), "{grids}"); + // Hidden, as Sketch shows them only when its layout view is on. + assert!(node(&doc, "Board").grids.iter().all(|g| !g.visible), "{grids}"); } #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 38d94c51..76d7437a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids. Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From 5f5b8021270abdbf2652affb1d0965f0c971f1c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 23:49:57 +0200 Subject: [PATCH 053/208] M6: Sketch import: roadmap and parity rows Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- docs/target-app-parity.md | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3f57ed57..0a7b5042 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,7 +316,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | +| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a1a5bf12..6ab02ade 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (`.sketch` import, best effort, numbers unchanged; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -124,7 +124,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects |---|---|---|---|---|---| | Features (depth) | 35 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | -| File formats | 12 | 60% | 5–10 | `.fig` read, best effort, with editable vector networks and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | +| File formats | 12 | 60% | 5–10 | `.sketch` read (Sketch 48 and newer, best effort; 2026-10-21): live symbols, shared styles, Sketch 2025 frames and stacks, checked on three real files against Sketch's own renders (0.8–2% of pixels differ on average); `.fig` read, best effort, with editable vector networks and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 52–104 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | | Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | | Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | @@ -639,6 +639,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| | 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | +| 2026-10-21 | minor | `.sketch` import (best effort, Sketch 48 and newer) in the file formats dimension: pages, layers, styles, live symbols with overrides, shared styles and Color Variables, prototype links, Sketch 2025 frames, stacks as auto layout and tints, text placed as Sketch places it; checked on three real files (Sketch 50, 64 and 2026) against Sketch's own renders. Numbers unchanged: Figma has no `.sketch` reader in its desktop app, so the file-exchange discounts (Figma users' files) don't move | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | From 79894cfad22723b40a7520f619a2e8419d67f20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 23:54:25 +0200 Subject: [PATCH 054/208] M6: Sketch import: junk tints and colour overrides can't panic A tint or fill that isn't a JSON object is left alone instead of being indexed. A structural fuzz replaces each value of a rich file with values of other types and imports every variant. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/frames_tests.rs | 14 +++++ crates/format/src/sketch/style.rs | 6 +- crates/format/src/sketch/symbols.rs | 11 +++- crates/format/src/sketch/tests.rs | 75 ++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/crates/format/src/sketch/frames_tests.rs b/crates/format/src/sketch/frames_tests.rs index aadc4b5f..2ae2b62d 100644 --- a/crates/format/src/sketch/frames_tests.rs +++ b/crates/format/src/sketch/frames_tests.rs @@ -383,3 +383,17 @@ fn cut_corners_become_paths() { assert_eq!(arc.vertices.len(), 8); assert!(arc.segments.iter().any(|g| g.ta != (0.0, 0.0)), "the corners are arcs"); } + +/// Tints and colour overrides that aren't colours, and fills that aren't objects, are junk: left +/// alone, never a panic. +#[test] +fn junk_tints_and_colour_overrides() { + let child = styled(rect("Child", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut g = group("G", 0.0, 0.0, 10.0, 10.0, vec![child.clone()]); + g["style"] = json!({"_class": "style", "fills": [{"_class": "fill", "isEnabled": true, "fillType": 0, "layeringType": 1, "color": 5}]}); + let (_, _) = open(vec![g]); + let mut odd = rect("L", 0.0, 0.0, 10.0, 10.0); + odd["style"]["fills"] = json!([true]); + let m = master("M", "S", "Odd", 10.0, 10.0, vec![odd]); + let _ = open_symbols(vec![instance("I", "S", 0.0, 0.0, 10.0, 10.0, &[("L_fillColor", color(1.0, 0.0, 0.0, 1.0))])], vec![m]); +} diff --git a/crates/format/src/sketch/style.rs b/crates/format/src/sketch/style.rs index 45c739e6..5e476803 100644 --- a/crates/format/src/sketch/style.rs +++ b/crates/format/src/sketch/style.rs @@ -335,7 +335,7 @@ pub(crate) fn tint_of(style: Option<&Value>, legacy: bool) -> Option { None => legacy, } })?; - (f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0).then(|| f.get("color").cloned()).flatten() + (f.get("fillType").and_then(Value::as_i64).unwrap_or(0) == 0).then(|| f.get("color").filter(|c| c.is_object()).cloned()).flatten() } /// Recolour `layers` (Sketch JSON) with tint `color`: every enabled fill and border takes it, @@ -382,7 +382,9 @@ pub(crate) fn tint_layers(layers: &mut [Value], color: &Value, depth: usize) { fn tinted(old: Option<&Value>, tint: &Value) -> Value { let alpha = |c: Option<&Value>| c.and_then(|c| num(c, "alpha")).unwrap_or(1.0).clamp(0.0, 1.0); let mut c = tint.clone(); - c["alpha"] = json!(alpha(Some(tint)) * alpha(old)); + if let Some(m) = c.as_object_mut() { + m.insert("alpha".into(), json!(alpha(Some(tint)) * alpha(old))); + } c } diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index 06a39f68..f3cd26c4 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -409,10 +409,15 @@ fn apply( } // A colour override on a shape or group: its first fill's colour. if let Some(c) = get("fillColor").filter(|c| c.is_object()) - && let Some(f) = l.get_mut("style").and_then(|s| s.get_mut("fills")).and_then(Value::as_array_mut).and_then(|f| f.first_mut()) + && let Some(f) = l + .get_mut("style") + .and_then(|s| s.get_mut("fills")) + .and_then(Value::as_array_mut) + .and_then(|f| f.first_mut()) + .and_then(Value::as_object_mut) { - f["fillType"] = json!(0); - f["color"] = c.clone(); + f.insert("fillType".into(), json!(0)); + f.insert("color".into(), c.clone()); } } } diff --git a/crates/format/src/sketch/tests.rs b/crates/format/src/sketch/tests.rs index 4fb24400..8ed30231 100644 --- a/crates/format/src/sketch/tests.rs +++ b/crates/format/src/sketch/tests.rs @@ -89,6 +89,81 @@ fn junk_never_panics() { let _ = import(&raw_zip(&document(&["P"]), &json!("x"), &[], &[]), "f"); } +/// A file using most of what the importer reads (Sketch 2025 frames and stacks, tints, symbols +/// with overrides, text, paths, gradients, masks, fades, grids) with each of its values in turn +/// replaced by a value of another type: every import ends without a panic. +#[test] +fn junk_values_never_panic() { + let text_layer = text("T", 0.0, 0.0, 80.0, 20.0, "Go", &[(0, 2, "Helvetica", 14.0, [0.0, 0.0, 0.0, 1.0])]); + let dot = styled(rect("Dot", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + let mut tint = solid_fill(1.0, 0.0, 0.0, 1.0); + tint["layeringType"] = json!(1); + let mut icon = master("MI", "SI", "Icon", 24.0, 24.0, vec![dot.clone()]); + icon["groupBehavior"] = json!(2); + icon["style"]["fills"] = json!([tint]); + let mut chip = stack(master("MC", "SC", "Chip", 60.0, 24.0, vec![text_layer.clone(), instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[])]), 0, 1, 1, 6.0, [8.0, 4.0, 10.0, 4.0]); + chip["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [12], "style": 2, "smoothing": 0.6}); + let over = [("T_stringValue", json!("Longer")), ("N_fillColor", color(0.0, 1.0, 0.0, 1.0)), ("T_textColor", color(0.0, 0.0, 1.0, 1.0)), ("N_isVisible", json!("0"))]; + let inst = instance("I", "SC", 0.0, 100.0, 200.0, 24.0, &over); + let grad = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0), + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0, 0}", "to": "{1, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(1.0, 1.0, 1.0, 0.0)}]}}); + let mut faded = styled(rect("Faded", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad.clone()])); + faded["style"]["contextSettings"] = json!({"_class": "graphicsContextSettings", "opacity": 1, "isProgressive": true, "gradient": grad["gradient"].clone()}); + let mask = with(with(styled(rect("Mask", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad])), "hasClippingMask", json!(true)), "clippingMaskMode", json!(1)); + let path = shape_path("P", 0.0, 0.0, 20.0, 20.0, &[(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)], true); + let mut g = group("G", 0.0, 0.0, 100.0, 100.0, vec![mask, faded, path, dot, text_layer]); + g["style"] = json!({"_class": "style", "fills": [tint_fill()]}); + let mut frame = stack(frame_group("F", 0.0, 0.0, 300.0, 300.0, vec![g, inst]), 1, 0, 3, 8.0, [0.0; 4]); + frame["layout"] = json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 20, "gutterWidth": 10, "numberOfColumns": 4, "drawVertical": true}); + let pages = [page("P", "Page", vec![frame]), page("S", "Symbols", vec![icon, chip])]; + // Every value's path, with each junk value in turn (a deterministic sample keeps it quick). + fn paths(v: &serde_json::Value, at: &mut Vec, out: &mut Vec>) { + out.push(at.clone()); + match v { + serde_json::Value::Object(m) => { + for (k, c) in m { + at.push(k.clone()); + paths(c, at, out); + at.pop(); + } + } + serde_json::Value::Array(a) => { + for (i, c) in a.iter().enumerate() { + at.push(i.to_string()); + paths(c, at, out); + at.pop(); + } + } + _ => {} + } + } + let junk = [json!(null), json!(5), json!(-1e300), json!("x"), json!(true), json!([]), json!({}), json!([1, "a"])]; + for (pi, p) in pages.iter().enumerate() { + let mut all = Vec::new(); + paths(p, &mut Vec::new(), &mut all); + for (n, at) in all.iter().enumerate().skip(1) { + let mut changed = pages.clone(); + let mut v = &mut changed[pi]; + for k in at { + v = match v { + serde_json::Value::Object(m) => m.get_mut(k).unwrap(), + serde_json::Value::Array(a) => &mut a[k.parse::().unwrap()], + _ => unreachable!(), + }; + } + *v = junk[n % junk.len()].clone(); + let _ = import(&sketch_zip("2026.3.1", 196, document(&["P", "S"]), &changed, &[]), "f"); + } + } +} + +fn tint_fill() -> serde_json::Value { + let mut f = solid_fill(0.0, 0.0, 1.0, 1.0); + f["layeringType"] = json!(1); + f +} + #[test] fn deep_nesting_does_not_fail_the_file() { let mut deep = rect("R", 0.0, 0.0, 1.0, 1.0); From 40c7cb302917d10e61023369a092dd6863a96c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Wed, 21 Oct 2026 23:57:20 +0200 Subject: [PATCH 055/208] M6: Sketch import: tint copies and handed overrides count as work Tinting copies a group's or master's layers; the copy is charged to the import's work budget (past it, tints are skipped with a note). An override handed to a nested instance costs its value's size, so deep chains of large overrides can't be copied without end. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/frames_tests.rs | 29 ++++++++++++++++++++++++ crates/format/src/sketch/symbols.rs | 20 +++++++++++----- crates/format/src/sketch/tree.rs | 21 +++++++++++++++-- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/crates/format/src/sketch/frames_tests.rs b/crates/format/src/sketch/frames_tests.rs index 2ae2b62d..429a884b 100644 --- a/crates/format/src/sketch/frames_tests.rs +++ b/crates/format/src/sketch/frames_tests.rs @@ -397,3 +397,32 @@ fn junk_tints_and_colour_overrides() { let m = master("M", "S", "Odd", 10.0, 10.0, vec![odd]); let _ = open_symbols(vec![instance("I", "S", 0.0, 0.0, 10.0, 10.0, &[("L_fillColor", color(1.0, 0.0, 0.0, 1.0))])], vec![m]); } + +/// Tinting copies a group's layers: the copies count against the import's work budget, so nested +/// tinted groups of large layers can't make the import copy without end; past the budget, tints +/// are skipped with a note. +#[test] +fn nested_tints_are_budgeted() { + // Text that doesn't compress, so the page stays within the archive's size checks. + let mut seed = 0x2545_f491_u32; + let big: String = (0..1_000_000) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + char::from(b'a' + (seed % 26) as u8) + }) + .collect(); + let mut l = styled(rect("Leaf", 0.0, 0.0, 10.0, 10.0), "fills", json!([solid_fill(0.0, 0.0, 0.0, 1.0)])); + for i in 0..35 { + let mut pad = rect(&big, 0.0, 0.0, 1.0, 1.0); + pad["do_objectID"] = json!(format!("pad{i}")); + let mut g = group(&format!("G{i}"), 0.0, 0.0, 10.0, 10.0, vec![l, pad]); + let mut t = solid_fill(1.0, 0.0, 0.0, 1.0); + t["layeringType"] = json!(1); + g["style"] = json!({"_class": "style", "fills": [t]}); + l = g; + } + let (_, warnings) = open(vec![l]); + assert!(warnings.iter().any(|w| w.contains("tint")), "{warnings:?}"); +} diff --git a/crates/format/src/sketch/symbols.rs b/crates/format/src/sketch/symbols.rs index f3cd26c4..dd806282 100644 --- a/crates/format/src/sketch/symbols.rs +++ b/crates/format/src/sketch/symbols.rs @@ -80,7 +80,7 @@ impl Symbols { } /// What copying and mapping `v` costs, in work units: one per JSON value, one per 8 bytes of text. -fn weight(v: &Value, depth: usize) -> u64 { +pub(crate) fn weight(v: &Value, depth: usize) -> u64 { if depth > 256 { return 1; } @@ -141,7 +141,7 @@ pub(crate) fn component(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, ro let sid = str_field(l, "symbolID").unwrap_or("").to_string(); // A graphic's fill tints its layers. let tinted; - let l = match super::style::tint_of(l.get("style"), false) { + let l = match super::style::tint_of(l.get("style"), false).filter(|_| super::tree::spend_on_tint(cx, l)) { Some(t) => { let mut c = l.clone(); if let Some(kids) = c.get_mut("layers").and_then(Value::as_array_mut) { @@ -342,8 +342,8 @@ fn nested(ov: &BTreeMap) -> BTreeMap<&str, Vec<(&str, &Value)>> { /// Apply `ov` to the layers of a master's copy: to the layers it names, and passed on to nested /// instances for the layers inside them (an outer override wins over the nested instance's own). -/// Each override handed to a nested instance costs [`OVERRIDE_COST`] units of `budget`; once it is spent, the -/// rest aren't handed on. +/// Each override handed to a nested instance costs [`OVERRIDE_COST`] units of `budget` plus its +/// value's [`weight`]; once it is spent, the rest aren't handed on. fn apply( layers: &mut [Value], ov: &BTreeMap, @@ -368,8 +368,16 @@ fn apply( _ => {} } let inner = nested.get(id.as_str()).map(Vec::as_slice).unwrap_or_default(); - let take = usize::try_from(*budget / OVERRIDE_COST).unwrap_or(usize::MAX).min(inner.len()); - *budget -= take as u64 * OVERRIDE_COST; + // Each costs its copy's size: deep chains of large overrides use up the budget. + let mut take = 0; + for (_, v) in inner { + let cost = OVERRIDE_COST + weight(v, 0); + if cost > *budget { + break; + } + *budget -= cost; + take += 1; + } if take > 0 { let mut own = l.get("overrideValues").and_then(Value::as_array).cloned().unwrap_or_default(); own.extend(inner.iter().take(take).map(|(rest, v)| json!({"_class": "overrideValue", "overrideName": rest, "value": v}))); diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index 841b4daf..cccf3934 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -87,11 +87,27 @@ impl Cx { true } + /// Spend `n` units of the work budget on tinting; false (with a note, once) when it would run + /// out. + pub(crate) fn spend_on_tint(&mut self, n: u64) -> bool { + if self.work.saturating_add(n) > self.max_work { + self.warn("tints past what the import handles were skipped; those layers keep their own colours"); + return false; + } + self.work += n; + true + } + pub(crate) fn work_left(&self) -> u64 { self.max_work.saturating_sub(self.work) } } +/// Charge the copy tinting layer `l` makes against the work budget: whether it may be tinted. +pub(crate) fn spend_on_tint(cx: &mut Cx, l: &Value) -> bool { + cx.spend_on_tint(super::symbols::weight(l, 0)) +} + /// Map the pages of `archive` to a REST file JSON named `name`. pub(crate) fn to_rest(archive: &Archive, name: &str, max_nodes: usize, fonts: Option) -> Map { let symbols = super::symbols::Symbols::read(&archive.document, &archive.pages); @@ -247,8 +263,9 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: // A group's or graphic's fill tints its layers (a frame's is its background). let tinted; let gb = l.get("groupBehavior").and_then(Value::as_i64); - let l = match (class, super::style::tint_of(l.get("style"), gb != Some(1))) { - ("group", Some(t)) => { + let tint = if class == "group" { super::style::tint_of(l.get("style"), gb != Some(1)) } else { None }; + let l = match tint.filter(|_| spend_on_tint(cx, l)) { + Some(t) => { // A plain group's legacy fill is all tint; a frame keeps its background fills. if gb != Some(1) { node.remove("fills"); From 3e92904cba325f9a451f96aa54401a0f66d0400a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Thu, 22 Oct 2026 00:03:30 +0200 Subject: [PATCH 056/208] M6: Sketch import: review fixes Texts an auto layout places keep the layout's position (the next layout pass would take the baseline move back). A faded layer's group takes the layer's id, so stacks, links and overrides still find it; faded masks stay masks. figcraft-format no longer depends on figcraft-layout. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 - crates/engine/src/cmd/file.rs | 5 +++- crates/engine/src/sketch_import_tests.rs | 35 ++++++++++++++++++++++++ crates/format/Cargo.toml | 1 - crates/format/src/sketch/frames_tests.rs | 15 ++++++++++ crates/format/src/sketch/tests.rs | 27 ++++++++++++++---- crates/format/src/sketch/tree.rs | 13 +++++---- 7 files changed, 83 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d118fda..32e7be59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1587,7 +1587,6 @@ name = "figcraft-format" version = "0.9.0" dependencies = [ "figcraft-doc", - "figcraft-layout", "figcraft-pathops", "flate2", "image", diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8db15cee..8f7da824 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -720,6 +720,9 @@ fn sketch_line_heights(doc: &mut Document) { }; let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); for id in ids { + // An auto layout places its texts itself (its next pass would take a move back). + let laid_out = + doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); let Some(n) = doc.node_mut(id) else { continue }; let NodeKind::Text(td) = &mut n.kind else { continue }; let down = set(&mut td.style); @@ -727,7 +730,7 @@ fn sketch_line_heights(doc: &mut Document) { set(&mut r.style); } // Down in the layer's own frame (a rotated text moves along its own axis). - if down.is_finite() && down != 0.0 { + if !laid_out && down.is_finite() && down != 0.0 { n.transform *= figcraft_doc::geom::Affine::translate((0.0, down)); } } diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index 36b200a5..292cd019 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -441,3 +441,38 @@ fn sketch_text_gets_sketchs_natural_line() { let figcraft_doc::LineHeight::Percent(p) = td.style.line_height else { panic!("{:?}", td.style.line_height) }; assert!((p - 116.0).abs() < 1e-9, "{p}"); } + +/// A text an auto layout places isn't moved to Sketch's baseline (the next layout pass would take +/// the move back): laying the stack out again leaves it where the import put it. +#[test] +fn sketch_text_in_a_stack_stays_put() { + if !std::path::Path::new("/System/Library/Fonts/Helvetica.ttc").exists() { + return; + } + figcraft_text::fontdb::scan_system_fonts(); + let font = json!({"name": "Helvetica", "size": 40}); + let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}}); + let label = shape( + "text", + "Label", + [10.0, 10.0, 50.0, 48.0], + json!({"attributedString": {"string": "Hi", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}), + ); + let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 1, "justifyContent": 0, "alignItems": 0, "allGuttersGap": 0, "stackingOrder": 0}); + let stack = shape( + "group", + "Stack", + [0.0, 0.0, 200.0, 68.0], + json!({"groupBehavior": 1, "groupLayout": layout, "leftPadding": 10, "topPadding": 10, "rightPadding": 10, "bottomPadding": 10, "layers": [label]}), + ); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[stack])); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let mut doc = s.doc().unwrap().clone(); + let stack = doc.nodes().find(|n| n.name == "Stack").unwrap().id; + let label = doc.nodes().find(|n| n.name == "Label").unwrap().id; + let before = doc.world_bounds(label).unwrap(); + figcraft_layout::layout_frame(&mut doc, stack, &crate::measure::TextMeasure); + let after = doc.world_bounds(label).unwrap(); + assert!((before.y0 - after.y0).abs() < 1e-6, "{before:?} → {after:?}"); +} diff --git a/crates/format/Cargo.toml b/crates/format/Cargo.toml index 80a943e2..3dca3912 100644 --- a/crates/format/Cargo.toml +++ b/crates/format/Cargo.toml @@ -11,7 +11,6 @@ workspace = true [dependencies] figcraft-doc = { workspace = true } figcraft-pathops = { workspace = true } -figcraft-layout = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } zip = { workspace = true } diff --git a/crates/format/src/sketch/frames_tests.rs b/crates/format/src/sketch/frames_tests.rs index 429a884b..5a5cefca 100644 --- a/crates/format/src/sketch/frames_tests.rs +++ b/crates/format/src/sketch/frames_tests.rs @@ -426,3 +426,18 @@ fn nested_tints_are_budgeted() { let (_, warnings) = open(vec![l]); assert!(warnings.iter().any(|w| w.contains("tint")), "{warnings:?}"); } + +/// A faded layer in a stack keeps its place in the stack: the group the fade makes takes the +/// layer's id, and with it its sizing. +#[test] +fn faded_stack_items_keep_their_sizing() { + let fade = json!({"_class": "graphicsContextSettings", "opacity": 1, "isProgressive": true, + "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0.5, 0}", "to": "{0.5, 1}", "stops": [ + {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(0.0, 0.0, 0.0, 0.0)}]}}); + let mut item = styled(rect("Item", 0.0, 0.0, 50.0, 20.0), "contextSettings", fade); + item["horizontalSizing"] = json!(2); + let (doc, _) = open(vec![stack(frame_group("Row", 0.0, 0.0, 200.0, 20.0, vec![item]), 0, 0, 0, 0.0, [0.0; 4])]); + let row = node(&doc, "Row"); + let wrap = doc.node(row.children[0]).unwrap(); + assert_eq!(format!("{:?}", wrap.child_layout.sizing_h), "Fill", "{:?}", wrap.child_layout); +} diff --git a/crates/format/src/sketch/tests.rs b/crates/format/src/sketch/tests.rs index 8ed30231..b4368de1 100644 --- a/crates/format/src/sketch/tests.rs +++ b/crates/format/src/sketch/tests.rs @@ -101,21 +101,36 @@ fn junk_values_never_panic() { let mut icon = master("MI", "SI", "Icon", 24.0, 24.0, vec![dot.clone()]); icon["groupBehavior"] = json!(2); icon["style"]["fills"] = json!([tint]); - let mut chip = stack(master("MC", "SC", "Chip", 60.0, 24.0, vec![text_layer.clone(), instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[])]), 0, 1, 1, 6.0, [8.0, 4.0, 10.0, 4.0]); + let mut chip = stack( + master("MC", "SC", "Chip", 60.0, 24.0, vec![text_layer.clone(), instance("N", "SI", 0.0, 0.0, 24.0, 24.0, &[])]), + 0, + 1, + 1, + 6.0, + [8.0, 4.0, 10.0, 4.0], + ); chip["style"]["corners"] = json!({"_class": "MSImmutableStyleCorners", "radii": [12], "style": 2, "smoothing": 0.6}); - let over = [("T_stringValue", json!("Longer")), ("N_fillColor", color(0.0, 1.0, 0.0, 1.0)), ("T_textColor", color(0.0, 0.0, 1.0, 1.0)), ("N_isVisible", json!("0"))]; + let over = [ + ("T_stringValue", json!("Longer")), + ("N_fillColor", color(0.0, 1.0, 0.0, 1.0)), + ("T_textColor", color(0.0, 0.0, 1.0, 1.0)), + ("N_isVisible", json!("0")), + ]; let inst = instance("I", "SC", 0.0, 100.0, 200.0, 24.0, &over); let grad = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "color": color(0.0, 0.0, 0.0, 1.0), "gradient": {"_class": "gradient", "gradientType": 0, "from": "{0, 0}", "to": "{1, 1}", "stops": [ {"_class": "gradientStop", "position": 0, "color": color(0.0, 0.0, 0.0, 1.0)}, {"_class": "gradientStop", "position": 1, "color": color(1.0, 1.0, 1.0, 0.0)}]}}); let mut faded = styled(rect("Faded", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad.clone()])); - faded["style"]["contextSettings"] = json!({"_class": "graphicsContextSettings", "opacity": 1, "isProgressive": true, "gradient": grad["gradient"].clone()}); - let mask = with(with(styled(rect("Mask", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad])), "hasClippingMask", json!(true)), "clippingMaskMode", json!(1)); + faded["style"]["contextSettings"] = + json!({"_class": "graphicsContextSettings", "opacity": 1, "isProgressive": true, "gradient": grad["gradient"].clone()}); + let mask = + with(with(styled(rect("Mask", 0.0, 0.0, 30.0, 30.0), "fills", json!([grad])), "hasClippingMask", json!(true)), "clippingMaskMode", json!(1)); let path = shape_path("P", 0.0, 0.0, 20.0, 20.0, &[(0.0, 0.0), (1.0, 0.0), (0.5, 1.0)], true); let mut g = group("G", 0.0, 0.0, 100.0, 100.0, vec![mask, faded, path, dot, text_layer]); g["style"] = json!({"_class": "style", "fills": [tint_fill()]}); let mut frame = stack(frame_group("F", 0.0, 0.0, 300.0, 300.0, vec![g, inst]), 1, 0, 3, 8.0, [0.0; 4]); - frame["layout"] = json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 20, "gutterWidth": 10, "numberOfColumns": 4, "drawVertical": true}); + frame["layout"] = + json!({"_class": "layoutGrid", "isEnabled": true, "columnWidth": 20, "gutterWidth": 10, "numberOfColumns": 4, "drawVertical": true}); let pages = [page("P", "Page", vec![frame]), page("S", "Symbols", vec![icon, chip])]; // Every value's path, with each junk value in turn (a deterministic sample keeps it quick). fn paths(v: &serde_json::Value, at: &mut Vec, out: &mut Vec>) { @@ -149,7 +164,7 @@ fn junk_values_never_panic() { v = match v { serde_json::Value::Object(m) => m.get_mut(k).unwrap(), serde_json::Value::Array(a) => &mut a[k.parse::().unwrap()], - _ => unreachable!(), + _ => panic!("a path only runs through objects and arrays"), }; } *v = junk[n % junk.len()].clone(); diff --git a/crates/format/src/sketch/tree.rs b/crates/format/src/sketch/tree.rs index cccf3934..8bad4c6c 100644 --- a/crates/format/src/sketch/tree.rs +++ b/crates/format/src/sketch/tree.rs @@ -294,7 +294,8 @@ pub(crate) fn layer(cx: &mut Cx, l: &Value, parent: Affine, depth: usize, root: fn fade(cx: &mut Cx, l: &Value, node: JsonMap, world: Affine, w: f64, h: f64) -> Value { let ctx = l.get("style").and_then(|s| s.get("contextSettings")); let gradient = ctx.filter(|c| c.get("isProgressive").and_then(Value::as_bool) == Some(true)).and_then(|c| c.get("gradient")); - let Some(gradient) = gradient else { return Value::Object(node) }; + // A mask stays as it is: its group would stop masking the layers above it. + let Some(gradient) = gradient.filter(|_| node.get("isMask").and_then(Value::as_bool) != Some(true)) else { return Value::Object(node) }; let fill = json!({"_class": "fill", "isEnabled": true, "fillType": 1, "gradient": gradient}); let fills = super::style::fills(cx, &json!({"fills": [fill]}), w, h); if fills.is_empty() { @@ -304,18 +305,20 @@ fn fade(cx: &mut Cx, l: &Value, node: JsonMap, world: Affine, w: let identity = json!([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]); let bb = world.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); let abs = json!({"x": bb.x0, "y": bb.y0, "width": bb.width(), "height": bb.height()}); - let mut inner = node.clone(); + // The group takes the layer's id (stacks, links and overrides find it), the layer a new one. + let mut inner = node; let mut group = JsonMap::new(); for k in ["relativeTransform", "absoluteBoundingBox", "constraints", "visible", "locked"] { - if let Some(v) = node.get(k) { + if let Some(v) = inner.get(k) { group.insert(k.into(), v.clone()); } } group.insert("type".into(), json!("GROUP")); - group.insert("id".into(), json!(format!("{id}-fade"))); - group.insert("name".into(), node.get("name").cloned().unwrap_or_else(|| json!("Fade"))); + group.insert("id".into(), json!(id)); + group.insert("name".into(), inner.get("name").cloned().unwrap_or_else(|| json!("Fade"))); group.insert("size".into(), json!({"x": w, "y": h})); group.insert("absoluteBoundingBox".into(), abs.clone()); + inner.insert("id".into(), json!(format!("{id}-faded"))); inner.insert("relativeTransform".into(), identity.clone()); inner.insert("absoluteBoundingBox".into(), abs.clone()); inner.insert("visible".into(), json!(true)); From 054d53900f9bc7a89a5e4c2c0f5ddf6de6ff9a84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Thu, 22 Oct 2026 00:21:16 +0200 Subject: [PATCH 057/208] M6: Sketch import: note embedded fonts as waiting for .figcraft font support Co-Authored-By: Claude Opus 5.5 --- crates/format/src/sketch/mod.rs | 3 ++- docs/file-format-parity.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch/mod.rs index 18332afd..985541b1 100644 --- a/crates/format/src/sketch/mod.rs +++ b/crates/format/src/sketch/mod.rs @@ -149,7 +149,8 @@ fn import_parts( /// The note about fonts the file carries (Sketch 7x and newer can embed them, as `fontReferences` /// with `fontData`): FigCraft doesn't use fonts from inside documents, so their text shows in -/// installed fonts. +/// installed fonts. To do once `.figcraft` documents can carry fonts (loading them only for the +/// session would lose them on the first save and reopen). fn embedded_fonts(document: &Value) -> Option { let refs = document.get("fontReferences").and_then(Value::as_array)?; // A reference without font data only names a font the document uses. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 76d7437a..203a015e 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths) | +| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | From 03c53243051723466b1eb34b8e6a72330a52f5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Thu, 22 Oct 2026 00:22:34 +0200 Subject: [PATCH 058/208] M6: Sketch import: count it in the parity assessment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Figma imports Sketch files from its file browser (desktop app too); the area table hadn't listed it. Import/export breadth 86% → 87%, file formats 60% → 61%; headline numbers unchanged after rounding. Co-Authored-By: Claude Opus 5.5 --- docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 203a015e..11867d88 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | — (Figma's own Sketch import runs in its web app) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | +| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6ab02ade..8bc953f0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (`.sketch` import, best effort, numbers unchanged; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (`.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -107,7 +107,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | -| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | +| 14 | Import/export | 6 | 87% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | | 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | @@ -124,7 +124,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects |---|---|---|---|---|---| | Features (depth) | 35 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file | | UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) | -| File formats | 12 | 60% | 5–10 | `.sketch` read (Sketch 48 and newer, best effort; 2026-10-21): live symbols, shared styles, Sketch 2025 frames and stacks, checked on three real files against Sketch's own renders (0.8–2% of pixels differ on average); `.fig` read, best effort, with editable vector networks and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | +| File formats | 12 | 61% | 5–10 | `.sketch` read (Sketch 48 and newer, best effort; 2026-10-21): live symbols, shared styles, Sketch 2025 frames and stacks, checked on three real files against Sketch's own renders (0.8–2% of pixels differ on average); `.fig` read, best effort, with editable vector networks and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) | | Collaboration | 10 | 13% | 52–104 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) | | Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) | | Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) | @@ -639,7 +639,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| | 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | -| 2026-10-21 | minor | `.sketch` import (best effort, Sketch 48 and newer) in the file formats dimension: pages, layers, styles, live symbols with overrides, shared styles and Color Variables, prototype links, Sketch 2025 frames, stacks as auto layout and tints, text placed as Sketch places it; checked on three real files (Sketch 50, 64 and 2026) against Sketch's own renders. Numbers unchanged: Figma has no `.sketch` reader in its desktop app, so the file-exchange discounts (Figma users' files) don't move | +| 2026-10-21 | minor | `.sketch` import (best effort, Sketch 48 and newer), a feature Figma has (its file browser imports Sketch files, desktop app included, per the help center) that the area table hadn't listed: pages, layers, styles, live symbols with overrides, shared styles and Color Variables, prototype links, Sketch 2025 frames, stacks as auto layout and tints, text placed as Sketch places it; checked on three real files (Sketch 50, 64 and 2026) against Sketch's own renders. Import/export breadth 86% → 87% (ready stays 58%: best effort, three files), file formats 60% → 61%; feature breadth (~85%), ready for real work (~53%) and the audience numbers unchanged after rounding | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | From fa53463dddf01cf63654086cf4c6dd8aee47db7d Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 11:01:42 +0800 Subject: [PATCH 059/208] =?UTF-8?q?feat:=20=E4=B8=BA=E7=9F=A2=E9=87=8F?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E6=B7=BB=E5=8A=A0=E7=BA=BF=E6=AE=B5=E4=B8=AD?= =?UTF-8?q?=E7=82=B9=E6=B7=BB=E5=8A=A0=E7=82=B9=E7=9A=84=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E5=92=8C=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/cursor_tests.rs | 3 +- crates/engine/src/tools.rs | 13 ++++ crates/engine/src/vector_behaviour_tests.rs | 4 +- crates/engine/src/vectoredit/pointer.rs | 36 ++++++++++- crates/engine/src/vectoredit/tests.rs | 66 ++++++++++++++++++++- crates/ui-egui/src/canvas.rs | 26 ++++++-- 6 files changed, 139 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 7a9e0621..8d8f245e 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -270,13 +270,14 @@ fn vector_edit_mode_shows_each_tool_and_modifier() { let mut s = Session::new(); let path = id(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [300, 100], [300, 300]], "closed": false}))); ex(&mut s, "vector.edit.begin", json!({"id": path})); - let (point, segment, empty) = ((300.0, 100.0), (200.0, 100.0), (200.0, 250.0)); + let (point, segment, midpoint, empty) = ((300.0, 100.0), (150.0, 100.0), (200.0, 100.0), (200.0, 250.0)); use Cursor::*; // (vector tool, modifiers, where, cursor, why) type Row = (&'static str, Mods, (f64, f64), Cursor, &'static str); let rows: Vec = vec![ ("move", NONE, point, Default, "the arrow over a point"), ("move", NONE, segment, Default, "the arrow over a segment"), + ("move", NONE, midpoint, Pen(PenCursor::Add), "the arrow over a segment's midpoint: adds a point there"), ("move", NONE, empty, Default, "the arrow elsewhere"), ("move", ALT, segment, Pen(PenCursor::Add), "Alt over a segment adds a point there"), ("move", ALT, point, Default, "Alt over a point adds nothing"), diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 187e28d4..e35b054e 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -285,6 +285,9 @@ pub struct Overlay { pub vector_hover: Option, /// The edited layer `vector_hover` is on (several layers can be in edit mode). pub vector_hover_on: Option, + /// Vector edit mode: the point a press on the outline would add (the pen's ghost anchor where + /// it is over a segment, or the midpoint of the segment under the Move tool). + pub vector_add: Option, /// Vector edit mode, Variable width tool: the width point, handle or path under the pointer. pub width_hover: Option, pub lasso: Vec<[f64; 2]>, @@ -309,6 +312,16 @@ pub struct Overlay { pub shape: Option, } +/// A point a press would add to the edited vector's outline: page position, whether it is a +/// segment's midpoint (the Move tool) rather than the pen's place on its segment, and whether the +/// pointer is on it (a press adds it; otherwise it just shows where one would go). +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct VectorAdd { + pub at: [f64; 2], + pub midpoint: bool, + pub on: bool, +} + /// The Scale tool's live readout while dragging a handle. #[derive(Clone, Copy, Debug, PartialEq, Serialize)] pub struct ScaleHint { diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index fb734085..230d2998 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -236,7 +236,7 @@ fn selecting_points_and_segments() { match what { "click tl" => click(&mut s, (0.0, 0.0), m), "click tr" => click(&mut s, (100.0, 0.0), m), - "click top" => click(&mut s, (50.0, 0.0), m), + "click top" => click(&mut s, (30.0, 0.0), m), "marquee top" => drag(&mut s, (-20.0, -20.0), (110.0, 10.0), m), _ => { ex(&mut s, "vector.edit.tool", json!({"tool": "lasso"})); @@ -310,7 +310,7 @@ fn deleting_points_vs_delete_and_heal() { // Deleting a selected segment keeps its points when others use them. let mut s = Session::new(); let id = square(&mut s, true); - click(&mut s, (50.0, 0.0), NONE); + click(&mut s, (30.0, 0.0), NONE); key(&mut s, "Delete", false); let n = net(&s, id); assert_eq!((n.vertices.len(), n.segments.len()), (4, 3), "deleting a segment leaves its points"); diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 6667a209..1684bf26 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -114,6 +114,16 @@ pub(crate) struct PointSnap { /// Eraser brush radius in screen pixels. const ERASER_PX: f64 = 8.0; +/// A segment's midpoint is a point a press adds when the pointer is this close (screen pixels). +const MIDPOINT_PX: f64 = 6.0; + +/// The page point at `t` (0..1) along segment `seg` of vector `id`. +fn segment_point(doc: &Document, id: NodeId, seg: usize, t: f64) -> Option { + use figcraft_geom::kurbo::ParamCurve; + let c = network(doc, id)?.cubic(seg)?; + Some(doc.world_transform(id) * c.eval(t.clamp(0.0, 1.0))) +} + fn erase_all(net: &mut VectorNetwork, stamps: &[Point], radius: f64) -> bool { stamps.iter().fold(false, |acc, c| ops::erase(net, *c, radius) | acc) } @@ -337,9 +347,22 @@ impl Session { let shown = if width_tool { None } else { h.map(|h| (ve.id, h)).or_else(|| hit_any(&doc, ve, ev.p(), tol)) }; // A pen click on empty canvas lands where it snaps. let pen_at = if ve.tool == VTool::Pen && h.is_none() && !ev.shift { Some(self.snap_pen(ev, Some(ve.id), &[])?) } else { None }; + // A point a press on the outline would add: the pen's place under its pointer on a segment + // (its ghost anchor), or the midpoint of the segment under the Move tool. + let add = match (ve.tool, h) { + (VTool::Pen, Some(VHit::Segment { seg, t })) => { + segment_point(&doc, ve.id, seg, t).map(|at| crate::tools::VectorAdd { at: [at.x, at.y], midpoint: false, on: true }) + } + (VTool::Move, Some(VHit::Segment { seg, .. })) if !ev.cmd && !ev.alt => segment_point(&doc, ve.id, seg, 0.5).map(|at| { + let on = (at - ev.p()).hypot() * ev.zoom.max(1e-6) <= MIDPOINT_PX; + crate::tools::VectorAdd { at: [at.x, at.y], midpoint: true, on } + }), + _ => None, + }; let ov = &mut self.tool.overlay; ov.vector_hover = shown.map(|x| x.1); ov.vector_hover_on = shown.map(|x| x.0); + ov.vector_add = add; ov.width_hover = None; ov.face.clear(); ov.rubber = None; @@ -351,6 +374,8 @@ impl Session { (VTool::Move, Some(_)) if ev.cmd => Cursor::Pen(PenCursor::Bend), (VTool::Move, _) if ev.cmd => Cursor::Crosshair, (VTool::Move, Some(VHit::Segment { .. })) if ev.alt => Cursor::Pen(PenCursor::Add), + // On a segment's midpoint the arrow turns into the pen: a press adds a point there. + (VTool::Move, Some(VHit::Segment { .. })) if add.is_some_and(|a| a.on) => Cursor::Pen(PenCursor::Add), (VTool::Move, _) => Cursor::Default, (VTool::Pen, Some(VHit::Vertex { .. })) => Cursor::Pen(PenCursor::Close), (VTool::Pen, Some(VHit::Segment { .. })) => Cursor::Pen(PenCursor::Add), @@ -370,11 +395,13 @@ impl Session { { self.tool.overlay.cursor = crate::tools::handle_cursor(&b.corners(), handle); self.tool.overlay.vector_hover = None; + self.tool.overlay.vector_add = None; } else if shown.is_none_or(|(on, h)| on == ve.id && !matches!(h, VHit::Handle { .. })) { region = Self::region_under(&doc, ve, h, ev); if region.is_some() { self.tool.overlay.vector_hover = None; self.tool.overlay.vector_hover_on = None; + self.tool.overlay.vector_add = None; } } } @@ -510,6 +537,7 @@ impl Session { }; let h = hit(&doc, ve, ev.p(), tol); self.tool.overlay.vector_hover = None; + self.tool.overlay.vector_add = None; self.tool.overlay.face.clear(); self.tool.overlay.guides.clear(); let tool = if ev.cmd && ve.tool == VTool::Move { VTool::Bend } else { ve.tool }; @@ -626,8 +654,14 @@ impl Session { self.vgesture(g); } Some(VHit::Segment { seg, t }) => { - if ev.alt { + // A press on the midpoint the hover showed adds a point exactly there; Alt adds + // one anywhere else on the segment. + let midpoint = !ev.alt + && !ev.shift + && segment_point(&doc, id, seg, 0.5).is_some_and(|m| (m - ev.p()).hypot() * ev.zoom.max(1e-6) <= MIDPOINT_PX); + if ev.alt || midpoint { // Add a point on the segment and drag it. + let t = if midpoint { 0.5 } else { t }; self.begin_interaction("Add point")?; let mut v = None; self.preview(|doc, _| { diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/vectoredit/tests.rs index 0eba28e3..ff3c4cfa 100644 --- a/crates/engine/src/vectoredit/tests.rs +++ b/crates/engine/src/vectoredit/tests.rs @@ -171,7 +171,7 @@ fn lasso_marquee_and_segment_selection() { assert!(s.tool.overlay.lasso.is_empty()); // Move tool: clicking a segment selects it; shift adds another. ex(&mut s, "vector.edit.tool", json!({"tool": "move"})); - click(&mut s, 50.0, 100.0); + click(&mut s, 30.0, 100.0); assert_eq!(ve(&s).segments.len(), 1); assert!(ve(&s).vertices.is_empty()); let mut sh = ev(PointerKind::Down, 0.0, 50.0); @@ -371,3 +371,67 @@ fn eraser_strokes_cut_paths_in_one_step() { assert!(key(&mut s, "E", true, false, false).unwrap()); assert_eq!(ve(&s).tool, VTool::Eraser); } + +#[test] +fn a_segment_shows_its_midpoint_and_a_press_adds_a_point_there() { + let mut s = Session::new(); + let id = rect(&mut s); + // Near the top edge but off its midpoint: the hint shows where the point would go, but the + // pointer is not on it, so the arrow stays. + s.pointer(&ev(PointerKind::Hover, 30.0, 0.0)).unwrap(); + let add = s.tool.overlay.vector_add.expect("the segment's midpoint"); + assert!(add.midpoint && !add.on, "{add:?}"); + assert!((add.at[0] - 50.0).abs() < 1e-9 && add.at[1].abs() < 1e-9, "{add:?}"); + assert_eq!(s.tool.overlay.cursor, crate::tools::Cursor::Default); + // On the midpoint: the pen cursor, and a press adds a point there and selects it. + s.pointer(&ev(PointerKind::Hover, 50.0, 0.0)).unwrap(); + assert!(s.tool.overlay.vector_add.is_some_and(|a| a.on), "the pointer is on the midpoint"); + assert_eq!(s.tool.overlay.cursor, crate::tools::Cursor::Pen(crate::tools::PenCursor::Add)); + click(&mut s, 50.0, 0.0); + let n = net(&s, id); + assert_eq!(n.vertices.len(), 5, "a point was added: {n:?}"); + assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 50.0, 0.0)], "the new point is selected"); +} + +#[test] +fn adding_a_midpoint_keeps_new_midpoints_coming_on_each_half() { + let mut s = Session::new(); + let id = rect(&mut s); + click(&mut s, 50.0, 0.0); + assert_eq!(net(&s, id).vertices.len(), 5); + // The left half has its own midpoint, shown and added the same way. + s.pointer(&ev(PointerKind::Hover, 25.0, 0.0)).unwrap(); + let add = s.tool.overlay.vector_add.expect("the new half's midpoint"); + assert!(add.midpoint && add.on && (add.at[0] - 25.0).abs() < 1e-9, "{add:?}"); + click(&mut s, 25.0, 0.0); + let n = net(&s, id); + assert_eq!(n.vertices.len(), 6, "{n:?}"); + assert_eq!(ve(&s).vertices, vec![vertex_at(&n, 25.0, 0.0)]); + // And on it goes: a quarter of the way along. + s.pointer(&ev(PointerKind::Hover, 12.5, 0.0)).unwrap(); + let add = s.tool.overlay.vector_add.expect("another midpoint"); + assert!((add.at[0] - 12.5).abs() < 1e-9, "{add:?}"); +} + +#[test] +fn a_press_away_from_the_midpoint_selects_the_segment() { + let mut s = Session::new(); + let id = rect(&mut s); + click(&mut s, 30.0, 0.0); + assert_eq!(net(&s, id).vertices.len(), 4, "no point is added off the midpoint"); + assert_eq!(ve(&s).segments.len(), 1, "the segment is selected instead"); +} + +#[test] +fn the_pen_shows_its_ghost_anchor_on_the_segment_under_it() { + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.tool", json!({"tool": "pen"})); + s.pointer(&ev(PointerKind::Hover, 80.0, 0.0)).unwrap(); + let add = s.tool.overlay.vector_add.expect("the ghost anchor"); + assert!(!add.midpoint && add.on, "{add:?}"); + assert!((add.at[0] - 80.0).abs() < 1e-3 && add.at[1].abs() < 1e-3, "{add:?}"); + click(&mut s, 80.0, 0.0); + assert_eq!(net(&s, id).vertices.len(), 5, "the press added the point"); + assert_eq!(ve(&s).anchor, None, "the click ended the pen's path"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 8126e6f3..841c95e8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1021,10 +1021,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let hover_on = |id| ov.vector_hover.filter(|_| ov.vector_hover_on.unwrap_or(ve.id) == id); for part in &ve.others { if let Some(po) = figcraft_engine::vectoredit::overlay(&doc, &ve.view(part)) { - vector_overlay(app, painter, &po, !width_tool, hover_on(part.id), false); + vector_overlay(app, painter, &po, !width_tool, hover_on(part.id), false, None); } } - vector_overlay(app, painter, &o, !width_tool, hover_on(ve.id), true); + vector_overlay(app, painter, &o, !width_tool, hover_on(ve.id), true, ov.vector_add); if width_tool { width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { @@ -1327,8 +1327,9 @@ fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color3 painter.circle_stroke(e, r, stroke(accent(painter), 1.0)); } -/// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and -/// for the `active` layer the closed area under the paint bucket and the pen's rubber band. +/// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, the +/// `add` point a press would put on the outline, and for the `active` layer the closed area under +/// the paint bucket and the pen's rubber band. fn vector_overlay( app: &FigApp, painter: &egui::Painter, @@ -1336,6 +1337,7 @@ fn vector_overlay( points: bool, hover: Option, active: bool, + add: Option, ) { use figcraft_engine::vectoredit::VHit; let ov = &app.session.tool.overlay; @@ -1383,6 +1385,22 @@ fn vector_overlay( painter.circle_stroke(p, 7.5, stroke(accent(painter), 1.0)); } } + // The point a press would add to the outline: the pen's ghost anchor where it is over a + // segment, or a segment's midpoint under the Move tool (which grows and fills once the pointer + // is on it, when a press adds it). + if let Some(a) = add { + let p = app.to_screen(a.at[0], a.at[1]); + let col = accent(painter); + if a.midpoint { + let (r, fill) = if a.on { (5.5, col) } else { (3.5, Color32::from_white_alpha(190)) }; + painter.circle_filled(p, r, fill); + painter.circle_stroke(p, r, stroke(if a.on { Color32::WHITE } else { col.gamma_multiply(0.7) }, 1.2)); + } else { + let r = Rect::from_center_size(p, vec2(7.0, 7.0)); + painter.rect_filled(r, CornerRadius::same(5), Color32::from_white_alpha(160)); + painter.rect_stroke(r, CornerRadius::same(5), stroke(col.gamma_multiply(0.8), 1.0), StrokeKind::Inside); + } + } if let Some([x0, y0, x1, y1]) = ov.rubber.filter(|_| active) { painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.0), 4.0, 3.0)); } From 6bb1f3a456e8c22b9d7414631ccdfdd23b8680aa Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 12:03:19 +0800 Subject: [PATCH 060/208] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=9F=A2?= =?UTF-8?q?=E9=87=8F=E7=BC=96=E8=BE=91=E6=A8=A1=E5=BC=8F=E4=B8=8B=E7=9A=84?= =?UTF-8?q?=E5=89=AA=E5=88=87=E5=91=BD=E4=BB=A4=E9=80=BB=E8=BE=91=E5=B9=B6?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在矢量编辑模式中,剪切命令会处理选中的顶点并自动修复路径,而非直接删除整个图层。新增了专门的cut函数处理该逻辑,同时添加了vector_keys_tests测试文件,覆盖了不同场景下的剪切行为,包括Windows系统的Shift+Delete映射、选中顶点时的剪切、无选中项时的剪切以及非编辑模式下的剪切。 --- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/shortcuts.rs | 17 +++++- crates/ui-egui/src/vector_keys_tests.rs | 70 +++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/vector_keys_tests.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e24bbf24..846bfa92 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -219,6 +219,8 @@ mod variables_rename_tests; #[cfg(test)] mod variants_discovery_tests; #[cfg(test)] +mod vector_keys_tests; +#[cfg(test)] mod vector_panel_tests; #[cfg(test)] mod vector_tail_ui_tests; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index c86cfdf1..30267cc7 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -125,7 +125,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowe for ev in &events { match ev { Event::Copy => crate::menus::invoke(app, "edit.copy"), - Event::Cut => crate::menus::invoke(app, "edit.cut"), + Event::Cut => cut(app), Event::Paste(text) if !swallowed.drop_text => crate::menus::paste(app, Some(text), None), Event::Key { key, physical_key, pressed: true, modifiers, repeat } if key_event(app, *key, *physical_key, *modifiers, *repeat, now, ctx) => @@ -137,6 +137,21 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowe } } +/// Cut: usually the layers. Windows sends Shift+Delete (remove the points and heal the path) +/// as a cut command and never as a key, so in vector edit mode the selection is points: they are +/// removed, a heal joining the path through them. +fn cut(app: &mut FigApp) { + let Some(ve) = app.session.ds().ok().and_then(|d| d.vector_edit.clone()) else { + crate::menus::invoke(app, "edit.cut"); + return; + }; + if !ve.vertices.is_empty() { + app.run("vector.deleteHeal", json!({})); + } else if !ve.segments.is_empty() { + app.run("vector.edit.delete", json!({})); + } +} + /// The command Cmd/Ctrl+V runs with `m` held too: `edit.paste`, `edit.pasteOver` (Shift), /// `edit.pasteProperties` (Alt), or `None` for a chord nothing is bound to. The keymap decides, /// whether the key press arrived (browser) or only a paste event and the release (desktop). diff --git a/crates/ui-egui/src/vector_keys_tests.rs b/crates/ui-egui/src/vector_keys_tests.rs new file mode 100644 index 00000000..eb99742a --- /dev/null +++ b/crates/ui-egui/src/vector_keys_tests.rs @@ -0,0 +1,70 @@ +//! Keys in vector edit mode that the window's key handling twists: Windows sends Shift+Delete +//! (delete the points and heal the path) as a cut command and no key at all, so the canvas must +//! heal the selected points rather than cut the layer. + +use egui::{Event, Modifiers}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn net(u: &Ui, path: NodeId) -> figcraft_doc::VectorNetwork { + let doc = u.app.session.doc().unwrap(); + figcraft_engine::vectoredit::network(doc, path).unwrap().clone() +} + +/// A three-point open path A-B-C in edit mode, B selected. +fn abc(u: &mut Ui) -> NodeId { + let path = id(u.app.session.execute("vector.create", &json!({"points": [[100, 100], [200, 100], [300, 100]], "closed": false})).unwrap()); + u.app.session.execute("vector.edit.begin", &json!({"id": path})).unwrap(); + u.app.session.execute("vector.edit.select", &json!({"vertices": [1]})).unwrap(); + path +} + +#[test] +fn shift_delete_in_vector_edit_mode_heals_the_point_instead_of_cutting_the_layer() { + let mut u = Ui::new(Session::new()); + let path = abc(&mut u); + // Windows: egui-winit turns Shift+Delete into a cut command, with no key event. + u.frame_with(vec![Event::Cut], Modifiers::SHIFT); + assert!(u.app.session.doc().unwrap().node(path).is_some(), "the layer stays"); + let n = net(&u, path); + assert_eq!(n.vertices.len(), 2, "the point is gone: {n:?}"); + assert_eq!(n.segments.len(), 1, "A and C are joined"); + let (a, b) = (n.vertices.first().map(|v| v.x).unwrap_or_default(), n.vertices.get(1).map(|v| v.x).unwrap_or_default()); + assert!(b - a == 200.0 && n.vertices.iter().all(|v| v.y == 0.0), "it runs straight from A to C: {n:?}"); +} + +#[test] +fn a_cut_command_in_edit_mode_takes_the_selected_points_not_the_layer() { + let mut u = Ui::new(Session::new()); + let path = abc(&mut u); + u.frame_with(vec![Event::Cut], Modifiers::COMMAND); + assert!(u.app.session.doc().unwrap().node(path).is_some(), "Ctrl+X on points keeps the layer"); + assert_eq!(net(&u, path).vertices.len(), 2, "it takes the selected point"); +} + +#[test] +fn a_cut_command_in_edit_mode_with_nothing_selected_keeps_the_layer() { + let mut u = Ui::new(Session::new()); + let path = abc(&mut u); + u.app.session.execute("vector.edit.select", &json!({"vertices": []})).unwrap(); + u.frame_with(vec![Event::Cut], Modifiers::SHIFT); + assert!(u.app.session.doc().unwrap().node(path).is_some(), "nothing selected: nothing goes"); + assert_eq!(net(&u, path).vertices.len(), 3); +} + +#[test] +fn a_cut_command_outside_edit_mode_cuts_the_layer() { + let mut s = Session::new(); + let path = id(s.execute("vector.create", &json!({"points": [[100, 100], [200, 100], [300, 100]], "closed": false})).unwrap()); + s.execute("select.set", &json!({"ids": [path]})).unwrap(); + let mut u = Ui::new(s); + u.frame_with(vec![Event::Cut], Modifiers::SHIFT); + assert!(u.app.session.doc().unwrap().node(path).is_none(), "outside edit mode the selected layer is cut"); +} From e3bf9b7270336831f0193e260a8bb0fcabdcfbd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Huy=20S=C6=A1n?= Date: Thu, 22 Oct 2026 11:33:34 +0700 Subject: [PATCH 061/208] i18n: add Vietnamese (vi) interface localization --- README.md | 2 +- ROADMAP.md | 4 +- crates/engine/src/cmd/view.rs | 2 +- crates/ui-egui/locales/vi.toml | 2367 ++++++++++++++++++++++++++++++ crates/ui-egui/src/cjk.rs | 2 +- crates/ui-egui/src/i18n.rs | 4 +- crates/ui-egui/src/i18n_tests.rs | 8 +- docs/localization-parity.md | 12 +- docs/mcp.md | 2 +- docs/translating.md | 14 +- 10 files changed, 2395 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/locales/vi.toml diff --git a/README.md b/README.md index 63bc739b..41bca26d 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..b1ec9215 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -109,11 +109,11 @@ native speakers' reviews of the six language drafts need people. | Japanese | ja | 99% | draft (needs native review) | | German | de | 96% | draft (needs native review) | | Korean | ko | 95% | draft (needs native review) | -| Vietnamese | vi | 0% | none | +| Vietnamese | vi | 100% | draft (needs native review) | Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 0592046e..b4ce92b9 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -427,7 +427,7 @@ pub fn specs() -> Vec { spec!( "pref.language", "Interface language", - "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}", + "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|…; else English}", false, |s, p| { let lang = req_string("pref.language", p, "language")?; diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml new file mode 100644 index 00000000..6493508b --- /dev/null +++ b/crates/ui-egui/locales/vi.toml @@ -0,0 +1,2367 @@ +# FigCraft interface strings: Vietnamese. +# +# Draft — needs native review: machine-drafted from the English catalog. Brand names, keyboard +# glyphs and {placeholders} stay as they are. Vietnamese nouns do not inflect for number, so plural +# entries give only `other`. See docs/translating.md. + +[meta] +name = "Tiếng Việt" +status = "draft — needs native review" + +[ui] +"% of all commits" = "% tổng số commit" +"(no longer published)" = "(không còn được xuất bản)" +"({n} warnings: some entries were skipped)" = { other = "({n} cảnh báo: một số mục đã bị bỏ qua)" } +"+ Create variable" = "+ Tạo biến" +".fig (experimental)" = ".fig (thử nghiệm)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} dòng (Δ {delta}), +{bin_added} / −{bin_deleted} tài nguyên nhị phân, {first} – {last}" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Đã tồn tại tệp .figcraft có tên này. Hãy chọn tên đầy đủ của tệp để xác nhận thay thế." +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Một bộ sưu tập của tệp này với các chế độ và biến sau: ghi đè giá trị theo từng chế độ và thêm các biến riêng" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Một lớp riêng lẻ sẽ căn chỉnh theo khung của nó: hãy chọn thêm lớp, hoặc chọn một lớp bên trong khung" +"A responsive product page: hero, features, figures, a quote and a footer" = "Trang sản phẩm đáp ứng: phần mở đầu, tính năng, số liệu, trích dẫn và chân trang" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Chuyển động lò xo chạy cho đến khi ổn định: khối lượng, độ cứng và độ giảm chấn quyết định thời lượng" +"A title, an agenda, a big figure, a chart and a closing slide" = "Tiêu đề, chương trình, số liệu lớn, biểu đồ và trang kết thúc" +"A to Z" = "A đến Z" +"About" = "Giới thiệu" +"About FigCraft" = "Giới thiệu về FigCraft" +"About this server" = "Giới thiệu về máy chủ này" +"Action" = "Hành động" +"Actions" = "Hành động" +"Actions for {name}" = "Hành động cho {name}" +"Actions…" = "Hành động…" +"Active" = "Đang hoạt động" +"Actual size (100%)" = "Kích thước thực (100%)" +"Add a category" = "Thêm danh mục" +"Add a comment" = "Thêm bình luận" +"Add a description" = "Thêm mô tả" +"Add a frame (F) or a shape (R) to get started" = "Thêm khung (F) hoặc hình dạng (R) để bắt đầu" +"Add a layer or component to tile first" = "Hãy thêm một lớp hoặc thành phần để lặp ô trước" +"Add a note for developers" = "Thêm ghi chú cho nhà phát triển" +"Add action" = "Thêm hành động" +"Add actions for when no condition holds" = "Thêm hành động khi không có điều kiện nào thỏa mãn" +"Add an annotation for developers" = "Thêm chú thích cho nhà phát triển" +"Add another condition" = "Thêm điều kiện khác" +"Add auto layout" = "Thêm bố cục tự động" +"Add background behind overlay" = "Thêm nền phía sau lớp phủ" +"Add content to this slot" = "Thêm nội dung vào ô chứa này" +"Add dev resource" = "Thêm tài nguyên dev" +"Add effect" = "Thêm hiệu ứng" +"Add export setting" = "Thêm thiết lập xuất" +"Add fill" = "Thêm màu tô" +"Add interaction" = "Thêm tương tác" +"Add layout grid" = "Thêm lưới bố cục" +"Add library" = "Thêm thư viện" +"Add library file…" = "Thêm tệp thư viện…" +"Add max height" = "Thêm chiều cao tối đa" +"Add max width" = "Thêm chiều rộng tối đa" +"Add min height" = "Thêm chiều cao tối thiểu" +"Add min width" = "Thêm chiều rộng tối thiểu" +"Add mode" = "Thêm chế độ" +"Add new page" = "Thêm trang mới" +"Add or update collections from a variables JSON" = "Thêm hoặc cập nhật bộ sưu tập từ JSON biến" +"Add properties to make instances configurable" = "Thêm thuộc tính để có thể cấu hình các bản thể" +"Add reaction" = "Thêm biểu cảm" +"Add starting point" = "Thêm điểm bắt đầu" +"Add stroke" = "Thêm nét" +"Add variant" = "Thêm biến thể" +"Add variant with a new value" = "Thêm biến thể với giá trị mới" +"Added" = "Đã thêm" +"Added the library \"{name}\"" = "Đã thêm thư viện \"{name}\"" +"Adding to {name}" = "Đang thêm vào {name}" +"Adjust" = "Điều chỉnh" +"Adjustment presets" = "Thiết lập sẵn điều chỉnh" +"Advanced" = "Nâng cao" +"Advanced stroke" = "Nét nâng cao" +"After delay" = "Sau khoảng trễ" +"After delay {ms}ms" = "Sau khoảng trễ {ms}ms" +"Align" = "Căn chỉnh" +"Align bottom" = "Căn dưới" +"Align center" = "Căn giữa" +"Align horizontal centers" = "Căn tâm ngang" +"Align left" = "Căn trái" +"Align middle" = "Căn giữa dọc" +"Align right" = "Căn phải" +"Align text baselines" = "Căn theo đường cơ sở văn bản" +"Align top" = "Căn trên" +"Align vertical centers" = "Căn tâm dọc" +"Alignment" = "Căn chỉnh" +"All" = "Tất cả" +"All categories" = "Tất cả danh mục" +"All comments" = "Tất cả bình luận" +"All components" = "Tất cả thành phần" +"All fonts" = "Tất cả phông chữ" +"All supported properties" = "Tất cả thuộc tính được hỗ trợ" +"All the fonts this file uses are installed" = "Tất cả phông chữ tệp này sử dụng đều đã được cài đặt" +"All variables" = "Tất cả biến" +"Alpha" = "Alpha" +"Alphabetical" = "Theo bảng chữ cái" +"Alt+drag" = "Alt+kéo" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Màn hình phân tích trên lưới: số liệu, biểu đồ vẽ bằng vector và bảng dữ liệu" +"An invite link that opens FigCraft and joins" = "Liên kết mời giúp mở FigCraft và tham gia ngay" +"Anchor" = "Điểm neo" +"Android" = "Android" +"Angular" = "Góc" +"Animation" = "Hoạt ảnh" +"Annotations" = "Chú thích" +"Annotations shown on the canvas" = "Chú thích hiển thị trên vùng vẽ" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Một máy chủ khác, hoặc máy chủ cần khóa truy cập. Máy chủ thường dùng được đặt trong Tùy chọn." +"Any status" = "Mọi trạng thái" +"Anyone on your network with this code can edit the file, while this app stays open." = "Bất kỳ ai trong mạng của bạn có mã này đều có thể chỉnh sửa tệp trong khi ứng dụng này đang mở." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Bất kỳ ai có liên kết chỉnh sửa đều có thể thay đổi tệp; liên kết xem chỉ cho phép xem. Các liên kết chứa mã bí mật: hãy chia sẻ cẩn thận như mật khẩu. `figcraft-server links` dùng để tạo thêm và thu hồi liên kết." +"Anyone with this code can edit the file." = "Bất kỳ ai có mã này đều có thể chỉnh sửa tệp." +"Anyone with this code can look at the file, not change it." = "Bất kỳ ai có mã này đều có thể xem tệp nhưng không thể chỉnh sửa." +"Appearance" = "Giao diện" +"Apply a component property" = "Áp dụng thuộc tính thành phần" +"Apply variable" = "Áp dụng biến" +"Apply variable to text" = "Áp dụng biến cho văn bản" +"Apply variable to visibility" = "Áp dụng biến cho hiển thị" +"Apps" = "Ứng dụng" +"Apr" = "Thg 4" +"Arc" = "Cung" +"Arc ratio" = "Tỷ lệ cung" +"Arc start" = "Điểm đầu cung" +"Arc sweep" = "Góc quét cung" +"Archive" = "Lưu trữ" +"Archived" = "Đã lưu trữ" +"Arrange" = "Sắp xếp" +"Arrow" = "Mũi tên" +"As typed" = "Như đã nhập" +"Asking the server…" = "Đang hỏi máy chủ…" +"Assets" = "Tài nguyên" +"Aug" = "Thg 8" +"Auto" = "Tự động" +"Auto (space around)" = "Tự động (cách đều xung quanh)" +"Auto (space between)" = "Tự động (cách đều giữa các mục)" +"Auto (space evenly)" = "Tự động (cách đều bằng nhau)" +"Auto ({mode})" = "Tự động ({mode})" +"Auto columns" = "Cột tự động" +"Auto gap" = "Khoảng hở tự động" +"Auto height" = "Chiều cao tự động" +"Auto layout" = "Bố cục tự động" +"Auto rows" = "Hàng tự động" +"Auto width" = "Chiều rộng tự động" +"Back" = "Quay lại" +"Back to Ready for dev" = "Quay lại Sẵn sàng cho dev" +"Back to Ready for dev: the next change compares with how it is now" = "Quay lại Sẵn sàng cho dev: thay đổi tiếp theo sẽ so sánh với trạng thái hiện tại" +"Back to assets" = "Quay lại tài nguyên" +"Back to current" = "Quay lại hiện tại" +"Back to styles" = "Quay lại các kiểu" +"Back to the FigCraft server" = "Quay lại máy chủ FigCraft" +"Back to the canvas from a focused region" = "Quay lại vùng vẽ từ vùng đang giữ tiêu điểm" +"Back to the current version" = "Quay lại phiên bản hiện tại" +"Back to the font's value" = "Quay lại giá trị của phông chữ" +"Back to the values the font style implies" = "Quay lại các giá trị mặc định của kiểu phông chữ" +"Background" = "Nền" +"Background blur" = "Làm mờ nền" +"Basics" = "Cơ bản" +"Before" = "Trước" +"Bend" = "Uốn cong" +"Bevel" = "Vát cạnh" +"Binary assets added" = "Tài nguyên nhị phân đã thêm" +"Binary assets removed" = "Tài nguyên nhị phân đã xóa" +"Black & white" = "Đen trắng" +"Bleed" = "Vùng tràn lề" +"Bleed marks" = "Dấu tràn lề" +"Bleed marks need a bleed" = "Dấu tràn lề cần có vùng tràn lề" +"Blend" = "Hòa trộn" +"Blend mode" = "Chế độ hòa trộn" +"Blue" = "Xanh lam" +"Blur" = "Làm mờ" +"Boolean" = "Boolean" +"Boolean groups" = "Nhóm boolean" +"Both directions" = "Cả hai hướng" +"Bottom" = "Dưới" +"Bottom left radius" = "Bán kính góc dưới trái" +"Bottom padding" = "Đệm dưới" +"Bottom right radius" = "Bán kính góc dưới phải" +"Bound to {name}. Click to detach the variable." = "Đã gắn với {name}. Nhấp để tách biến." +"Branch" = "Nhánh" +"Branch archived" = "Đã lưu trữ nhánh" +"Branch merged" = "Đã hợp nhất nhánh" +"Branch name" = "Tên nhánh" +"Branch of {file}" = "Nhánh của {file}" +"Branch of {file} · merged or archived" = "Nhánh của {file} · đã hợp nhất hoặc lưu trữ" +"Branch updated from main" = "Đã cập nhật nhánh từ nhánh chính" +"Branch {name}" = "Nhánh {name}" +"Branches" = "Các nhánh" +"Bring forward" = "Đưa tiến một lớp" +"Bring the main file's changes into the branch" = "Đưa các thay đổi của tệp chính vào nhánh" +"Bring to front" = "Đưa lên trên cùng" +"Bristle" = "Lông cọ" +"Browse" = "Duyệt" +"Brush" = "Cọ vẽ" +"Brush rotation jitter" = "Độ ngẫu nhiên góc xoay cọ" +"Brush scatter" = "Độ phân tán cọ" +"Brush size jitter" = "Độ ngẫu nhiên cỡ cọ" +"Brush spacing" = "Khoảng cách nét cọ" +"Bulleted list" = "Danh sách dấu đầu dòng" +"C or Shift+C" = "C hoặc Shift+C" +"C or ⇧C" = "C hoặc ⇧C" +"CMYK colour" = "Màu CMYK" +"CSS custom properties…" = "Thuộc tính tùy chỉnh CSS…" +"Can edit" = "Có thể chỉnh sửa" +"Can view" = "Có thể xem" +"Can't read this library: {e}" = "Không thể đọc thư viện này: {e}" +"Cancel" = "Hủy" +"Canvas" = "Vùng vẽ" +"Canvas stacking" = "Thứ tự xếp chồng trên vùng vẽ" +"Cap height to baseline" = "Chiều cao chữ hoa đến đường cơ sở" +"Case" = "Chữ hoa/thường" +"Case-sensitive forms" = "Dạng ký tự theo chữ hoa/thường" +"Categories" = "Danh mục" +"Center" = "Giữa" +"Change alias" = "Đổi bí danh" +"Change resolved" = "Đã giải quyết thay đổi" +"Change style" = "Đổi kiểu" +"Change to" = "Đổi thành" +"Change to needs a layer inside a variant" = "Đổi thành cần một lớp nằm trong biến thể" +"Changed" = "Đã thay đổi" +"Changed on both sides: keep one" = "Đã thay đổi ở cả hai phía: hãy giữ một bên" +"Changes on main" = "Thay đổi trên nhánh chính" +"Changes on the branch" = "Thay đổi trên nhánh" +"Changes unknown" = "Không rõ thay đổi" +"Checking the fonts installed on this computer…" = "Đang kiểm tra các phông chữ cài đặt trên máy tính này…" +"Checking the server…" = "Đang kiểm tra máy chủ…" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Hãy chọn React, Leptos, SwiftUI hoặc Compose ở trên để kết nối thành phần này với mã nguồn." +"Choose another profile to change it." = "Hãy chọn hồ sơ khác để thay đổi." +"Choose collection" = "Chọn bộ sưu tập" +"Choose image" = "Chọn hình ảnh" +"Choose mode" = "Chọn chế độ" +"Choose variable" = "Chọn biến" +"Circle" = "Hình tròn" +"Clear from this list" = "Xóa khỏi danh sách này" +"Clear recent" = "Xóa danh sách gần đây" +"Click + to replace mixed content" = "Nhấp + để thay thế nội dung hỗn hợp" +"Click anywhere on the canvas to leave one." = "Nhấp vào bất kỳ đâu trên vùng vẽ để lại bình luận." +"Click or drag to merge · {alt} removes" = "Nhấp hoặc kéo để hợp nhất · {alt} để xóa" +"Click or drag to remove · {alt} merges" = "Nhấp hoặc kéo để xóa · {alt} để hợp nhất" +"Click to record a key" = "Nhấp để ghi nhận phím" +"Clip content" = "Cắt nội dung tràn khung" +"Clip to shape" = "Cắt theo hình dạng" +"Close" = "Đóng" +"Close overlay" = "Đóng lớp phủ" +"Close preview" = "Đóng xem trước" +"Close tab" = "Đóng thẻ" +"Close version history" = "Đóng lịch sử phiên bản" +"Close when clicking outside" = "Đóng khi nhấp ra ngoài" +"Close without saving" = "Đóng không lưu" +"Code" = "Mã" +"Code or invite link" = "Mã hoặc liên kết mời" +"Code syntax" = "Cú pháp mã" +"Collapse" = "Thu gọn" +"Collapse panel" = "Thu gọn bảng" +"Collection" = "Bộ sưu tập" +"Collections" = "Bộ sưu tập" +"Color" = "Màu" +"Color burn" = "Đốt màu" +"Color dodge" = "Làm sáng màu" +"Color format" = "Định dạng màu" +"Color profile" = "Hồ sơ màu" +"Color profile: {name}" = "Hồ sơ màu: {name}" +"Color styles" = "Kiểu màu" +"Color values in this file are in this color space." = "Các giá trị màu trong tệp này thuộc không gian màu này." +"Color variables" = "Biến màu" +"Colour" = "Màu" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Kiểu màu và văn bản, biến Sáng và Tối, nút bấm, ô nhập liệu và thẻ" +"Column" = "Cột" +"Column gap" = "Khoảng hở cột" +"Column sizes" = "Kích thước cột" +"Column span" = "Số cột gộp" +"Columns" = "Các cột" +"Columns {count}" = "Cột {count}" +"Comment" = "Bình luận" +"Comments" = "Bình luận" +"Commits" = "Commit" +"Company" = "Công ty" +"Compare changes" = "So sánh thay đổi" +"Compatibility notes" = "Ghi chú tương thích" +"Completed" = "Đã hoàn thành" +"Component" = "Thành phần" +"Component name" = "Tên thành phần" +"Component property" = "Thuộc tính thành phần" +"Component set" = "Bộ thành phần" +"Component sets" = "Các bộ thành phần" +"Components" = "Các thành phần" +"Conditional" = "Điều kiện" +"Confetti" = "Hoa giấy" +"Conflicts" = "Xung đột" +"Connect to code" = "Kết nối với mã" +"Connected" = "Đã kết nối" +"Connected to code: {from}" = "Đã kết nối với mã: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Mất kết nối, đang kết nối lại (lần thử {attempt})…" +"Constrain proportions" = "Giữ tỷ lệ" +"Constraints" = "Ràng buộc" +"Content" = "Nội dung" +"Contents hidden" = "Đã ẩn nội dung" +"Contents only" = "Chỉ nội dung" +"Contextual alternates" = "Ký tự thay thế theo ngữ cảnh" +"Contrast" = "Độ tương phản" +"Contributors" = "Người đóng góp" +"Contributors: {people} · Commits: {commits}" = "Người đóng góp: {people} · Commit: {commits}" +"Controller" = "Tay cầm điều khiển" +"Convert to frame" = "Chuyển thành khung" +"Convert to section" = "Chuyển thành phần" +"Cool" = "Lạnh" +"Copied as CSS" = "Đã sao chép dưới dạng CSS" +"Copied as PNG" = "Đã sao chép dưới dạng PNG" +"Copied as SVG" = "Đã sao chép dưới dạng SVG" +"Copied as text" = "Đã sao chép dưới dạng văn bản" +"Copied {value}" = "Đã sao chép {value}" +"Copy" = "Sao chép" +"Copy as" = "Sao chép dưới dạng" +"Copy as CSS" = "Sao chép dưới dạng CSS" +"Copy as PNG" = "Sao chép dưới dạng PNG" +"Copy as SVG" = "Sao chép dưới dạng SVG" +"Copy code" = "Sao chép mã" +"Copy link" = "Sao chép liên kết" +"Copy the {label}" = "Sao chép {label}" +"Copy {value}" = "Sao chép {value}" +"Corner radius" = "Bán kính góc" +"Corner smoothing" = "Làm mượt góc" +"Couldn't download the variables" = "Không thể tải xuống các biến" +"Couldn't reach {server}: showing what this computer remembers." = "Không thể kết nối tới {server}: đang hiển thị dữ liệu máy tính này ghi nhớ." +"Couldn't read {name}." = "Không thể đọc {name}." +"Couldn't save automatically: {error}" = "Không thể tự động lưu: {error}" +"Count" = "Số lượng" +"Create" = "Tạo" +"Create a variable collection first" = "Hãy tạo bộ sưu tập biến trước" +"Create a variable first" = "Hãy tạo biến trước" +"Create alias" = "Tạo bí danh" +"Create branch" = "Tạo nhánh" +"Create collection" = "Tạo bộ sưu tập" +"Create component" = "Tạo thành phần" +"Create component property" = "Tạo thuộc tính thành phần" +"Create components, styles or variables to publish them as a library." = "Hãy tạo các thành phần, kiểu hoặc biến để xuất bản thành thư viện." +"Create property…" = "Tạo thuộc tính…" +"Create style" = "Tạo kiểu" +"Create style from the selected text" = "Tạo kiểu từ văn bản đã chọn" +"Created" = "Đã tạo" +"Crop" = "Cắt xén" +"Crop marks" = "Dấu xén" +"Ctrl+arrows" = "Ctrl+mũi tên" +"Ctrl+click" = "Ctrl+nhấp" +"Ctrl+scroll" = "Ctrl+cuộn" +"Current name" = "Tên hiện tại" +"Current version" = "Phiên bản hiện tại" +"Cursor chat" = "Trò chuyện con trỏ" +"Custom" = "Tùy chỉnh" +"Custom size" = "Kích thước tùy chỉnh" +"Cut" = "Cắt" +"Dark" = "Tối" +"Darken" = "Làm tối" +"Dash" = "Nét đứt" +"Dashboard" = "Bảng điều khiển" +"Date created" = "Ngày tạo" +"Dec" = "Thg 12" +"Decoration" = "Trang trí chữ" +"Decoration style" = "Kiểu trang trí chữ" +"Deep select" = "Chọn sâu" +"Default" = "Mặc định" +"Default content" = "Nội dung mặc định" +"Default value (the top-left variant)" = "Giá trị mặc định (biến thể ở góc trên trái)" +"Delay" = "Độ trễ" +"Delete" = "Xóa" +"Delete category (its annotations stay)" = "Xóa danh mục (các chú thích vẫn được giữ lại)" +"Delete collection" = "Xóa bộ sưu tập" +"Delete group" = "Xóa nhóm" +"Delete mode" = "Xóa chế độ" +"Delete page" = "Xóa trang" +"Delete preset" = "Xóa thiết lập sẵn" +"Delete property" = "Xóa thuộc tính" +"Delete the style (layers keep their values)" = "Xóa kiểu (các lớp vẫn giữ nguyên giá trị)" +"Delete thread" = "Xóa luồng bình luận" +"Delete variable" = "Xóa biến" +"Delete “{name}”? This can't be undone." = "Xóa “{name}”? Thao tác này không thể hoàn tác." +"Density" = "Mật độ" +"Depth" = "Độ sâu" +"Describe how and when to use this component" = "Mô tả cách thức và thời điểm sử dụng thành phần này" +"Describe what changed" = "Mô tả những gì đã thay đổi" +"Description" = "Mô tả" +"Description (optional)" = "Mô tả (tùy chọn)" +"Design" = "Thiết kế" +"Design system starter" = "Bộ khởi đầu hệ thống thiết kế" +"Desktop" = "Máy tính" +"Destination" = "Đích" +"Detach alias" = "Tách bí danh" +"Detach instance" = "Tách bản thể" +"Detach property" = "Tách thuộc tính" +"Detach style" = "Tách kiểu" +"Detach styles" = "Tách các kiểu" +"Detach variable" = "Tách biến" +"Details" = "Chi tiết" +"Dev Mode" = "Dev Mode" +"Dev resources" = "Tài nguyên dev" +"Development" = "Phát triển" +"Device" = "Thiết bị" +"Device at 100%" = "Thiết bị ở mức 100%" +"Diamond" = "Hình thoi" +"Difference" = "Khác biệt" +"Dimensions" = "Kích thước" +"Direction" = "Hướng" +"Disconnect from code" = "Ngắt kết nối mã" +"Dispersion" = "Tán sắc" +"Display name" = "Tên hiển thị" +"Dissolve" = "Hòa tan" +"Distribute and tidy" = "Phân bổ và sắp xếp gọn" +"Distribute horizontal spacing" = "Phân bổ đều khoảng cách ngang" +"Distribute vertical spacing" = "Phân bổ đều khoảng cách dọc" +"Divider" = "Đường phân cách" +"Don't truncate" = "Không cắt bớt" +"Done" = "Xong" +"Done editing" = "Hoàn tất chỉnh sửa" +"Dots" = "Chấm" +"Dotted" = "Chấm tròn" +"Down" = "Xuống" +"Drafts" = "Bản nháp" +"Drag the stroke to add a width point" = "Kéo nét để thêm điểm độ rộng" +"Draw" = "Vẽ" +"Draw a frame" = "Vẽ khung" +"Drawing tools" = "Công cụ vẽ" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Thả tệp .figcraft, .fig, .json, .svg hoặc hình ảnh vào đây." +"Drop shadow" = "Đổ bóng" +"Drop to import" = "Thả để nhập" +"Drop to open" = "Thả để mở" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Cấp phép kép MIT HOẶC Apache-2.0. Được phát triển bởi đội ngũ và cộng đồng ArtCraft." +"Duo" = "Hai tông" +"Duplicate" = "Nhân bản" +"Duplicate by dragging" = "Nhân bản bằng cách kéo" +"Duplicate mode" = "Nhân bản chế độ" +"Duplicate page" = "Nhân bản trang" +"Edit" = "Chỉnh sửa" +"Edit as an expression" = "Chỉnh sửa dưới dạng biểu thức" +"Edit colour" = "Chỉnh sửa màu" +"Edit comment" = "Chỉnh sửa bình luận" +"Edit dev resource" = "Chỉnh sửa tài nguyên dev" +"Edit link" = "Liên kết chỉnh sửa" +"Edit link copied" = "Đã sao chép liên kết chỉnh sửa" +"Edit style" = "Chỉnh sửa kiểu" +"Edit variable" = "Chỉnh sửa biến" +"Edit with the builder" = "Chỉnh sửa bằng bộ dựng" +"Edited {when}" = "Đã chỉnh sửa {when}" +"Effect settings" = "Thiết lập hiệu ứng" +"Effect styles" = "Kiểu hiệu ứng" +"Effects" = "Hiệu ứng" +"Ellipse" = "Hình elip" +"Ellipses" = "Hình elip" +"Else" = "Ngược lại" +"Else if" = "Hoặc nếu" +"Embedded profile" = "Hồ sơ nhúng sẵn" +"Empty" = "Trống" +"End" = "Cuối" +"End point" = "Điểm cuối" +"Ends the session; everyone keeps their copy" = "Kết thúc phiên; mọi người vẫn giữ bản sao của mình" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Nhập địa chỉ figcraft-server và nhấn Duyệt để xem các plugin mà máy chủ cung cấp." +"Enter to save · Esc to cancel" = "Enter để lưu · Esc để hủy" +"Eraser" = "Cục tẩy" +"Esc" = "Esc" +"Essential" = "Thiết yếu" +"Every conflict has a choice." = "Mọi xung đột đều đã có lựa chọn." +"Every top-level frame already starts a flow" = "Mọi khung cấp cao nhất đều đã là điểm bắt đầu luồng" +"Exact opacity (4 then 5: 45%)" = "Độ mờ chính xác (nhấn 4 rồi 5: 45%)" +"Exclude" = "Loại trừ" +"Excluded" = "Đã loại trừ" +"Excluded from layout" = "Đã loại trừ khỏi bố cục" +"Excludes" = "Phép loại trừ" +"Exclusion" = "Loại trừ" +"Exit focus view (Esc)" = "Thoát chế độ xem tập trung (Esc)" +"Expand" = "Mở rộng" +"Export" = "Xuất" +"Export all" = "Xuất tất cả" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Xuất các khung và lớp dưới dạng PNG, JPG, SVG hoặc PDF" +"Export page as PNG" = "Xuất trang dưới dạng PNG" +"Export settings" = "Thiết lập xuất" +"Export variables" = "Xuất các biến" +"Export {name}" = "Xuất {name}" +"Export {n} layers" = { other = "Xuất {n} lớp" } +"Exported {file}" = "Đã xuất {file}" +"Exported {name}" = "Đã xuất {name}" +"Exported {name}; {n} kinds of content were left out" = { other = "Đã xuất {name}; {n} loại nội dung đã bị lược bỏ" } +"Exported {n} files" = { other = "Đã xuất {n} tệp" } +"Export…" = "Xuất…" +"Expose properties to instances" = "Hiển thị thuộc tính từ các bản thể lồng nhau" +"Exposure" = "Phơi sáng" +"Expression" = "Biểu thức" +"Extend collection" = "Mở rộng bộ sưu tập" +"Extends \"{name}\": overridden values are marked" = "Mở rộng \"{name}\": các giá trị ghi đè được đánh dấu" +"Eye" = "Mắt" +"F" = "F" +"Fade" = "Mờ dần" +"False" = "Sai" +"Feb" = "Thg 2" +"Fetching from the server…" = "Đang tải từ máy chủ…" +"FigCraft couldn't open its window: {error}" = "FigCraft không thể mở cửa sổ: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft chưa hỗ trợ mọi tính năng của các tệp này. Các ghi chú dưới đây liệt kê những gì đã được lược bỏ hoặc thay đổi trong bản sao đã nhập; tệp gốc không bị thay đổi." +"File" = "Tệp" +"File name" = "Tên tệp" +"File not found" = "Không tìm thấy tệp" +"File published" = "Đã xuất bản tệp" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Các tệp không được mã hóa đầu cuối: người vận hành máy chủ có thể đọc được chúng." +"Files nobody opens for {n} days are deleted." = { other = "Các tệp không ai mở trong {n} ngày sẽ bị xóa." } +"Fill" = "Màu tô" +"Fill (fr)" = "Lấp đầy (fr)" +"Fill container" = "Lấp đầy khung chứa" +"Fill window" = "Lấp đầy cửa sổ" +"Fills" = "Màu tô" +"Fill|sizing" = "Lấp đầy" +"Filter and sort" = "Lọc và sắp xếp" +"Find" = "Tìm" +"Find and replace" = "Tìm và thay thế" +"Find layers" = "Tìm lớp" +"First" = "Đầu tiên" +"First commit" = "Commit đầu tiên" +"First on top" = "Mục đầu ở trên cùng" +"Fit" = "Vừa khít" +"Fit device to window" = "Vừa thiết bị với cửa sổ" +"Fit to contents" = "Vừa khít nội dung" +"Fit to window" = "Vừa khít cửa sổ" +"Fit width" = "Vừa chiều rộng" +"Fixed" = "Cố định" +"Fixed gap" = "Khoảng hở cố định" +"Fixed size" = "Kích thước cố định" +"Flatten" = "Làm phẳng" +"Flip" = "Lật" +"Flip horizontal" = "Lật ngang" +"Flip text to the other side" = "Lật văn bản sang phía bên kia" +"Flip vertical" = "Lật dọc" +"Flip width profile" = "Lật biên dạng độ rộng" +"Flow starting point" = "Điểm bắt đầu luồng" +"Flow starting points" = "Các điểm bắt đầu luồng" +"Flows" = "Các luồng" +"Focus view" = "Chế độ xem tập trung" +"Fold autosaves" = "Gộp các bản tự động lưu" +"Folder: click to collapse" = "Thư mục: nhấp để thu gọn" +"Folder: click to expand" = "Thư mục: nhấp để mở rộng" +"Follow" = "Theo dõi" +"Follow {name}" = "Theo dõi {name}" +"Following" = "Đang theo dõi" +"Following {name} · {key} to stop" = "Đang theo dõi {name} · {key} để dừng" +"Font" = "Phông chữ" +"Font not installed" = "Phông chữ chưa được cài đặt" +"Font size" = "Cỡ chữ" +"Font style" = "Kiểu phông chữ" +"Forget on this computer" = "Quên trên máy tính này" +"Four screens of a trail app with components and a prototype flow" = "Bốn màn hình ứng dụng đường mòn với các thành phần và luồng nguyên mẫu" +"Fractions" = "Phân số" +"Frame" = "Khung" +"Frame selection" = "Đóng khung vùng chọn" +"Frame tools" = "Công cụ khung" +"Frames" = "Các khung" +"From the library \"{name}\": read-only" = "Từ thư viện \"{name}\": chỉ đọc" +"From {name}" = "Từ {name}" +"From {server}" = "Từ {server}" +"Frost" = "Độ mờ sương" +"Gap" = "Khoảng hở" +"Gap between columns" = "Khoảng hở giữa các cột" +"Gap between items" = "Khoảng hở giữa các mục" +"Gap between rows" = "Khoảng hở giữa các hàng" +"Getting started" = "Bắt đầu" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Nhập địa chỉ của một máy chủ công khai, hoặc địa chỉ máy chủ kèm theo khóa (`figcraft-server key` sẽ in khóa đó)." +"Glass" = "Kính" +"Go to main component" = "Đi tới thành phần chính" +"Go to {name}" = "Đi tới {name}" +"Grab bag" = "Tổng hợp" +"Gradient" = "Chuyển sắc" +"Gray" = "Xám" +"Green" = "Xanh lá" +"Greyscale components and two example screens to sketch a flow with" = "Các thành phần thang độ xám và hai màn hình mẫu để phác thảo luồng" +"Grid" = "Lưới" +"Grid ({size})" = "Lưới ({size})" +"Grid styles" = "Kiểu lưới" +"Grid {size}" = "Lưới {size}" +"Group" = "Nhóm" +"Group selection" = "Nhóm vùng chọn" +"Groups" = "Các nhóm" +"Gutter" = "Rãnh cột" +"Hand tool" = "Công cụ bàn tay" +"Hanging list" = "Danh sách nhô đầu dòng" +"Hanging punctuation" = "Dấu câu treo ngoài lề" +"Hard light" = "Ánh sáng gắt" +"Height" = "Chiều cao" +"Help" = "Trợ giúp" +"Help and resources" = "Trợ giúp và tài nguyên" +"Help page" = "Trang trợ giúp" +"Here: {names}" = "Tại đây: {names}" +"Hidden" = "Đã ẩn" +"Hide contents" = "Ẩn nội dung" +"Hide from publishing" = "Ẩn khi xuất bản" +"Hide section contents" = "Ẩn nội dung phần" +"Hide these tips (Getting started in the Help menu shows them again)" = "Ẩn các mẹo này (chọn Bắt đầu trong trình đơn Trợ giúp để hiện lại)" +"Hide when publishing" = "Ẩn khi xuất bản" +"Highlight layers on hover" = "Làm nổi bật lớp khi di chuột qua" +"Highlights" = "Vùng sáng" +"Hold Alt" = "Giữ Alt" +"Hold Alt+Z" = "Giữ Alt+Z" +"Hold Ctrl" = "Giữ Ctrl" +"Hold Z" = "Giữ Z" +"Hold ⌃ or ⌘" = "Giữ ⌃ hoặc ⌘" +"Hold ⌥" = "Giữ ⌥" +"Hold ⌥Z" = "Giữ ⌥Z" +"Home" = "Trang chủ" +"Horizontal" = "Ngang" +"Horizontal align" = "Căn ngang" +"Horizontal hex" = "Lục giác ngang" +"Horizontal padding" = "Đệm ngang" +"Horizontal spacing" = "Khoảng cách ngang" +"Hosted by {operator}" = "Được vận hành bởi {operator}" +"Hover a layer to see its properties" = "Di chuột qua một lớp để xem thuộc tính" +"Hover other layers to measure the distance" = "Di chuột qua các lớp khác để đo khoảng cách" +"Hue" = "Sắc độ" +"Hug" = "Ôm khít" +"Hug contents" = "Ôm khít nội dung" +"Icon components on a 24 px grid with keylines and a size preview" = "Các thành phần biểu tượng trên lưới 24 px có đường định chuẩn và khung xem trước kích thước" +"Icon set starter" = "Bộ khởi đầu biểu tượng" +"If" = "Nếu" +"If it keeps happening, please report it at {link} with this message." = "Nếu lỗi vẫn tiếp diễn, vui lòng báo cáo tại {link} kèm theo thông báo này." +"If {condition}" = "Nếu {condition}" +"Ignore auto layout (absolute position)" = "Bỏ qua bố cục tự động (vị trí tuyệt đối)" +"Image" = "Hình ảnh" +"Images" = "Hình ảnh" +"Import" = "Nhập" +"Import path" = "Đường dẫn nhập" +"Import plugin from manifest" = "Nhập plugin từ tệp kê khai" +"Import plugin from manifest…" = "Nhập plugin từ tệp kê khai…" +"Import report" = "Báo cáo nhập" +"Import variables" = "Nhập các biến" +"Imported document" = "Tài liệu đã nhập" +"Imported {name}" = "Đã nhập {name}" +"Imported {n} new variables, updated {updated}" = { other = "Đã nhập {n} biến mới, cập nhật {updated}" } +"Import…" = "Nhập…" +"In an instance, this comes from the main component" = "Trong bản thể, giá trị này được lấy từ thành phần chính" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "Trên trình duyệt, bạn có thể tham gia các phiên do ứng dụng máy tính chủ trì và các tệp trên máy chủ FigCraft. Trang mở qua https chỉ kết nối tới máy tính khác qua liên kết wss:// (máy chủ có TLS)." +"Include bounding box" = "Bao gồm khung giới hạn" +"Include id attribute" = "Bao gồm thuộc tính id" +"Included" = "Đã bao gồm" +"Included in layout" = "Được tính trong bố cục" +"Individual corners" = "Từng góc riêng" +"Individual padding" = "Đệm từng cạnh" +"Individual strokes" = "Nét từng cạnh" +"Ink" = "Mực" +"Inner shadow" = "Bóng trong" +"Inside" = "Bên trong" +"Inspect" = "Kiểm tra" +"Inspect the layer under the pointer: size, colours, typography" = "Kiểm tra lớp dưới con trỏ: kích thước, màu sắc, kiểu chữ" +"Install" = "Cài đặt" +"Install plugin" = "Cài đặt plugin" +"Installed" = "Đã cài đặt" +"Installed {name}" = "Đã cài đặt {name}" +"Installing…" = "Đang cài đặt…" +"Instance" = "Bản thể" +"Instance properties" = "Thuộc tính bản thể" +"Instance swap" = "Hoán đổi bản thể" +"Instance swap (no other component)" = "Hoán đổi bản thể (không có thành phần khác)" +"Instances" = "Các bản thể" +"Instant" = "Tức thì" +"Intensity" = "Cường độ" +"Interaction" = "Tương tác" +"Interactions" = "Các tương tác" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Thiết kế giao diện và tạo nguyên mẫu. Công cụ thiết kế miễn phí, mã nguồn mở, được viết mới hoàn toàn bằng Rust, thuộc bộ ứng dụng sáng tạo ArtCraft." +"Interface size" = "Kích thước giao diện" +"Intersect" = "Giao nhau" +"Intersects" = "Phép giao" +"Invalid color" = "Màu không hợp lệ" +"Invite link" = "Liên kết mời" +"Invite link copied" = "Đã sao chép liên kết mời" +"It asks permission to:" = "Plugin yêu cầu quyền:" +"Jan" = "Thg 1" +"Join" = "Tham gia" +"Join code copied" = "Đã sao chép mã tham gia" +"Join code or invite link" = "Mã tham gia hoặc liên kết mời" +"Join live session" = "Tham gia phiên trực tiếp" +"Join us on Discord" = "Tham gia Discord cùng chúng tôi" +"Join with a code" = "Tham gia bằng mã" +"Joined" = "Đã tham gia" +"Joined {file}" = "Đã tham gia {file}" +"Joining…" = "Đang tham gia…" +"Join|stroke" = "Khớp nối" +"Jul" = "Thg 7" +"Jun" = "Thg 6" +"Just now" = "Vừa xong" +"Justify" = "Căn đều" +"Keep appearance" = "Giữ nguyên diện mạo" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Giữ nguyên diện mạo sẽ chuyển đổi mọi giá trị màu sang {profile} để màu sắc hiển thị như cũ. Giữ nguyên giá trị sẽ giữ nguyên các con số nên màu sắc sẽ thay đổi." +"Keep tool selected after use" = "Giữ công cụ được chọn sau khi dùng" +"Keep values" = "Giữ nguyên giá trị" +"Key/Gamepad" = "Phím/Tay cầm" +"Key/Gamepad {key}" = "Phím/Tay cầm {key}" +"Keyboard shortcuts" = "Phím tắt" +"Kits" = "Bộ công cụ" +"Landing page" = "Trang đích" +"Language" = "Ngôn ngữ" +"Lasso" = "Dây quây" +"Last" = "Cuối cùng" +"Last change: {note}" = "Thay đổi gần nhất: {note}" +"Last commit" = "Commit cuối cùng" +"Last modified" = "Sửa đổi lần cuối" +"Last on top" = "Mục cuối ở trên cùng" +"Layer blur" = "Làm mờ lớp" +"Layer name" = "Tên lớp" +"Layers" = "Các lớp" +"Layers in an instance take their position from the main component" = "Các lớp trong bản thể lấy vị trí từ thành phần chính" +"Layout" = "Bố cục" +"Layout grid" = "Lưới bố cục" +"Layout grid settings" = "Thiết lập lưới bố cục" +"Layout grids" = "Lưới bố cục" +"Leave session" = "Rời phiên" +"Leaves" = "Lá cây" +"Left" = "Trái" +"Left & right" = "Trái & phải" +"Left padding" = "Đệm trái" +"Left side" = "Cạnh trái" +"Left side width" = "Độ rộng cạnh trái" +"Letter case" = "Kiểu chữ hoa/thường" +"Letter spacing" = "Khoảng cách chữ" +"Libraries" = "Thư viện" +"Libraries in this file" = "Thư viện trong tệp này" +"Library updates available" = "Có bản cập nhật thư viện" +"Library: click to collapse" = "Thư viện: nhấp để thu gọn" +"Library: click to expand" = "Thư viện: nhấp để mở rộng" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Thư viện: các biến của thư viện chỉ đọc tại đây; hãy chỉnh sửa chúng trong tệp thư viện rồi xuất bản" +"Light" = "Sáng" +"Lighten" = "Làm sáng" +"Light|glass" = "Ánh sáng" +"Line" = "Đường thẳng" +"Line arrow" = "Mũi tên nét mảnh" +"Line delta" = "Chênh lệch dòng" +"Line height" = "Chiều cao dòng" +"Linear" = "Tuyến tính" +"Lines" = "Đường thẳng" +"Lines +/−" = "Dòng +/−" +"Lines added" = "Dòng đã thêm" +"Lines deleted" = "Dòng đã xóa" +"Link" = "Liên kết" +"Link copied" = "Đã sao chép liên kết" +"Link docs, code or tickets for developers." = "Liên kết tài liệu, mã nguồn hoặc thẻ công việc cho nhà phát triển." +"Link frames on the Prototype tab, then present the flow" = "Liên kết các khung trong thẻ Nguyên mẫu, sau đó trình chiếu luồng" +"Link this component to its code component: instances then show your real snippet with their values." = "Liên kết thành phần này với thành phần mã nguồn: các bản thể sẽ hiển thị đoạn mã thực kèm theo giá trị của chúng." +"List spacing" = "Khoảng cách mục danh sách" +"List style" = "Kiểu danh sách" +"Live session" = "Phiên trực tiếp" +"Live · {people}" = "Trực tiếp · {people}" +"Local components" = "Thành phần cục bộ" +"Local network" = "Mạng cục bộ" +"Local styles" = "Kiểu cục bộ" +"Local variables" = "Biến cục bộ" +"Lock/Unlock" = "Khóa/Mở khóa" +"Locked" = "Đã khóa" +"Lowercase" = "Chữ thường" +"Luminance" = "Độ chói" +"Luminosity" = "Độ sáng" +"Main" = "Chính" +"Main component deleted" = "Thành phần chính đã bị xóa" +"Main menu" = "Trình đơn chính" +"Make a component" = "Tạo thành phần" +"Make slot" = "Tạo ô chứa" +"Mar" = "Thg 3" +"Margin" = "Lề" +"Mark all as read" = "Đánh dấu tất cả là đã đọc" +"Mark as resolved" = "Đánh dấu là đã giải quyết" +"Mark as unread" = "Đánh dấu là chưa đọc" +"Mark as unresolved" = "Đánh dấu là chưa giải quyết" +"Mask" = "Mặt nạ" +"Mass · Stiffness · Damping" = "Khối lượng · Độ cứng · Độ giảm chấn" +"Match (empty: the whole name)" = "Khớp (để trống: toàn bộ tên)" +"Max H" = "Cao tối đa" +"Max W" = "Rộng tối đa" +"Max lines" = "Số dòng tối đa" +"Maximize" = "Phóng to cực đại" +"May" = "Tháng 5" +"Measure to the selection" = "Đo khoảng cách tới vùng chọn" +"Merge into main" = "Hợp nhất vào nhánh chính" +"Merge regions" = "Hợp nhất các vùng" +"Merge the branch into the main file (one undo step) and archive it" = "Hợp nhất nhánh vào tệp chính (một bước hoàn tác) và lưu trữ nhánh" +"Merged" = "Đã hợp nhất" +"Merged PRs" = "PR đã hợp nhất" +"Min H" = "Cao tối thiểu" +"Min W" = "Rộng tối thiểu" +"Minimize" = "Thu nhỏ" +"Mirror angle" = "Đối xứng góc" +"Mirror angle and length" = "Đối xứng góc và độ dài" +"Mirroring" = "Đối xứng tay nắm" +"Missing" = "Thiếu" +"Missing fonts" = "Phông chữ bị thiếu" +"Missing layer" = "Lớp bị thiếu" +"Missing style" = "Kiểu bị thiếu" +"Missing variable" = "Biến bị thiếu" +"Miter" = "Góc nhọn" +"Miter angle" = "Góc vát nhọn" +"Mixed" = "Hỗn hợp" +"Mixed fonts" = "Nhiều phông chữ" +"Mixed interactions: click + to replace them" = "Tương tác hỗn hợp: nhấp + để thay thế" +"Mobile app" = "Ứng dụng di động" +"Mode" = "Chế độ" +"Model" = "Mô hình" +"Models" = "Mô hình AI" +"Mono" = "Đơn sắc" +"More" = "Thêm" +"More instance actions" = "Thêm hành động cho bản thể" +"Mouse down" = "Nhấn chuột xuống" +"Mouse enter" = "Đưa chuột vào" +"Mouse leave" = "Đưa chuột ra" +"Mouse up" = "Nhả chuột" +"Move" = "Di chuyển" +"Move along the toolbar or the layer rows" = "Di chuyển dọc thanh công cụ hoặc các hàng lớp" +"Move column left" = "Chuyển cột sang trái" +"Move column right" = "Chuyển cột sang phải" +"Move down" = "Di chuyển xuống" +"Move in" = "Di chuyển vào" +"Move out" = "Di chuyển ra" +"Move tools" = "Công cụ di chuyển" +"Move up" = "Di chuyển lên" +"Moved to {name}" = "Đã chuyển sang {name}" +"Multi" = "Đa sắc" +"Multiply" = "Nhân" +"Muted" = "Trầm" +"Name" = "Tên" +"Name (A–Z)" = "Tên (A–Z)" +"Named instance" = "Bản thể có tên" +"Navigate to" = "Điều hướng đến" +"Nested instance" = "Bản thể lồng nhau" +"New design file" = "Tệp thiết kế mới" +"Newest first" = "Mới nhất trước" +"Next frame" = "Khung tiếp theo" +"Next or previous control in the focused region" = "Điều khiển tiếp theo hoặc trước đó trong vùng đang giữ tiêu điểm" +"Next ready for dev" = "Mục Sẵn sàng cho dev tiếp theo" +"Next size option" = "Tùy chọn kích thước tiếp theo" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Không có tài nguyên: chưa có thiết lập xuất, biểu tượng hay hình ảnh. Hãy thêm thiết lập xuất trong thẻ Thiết kế." +"No boolean variables fit here." = "Không có biến boolean nào phù hợp tại đây." +"No boolean variables yet. Create them in Local variables." = "Chưa có biến boolean nào. Hãy tạo trong mục Biến cục bộ." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Chưa có nhánh nào. Nhánh là bản sao của tệp để thử nghiệm thay đổi, sau đó xem xét và hợp nhất." +"No category" = "Không có danh mục" +"No changes" = "Không có thay đổi" +"No color styles" = "Không có kiểu màu" +"No colour variables fit here." = "Không có biến màu nào phù hợp tại đây." +"No colour variables yet. Create them in Local variables." = "Chưa có biến màu nào. Hãy tạo trong mục Biến cục bộ." +"No comments yet" = "Chưa có bình luận nào" +"No compatibility warnings reported" = "Không có cảnh báo tương thích nào" +"No components yet. Select layers and press {keys}." = "Chưa có thành phần nào. Hãy chọn các lớp và nhấn {keys}." +"No conflicts." = "Không có xung đột." +"No contributor data was built into this copy." = "Bản sao này không có dữ liệu người đóng góp." +"No device" = "Không có thiết bị" +"No files or templates match your search" = "Không có tệp hoặc mẫu nào khớp với tìm kiếm của bạn" +"No fonts match" = "Không có phông chữ nào khớp" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Không có mã tham gia tại đây (máy chủ cũ hơn tính năng mã tham gia, hoặc địa chỉ này không thể tạo mã): hãy chia sẻ liên kết thay thế." +"No layer changes to show." = "Không có thay đổi lớp nào để hiển thị." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Chưa có thư viện nào. Hãy thêm tệp .figcraft đã xuất bản làm thư viện, hoặc dán liên kết thư viện nhóm." +"No list" = "Không tạo danh sách" +"No matching comments" = "Không có bình luận phù hợp" +"No matching components" = "Không có thành phần phù hợp" +"No matching layers" = "Không có lớp phù hợp" +"No matching styles" = "Không có kiểu phù hợp" +"No matching variables" = "Không có biến phù hợp" +"No mirroring" = "Không đối xứng" +"No model credits were built into this copy." = "Bản sao này không có thông tin ghi nhận mô hình." +"No number variables fit here." = "Không có biến số nào phù hợp tại đây." +"No number variables yet. Create them in Local variables." = "Chưa có biến số nào. Hãy tạo trong mục Biến cục bộ." +"No other pages" = "Không có trang nào khác" +"No paints" = "Không có màu tô" +"No plugins on {server}." = "Không có plugin nào trên {server}." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Chưa có plugin nào. Hãy nhập một plugin phát triển từ tệp figcraft-plugin.json." +"No preferred components" = "Chưa có thành phần ưu tiên" +"No preset" = "Không có thiết lập sẵn" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Chưa có phiên bản đã lưu nào. Hãy lưu một phiên bản để đánh dấu mốc công việc có thể quay lại sau." +"No scrolling" = "Không cuộn" +"No shortcuts match" = "Không có phím tắt nào khớp" +"No status" = "Không có trạng thái" +"No string variables fit here." = "Không có biến chuỗi nào phù hợp tại đây." +"No string variables yet. Create them in Local variables." = "Chưa có biến chuỗi nào. Hãy tạo trong mục Biến cục bộ." +"No styles yet." = "Chưa có kiểu nào." +"No styles yet. Click + to create one from the selection." = "Chưa có kiểu nào. Nhấp + để tạo kiểu từ vùng chọn." +"No templates match your search" = "Không có mẫu nào khớp với tìm kiếm của bạn" +"No text styles yet. Click + to create one from the selected text." = "Chưa có kiểu văn bản nào. Nhấp + để tạo kiểu từ văn bản đã chọn." +"No variables fit here." = "Không có biến nào phù hợp tại đây." +"No variables yet. Create them in Local variables." = "Chưa có biến nào. Hãy tạo trong mục Biến cục bộ." +"Nobody else is here yet" = "Chưa có ai khác ở đây" +"Noise" = "Nhiễu" +"None" = "Không có" +"None." = "Không có." +"Normal" = "Bình thường" +"Not bound" = "Chưa gắn biến" +"Not in a live session" = "Không ở trong phiên trực tiếp" +"Not in this font" = "Không có trong phông chữ này" +"Not properties, kept as written: {names}" = "Không phải thuộc tính, được giữ nguyên như đã viết: {names}" +"Not read yet" = "Chưa đọc" +"Not shared" = "Chưa chia sẻ" +"Note {n}" = "Ghi chú {n}" +"Nothing changed since this was marked ready for dev." = "Chưa có gì thay đổi kể từ khi được đánh dấu sẵn sàng cho dev." +"Nothing here yet. Set a frame's status to list it." = "Chưa có gì ở đây. Hãy đặt trạng thái cho một khung để hiển thị tại đây." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Chưa có tệp trực tuyến nào. Hãy dùng nút Chia sẻ trên một tệp để đưa lên trực tuyến và tệp sẽ xuất hiện tại đây." +"Nothing to export" = "Không có gì để xuất" +"Nov" = "Thg 11" +"Now" = "Bây giờ" +"Nudge (Shift: big nudge)" = "Dịch nhẹ (Shift: dịch bước lớn)" +"Nudge amount" = "Mức dịch chuyển nhẹ" +"Number" = "Số" +"Number ↑" = "Số ↑" +"Number ↓" = "Số ↓" +"Numbered list" = "Danh sách đánh số" +"Object" = "Đối tượng" +"Oct" = "Thg 10" +"Off" = "Tắt" +"Offered first when filling the slot (any content is allowed)" = "Được đề xuất đầu tiên khi thêm vào ô chứa (cho phép mọi nội dung)" +"Offered first when swapping the instance (any component is allowed)" = "Được đề xuất đầu tiên khi hoán đổi bản thể (cho phép mọi thành phần)" +"Offset" = "Độ lệch" +"Oldest first" = "Cũ nhất trước" +"On click" = "Khi nhấp" +"On drag" = "Khi kéo" +"On this page" = "Trên trang này" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Một thông báo với năm kích thước phổ biến: vuông, dọc, story, liên kết và biểu ngữ" +"Online" = "Trực tuyến" +"Online files" = "Tệp trực tuyến" +"Online sharing is off: files are shared on the local network only." = "Chia sẻ trực tuyến đang tắt: tệp chỉ được chia sẻ trong mạng cục bộ." +"Only the first changed layers are listed." = "Chỉ liệt kê những lớp thay đổi đầu tiên." +"Only the first {n} changed layers are listed." = "Chỉ liệt kê {n} lớp thay đổi đầu tiên." +"Only your threads" = "Chỉ luồng của bạn" +"Opacity" = "Độ mờ" +"Opacity & blend" = "Độ mờ & hòa trộn" +"Opacity 10%…90%, 0: 100%" = "Độ mờ 10%…90%, 0: 100%" +"Open" = "Mở" +"Open .figcraft, .svg or images" = "Mở tệp .figcraft, .svg hoặc hình ảnh" +"Open branch" = "Mở nhánh" +"Open file" = "Mở tệp" +"Open files" = "Các tệp đang mở" +"Open in focus view" = "Mở trong chế độ xem tập trung" +"Open in new window" = "Mở trong cửa sổ mới" +"Open invite links in FigCraft" = "Mở liên kết mời trong FigCraft" +"Open link" = "Mở liên kết" +"Open link {url}" = "Mở liên kết {url}" +"Open overlay" = "Mở lớp phủ" +"Open prototype preview" = "Mở xem trước nguyên mẫu" +"Open variables ({count})" = "Mở các biến ({count})" +"Opened as a new document" = "Đã mở dưới dạng tài liệu mới" +"Opened “{name}” in a new tab" = "Đã mở “{name}” trong thẻ mới" +"Opening the file…" = "Đang mở tệp…" +"Opening {n} files as new files" = { other = "Đang mở {n} tệp dưới dạng tệp mới" } +"Open|comments" = "Đang mở" +"Options" = "Tùy chọn" +"Orange" = "Cam" +"Order" = "Thứ tự" +"Other" = "Khác" +"Other apps may not open every .fig file exported here." = "Các ứng dụng khác có thể không mở được mọi tệp .fig xuất từ đây." +"Others lose access and keep their copy; the file stays on the server for you" = "Những người khác sẽ mất quyền truy cập và giữ lại bản sao của họ; tệp vẫn còn trên máy chủ cho bạn" +"Others type this under Share ▸ Join with a code" = "Người khác nhập mã này tại mục Chia sẻ ▸ Tham gia bằng mã" +"Outline stroke" = "Chuyển nét thành đường viền" +"Outline text" = "Chuyển văn bản thành đường viền" +"Outlines" = "Đường viền nét" +"Outside" = "Bên ngoài" +"Overflow" = "Nội dung tràn" +"Overlay" = "Lớp phủ" +"Overlay position" = "Vị trí lớp phủ" +"Overridden here (the collection it extends has another value)" = "Đã ghi đè tại đây (bộ sưu tập gốc có giá trị khác)" +"Overrides" = "Ghi đè" +"Owner token" = "Mã thông báo chủ sở hữu" +"Owner token copied" = "Đã sao chép mã thông báo chủ sở hữu" +"PDF print options" = "Tùy chọn in PDF" +"PDF print settings" = "Thiết lập in PDF" +"Padding" = "Đệm" +"Page" = "Trang" +"Page deleted" = "Đã xóa trang" +"Pages" = "Các trang" +"Pages added to the document" = "Các trang đã được thêm vào tài liệu" +"Pages: {pages}" = "Trang: {pages}" +"Paint bucket" = "Thùng sơn" +"Pan the canvas" = "Di chuyển vùng vẽ" +"Pan with nothing selected (Shift: farther)" = "Di chuyển vùng vẽ khi không chọn gì (Shift: xa hơn)" +"Panels" = "Bảng" +"Paper" = "Giấy" +"Paragraph indent" = "Thụt lề đoạn văn" +"Paragraph indent applies to left-aligned text" = "Thụt lề đoạn văn áp dụng cho văn bản căn trái" +"Paragraph spacing" = "Khoảng cách đoạn văn" +"Pass through" = "Xuyên qua" +"Paste a link (https://…)" = "Dán một liên kết (https://…)" +"Paste a team library link (a view link)" = "Dán liên kết thư viện nhóm (liên kết xem)" +"Paste here" = "Dán vào đây" +"Paste to replace" = "Dán để thay thế" +"Pasted design layers; {n} kinds of content were left out" = { other = "Đã dán các lớp thiết kế; {n} loại nội dung đã bị lược bỏ" } +"Pattern" = "Họa tiết" +"Pattern alignment" = "Căn chỉnh họa tiết" +"Pattern scale" = "Tỷ lệ họa tiết" +"Pattern source" = "Nguồn họa tiết" +"Pen" = "Bút" +"Pencil" = "Bút chì" +"People in this file" = "Những người trong tệp này" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Mọi người có thể tham gia từ bất kỳ đâu bằng mã. Tệp được lưu trên {server} trong khi bạn chia sẻ." +"People on your network join with a code. Nothing goes through a server." = "Mọi người trong mạng của bạn tham gia bằng mã. Không có dữ liệu nào đi qua máy chủ." +"Phone" = "Điện thoại" +"Pick color" = "Chọn màu" +"Pick color (I)" = "Chọn màu (I)" +"Pick one font family first" = "Hãy chọn một họ phông chữ trước" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Chọn công cụ Khung, sau đó kéo trên vùng vẽ hoặc chọn kích thước thiết bị" +"Pin a property" = "Ghim thuộc tính" +"Pinch" = "Bóp nhọn" +"Pixel grid" = "Lưới pixel" +"Pixel preview 1x" = "Xem trước pixel 1x" +"Pixel preview 2x" = "Xem trước pixel 2x" +"Place a shape or frame (its tool chosen)" = "Đặt hình dạng hoặc khung (khi đã chọn công cụ tương ứng)" +"Place automatically" = "Đặt tự động" +"Place image" = "Đặt hình ảnh" +"Placed on the current page" = "Đã đặt trên trang hiện tại" +"Playground" = "Sân thử nghiệm" +"Plugin" = "Plugin" +"Plugins" = "Plugin" +"Plugins & widgets" = "Plugin & tiện ích" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugin chạy trong môi trường cô lập của FigCraft, không truy cập tệp, mạng hay đồng hồ. Chỉ cài đặt những plugin bạn tin tưởng." +"Plus darker" = "Cộng tối hơn" +"Plus lighter" = "Cộng sáng hơn" +"Point" = "Điểm" +"Points" = "Các điểm" +"Polygon" = "Đa giác" +"Polygons" = "Đa giác" +"Port" = "Cổng" +"Position" = "Vị trí" +"Post" = "Đăng" +"Preferences" = "Tùy chọn" +"Preferred" = "Ưu tiên" +"Present" = "Trình chiếu" +"Present or preview" = "Trình chiếu hoặc xem trước" +"Present this flow" = "Trình chiếu luồng này" +"Presentation" = "Thuyết trình" +"Presentation slides" = "Trang trình chiếu" +"Presenting: comments" = "Khi trình chiếu: bình luận" +"Presenting: cycle the size options" = "Khi trình chiếu: chuyển đổi tùy chọn kích thước" +"Presenting: full screen" = "Khi trình chiếu: toàn màn hình" +"Presenting: next frame" = "Khi trình chiếu: khung tiếp theo" +"Presenting: previous frame" = "Khi trình chiếu: khung trước" +"Presenting: restart" = "Khi trình chiếu: bắt đầu lại" +"Preserve scroll position" = "Giữ nguyên vị trí cuộn" +"Presets" = "Thiết lập sẵn" +"Press a key…" = "Nhấn một phím…" +"Preview" = "Xem trước" +"Preview this flow" = "Xem trước luồng này" +"Preview this frame" = "Xem trước khung này" +"Previous frame" = "Khung trước" +"Previous ready for dev" = "Mục Sẵn sàng cho dev trước đó" +"Progressive" = "Lũy tiến" +"Properties" = "Thuộc tính" +"Prototype" = "Nguyên mẫu" +"Prototype preview" = "Xem trước nguyên mẫu" +"Prototype settings" = "Thiết lập nguyên mẫu" +"Publish" = "Xuất bản" +"Publish changes" = "Xuất bản thay đổi" +"Publish to another server…" = "Xuất bản lên máy chủ khác…" +"Publish to server" = "Xuất bản lên máy chủ" +"Published version {v}" = "Đã xuất bản phiên bản {v}" +"Published version {v} to the server" = "Đã xuất bản phiên bản {v} lên máy chủ" +"Published version {v}: save the file so other files can use it" = "Đã xuất bản phiên bản {v}: hãy lưu tệp để các tệp khác có thể sử dụng" +"Published · {people}" = "Đã xuất bản · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Cần ứng dụng máy tính để xuất bản lên máy chủ; trên trình duyệt bạn có thể tham gia các tệp trên máy chủ" +"Publishing with the server's key." = "Đang xuất bản bằng khóa của máy chủ." +"Publishing…" = "Đang xuất bản…" +"Push" = "Đẩy" +"Push changes to main component" = "Đẩy thay đổi lên thành phần chính" +"Put text on path" = "Đặt văn bản lên đường dẫn" +"R" = "R" +"Radial" = "Hướng tâm" +"Radius" = "Bán kính" +"Ratio" = "Tỷ lệ" +"Reading the library…" = "Đang đọc thư viện…" +"Ready for dev" = "Sẵn sàng cho dev" +"Real name" = "Tên thật" +"Recent" = "Gần đây" +"Recent files" = "Tệp gần đây" +"Recents" = "Gần đây" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { other = "Đã khôi phục {n} tệp chưa lưu sau khi ứng dụng thoát đột ngột: hãy lưu lại để giữ chúng" } +"Rectangle" = "Hình chữ nhật" +"Rectangles" = "Hình chữ nhật" +"Red" = "Đỏ" +"Reduce motion" = "Giảm chuyển động" +"Refraction" = "Khúc xạ" +"Regular expression" = "Biểu thức chính quy" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Tương ứng với tỷ lệ hiển thị của hệ thống (100% là kích thước chuẩn của hệ thống). Mức thu phóng tài liệu là riêng biệt." +"Remove" = "Xóa" +"Remove all" = "Xóa tất cả" +"Remove annotation" = "Xóa chú thích" +"Remove auto layout" = "Xóa bố cục tự động" +"Remove dev resource" = "Xóa tài nguyên dev" +"Remove export setting" = "Xóa thiết lập xuất" +"Remove from recents" = "Xóa khỏi danh sách gần đây" +"Remove from server" = "Xóa khỏi máy chủ" +"Remove from server…" = "Xóa khỏi máy chủ…" +"Remove guide" = "Xóa đường gióng" +"Remove interaction" = "Xóa tương tác" +"Remove library from this file" = "Xóa thư viện khỏi tệp này" +"Remove min and max" = "Xóa giới hạn tối thiểu và tối đa" +"Remove min and max height" = "Xóa chiều cao tối thiểu và tối đa" +"Remove min and max width" = "Xóa chiều rộng tối thiểu và tối đa" +"Remove regions" = "Xóa các vùng" +"Remove slot" = "Xóa ô chứa" +"Remove starting point" = "Xóa điểm bắt đầu" +"Remove stop" = "Xóa điểm dừng màu" +"Remove text from path" = "Gỡ văn bản khỏi đường dẫn" +"Remove the else and its actions" = "Xóa nhánh ngược lại và các hành động của nó" +"Remove this condition and its actions" = "Xóa điều kiện này và các hành động của nó" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Xóa tệp này khỏi máy chủ? Tệp sẽ không thể chia sẻ hay mở lại từ đó nữa. Bản sao của bạn vẫn mở tại đây." +"Remove {name} from session" = "Xóa {name} khỏi phiên" +"Remove {name} from {server}?" = "Xóa {name} khỏi {server}?" +"Removed" = "Đã xóa" +"Removed from the server" = "Đã xóa khỏi máy chủ" +"Removed since it was marked" = "Đã bị xóa kể từ khi đánh dấu" +"Rename" = "Đổi tên" +"Rename duplicated layers" = "Đổi tên các lớp nhân bản" +"Rename folder" = "Đổi tên thư mục" +"Rename group" = "Đổi tên nhóm" +"Rename layers" = "Đổi tên các lớp" +"Rename mode" = "Đổi tên chế độ" +"Rename on the server" = "Đổi tên trên máy chủ" +"Rename on the server…" = "Đổi tên trên máy chủ…" +"Rename page" = "Đổi tên trang" +"Rename to" = "Đổi tên thành" +"Reorder selection" = "Sắp xếp lại vùng chọn" +"Replace all" = "Thay thế tất cả" +"Replace mixed export settings" = "Thay thế thiết lập xuất hỗn hợp" +"Replace with" = "Thay thế bằng" +"Replace with a new interaction" = "Thay thế bằng tương tác mới" +"Reply" = "Trả lời" +"Report" = "Báo cáo" +"Report this file…" = "Báo cáo tệp này…" +"Reset" = "Đặt lại" +"Reset adjustments" = "Đặt lại điều chỉnh" +"Reset all changes" = "Đặt lại mọi thay đổi" +"Reset all overrides on this layer" = "Đặt lại mọi ghi đè trên lớp này" +"Reset overrides" = "Đặt lại ghi đè" +"Reset slot" = "Đặt lại ô chứa" +"Reset this override" = "Đặt lại ghi đè này" +"Reset to style" = "Đặt lại theo kiểu" +"Reset to the inherited value" = "Đặt lại về giá trị kế thừa" +"Resize by the nudge amount" = "Đổi kích thước theo mức dịch chuyển nhẹ" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Đổi kích thước khung theo màn hình thiết bị (hoặc cửa sổ nếu không có thiết bị): các ràng buộc và bố cục tự động sẽ tự điều chỉnh" +"Resize to fit" = "Đổi kích thước vừa khít" +"Resizing" = "Đổi kích thước" +"Resolve" = "Giải quyết" +"Resolve change" = "Giải quyết thay đổi" +"Resolved" = "Đã giải quyết" +"Resources" = "Tài nguyên" +"Responsive" = "Đáp ứng" +"Restart" = "Bắt đầu lại" +"Restore" = "Khôi phục" +"Restore main component" = "Khôi phục thành phần chính" +"Restore this version" = "Khôi phục phiên bản này" +"Restored “{name}”" = "Đã khôi phục “{name}”" +"Reverse the order" = "Đảo ngược thứ tự" +"Reversed triangle" = "Tam giác ngược" +"Review and merge" = "Xem xét và hợp nhất" +"Ribbon" = "Dải ruy băng" +"Right" = "Phải" +"Right padding" = "Đệm phải" +"Right side" = "Cạnh phải" +"Right side width" = "Độ rộng cạnh phải" +"Rotate 90°" = "Xoay 90°" +"Rotate 90° right" = "Xoay 90° sang phải" +"Rotate to landscape" = "Xoay sang chiều ngang" +"Rotate to portrait" = "Xoay sang chiều dọc" +"Rotation" = "Góc xoay" +"Rotation jitter" = "Độ ngẫu nhiên góc xoay" +"Round" = "Tròn" +"Rounded" = "Bo tròn" +"Row" = "Hàng" +"Row gap" = "Khoảng hở hàng" +"Row sizes" = "Kích thước hàng" +"Row span" = "Số hàng gộp" +"Rows" = "Các hàng" +"Rows {count}" = "Hàng {count}" +"Rulers" = "Thước" +"Run" = "Chạy" +"Run {label}" = "Chạy {label}" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Giống như lớp" +"Saturation" = "Độ bão hòa" +"Save" = "Lưu" +"Save as" = "Lưu thành" +"Save as preset…" = "Lưu thành thiết lập sẵn…" +"Save files automatically" = "Tự động lưu tệp" +"Save version {key}" = "Lưu phiên bản {key}" +"Saved" = "Đã lưu" +"Saved {name}" = "Đã lưu {name}" +"Say something" = "Nhập nội dung trò chuyện" +"Scale" = "Tỷ lệ" +"Scatter" = "Phân tán" +"Scoping: where pickers offer it" = "Phạm vi: nơi biến xuất hiện trong bộ chọn" +"Screen" = "Màn lọc" +"Scroll behavior" = "Hành vi cuộn" +"Scroll to" = "Cuộn tới" +"Scroll to layer" = "Cuộn tới lớp" +"Scroll with parent" = "Cuộn theo lớp cha" +"Search" = "Tìm kiếm" +"Search actions, layers and tools" = "Tìm kiếm hành động, lớp và công cụ" +"Search assets" = "Tìm tài nguyên" +"Search comments" = "Tìm bình luận" +"Search files and templates" = "Tìm tệp và mẫu" +"Search fonts" = "Tìm phông chữ" +"Search layers" = "Tìm lớp" +"Search pages" = "Tìm trang" +"Search plugins" = "Tìm plugin" +"Search results" = "Kết quả tìm kiếm" +"Search shortcuts" = "Tìm phím tắt" +"Search templates" = "Tìm mẫu" +"Section" = "Phần" +"Section color" = "Màu phần" +"Sections" = "Các phần" +"See all templates" = "Xem tất cả mẫu" +"Select a frame and add a starting point to present it as a flow." = "Hãy chọn một khung và thêm điểm bắt đầu để trình chiếu thành một luồng." +"Select a frame to preview it" = "Chọn một khung để xem trước" +"Select a layer to see its properties and code" = "Chọn một lớp để xem thuộc tính và mã nguồn" +"Select all" = "Chọn tất cả" +"Select all with same" = "Chọn tất cả có cùng" +"Select layer" = "Chọn lớp" +"Select layers and they space themselves as their content changes" = "Chọn các lớp để chúng tự động giãn cách khi nội dung thay đổi" +"Select layers to apply the style to" = "Chọn các lớp để áp dụng kiểu" +"Select layers with this color" = "Chọn các lớp có màu này" +"Select overlapping shapes" = "Chọn các hình chồng lên nhau" +"Select points to edit their position, radius and mirroring" = "Chọn các điểm để chỉnh sửa vị trí, bán kính và đối xứng tay nắm" +"Select slot" = "Chọn ô chứa" +"Selection" = "Vùng chọn" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Khung chọn, {w} × {h} tại {x}, {y}: phím mũi tên để di chuyển, Shift+mũi tên hoặc {cmd}+mũi tên để đổi kích thước, Enter để chọn, Escape để hủy" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Khung chọn: phím mũi tên để di chuyển, Shift+mũi tên hoặc {cmd}+mũi tên để đổi kích thước, Enter để chọn, Escape để hủy" +"Selection colors" = "Màu của vùng chọn" +"Send backward" = "Đưa lùi một lớp" +"Send report" = "Gửi báo cáo" +"Send to back" = "Đưa xuống dưới cùng" +"Sep" = "Thg 9" +"Server address" = "Địa chỉ máy chủ" +"Server address (ws://host:47900/)" = "Địa chỉ máy chủ (ws://host:47900/)" +"Server for online sharing" = "Máy chủ chia sẻ trực tuyến" +"Set as default" = "Đặt làm mặc định" +"Set variable" = "Đặt biến" +"Set variable mode" = "Đặt chế độ biến" +"Set variable {name}" = "Đặt biến {name}" +"Set {name} to {value}" = "Đặt {name} thành {value}" +"Shadows" = "Vùng tối" +"Shape" = "Hình dạng" +"Shape builder" = "Tạo hình" +"Shape tools" = "Công cụ hình dạng" +"Share" = "Chia sẻ" +"Share this file" = "Chia sẻ tệp này" +"Share with a code" = "Chia sẻ bằng mã" +"Share: live session, export" = "Chia sẻ: phiên trực tiếp, xuất tệp" +"Shared" = "Đã chia sẻ" +"Shared: copy its code from the file's menu" = "Đã chia sẻ: sao chép mã từ trình đơn của tệp" +"Sharing a file needs the desktop app; here you can join one." = "Cần ứng dụng máy tính để chia sẻ tệp; tại đây bạn có thể tham gia một phiên." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Đang chia sẻ trên mạng cục bộ (chia sẻ trực tuyến đang tắt: chưa đặt máy chủ trong Tùy chọn)." +"Sharing on your network · {people}" = "Đang chia sẻ trên mạng của bạn · {people}" +"Sharing online · {people}" = "Đang chia sẻ trực tuyến · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Đã dừng chia sẻ: tệp vẫn còn trên máy chủ và chỉ mình bạn mở được" +"Show" = "Hiện" +"Show all templates" = "Hiện tất cả mẫu" +"Show as grid" = "Hiện dạng lưới" +"Show as list" = "Hiện dạng danh sách" +"Show autosaves" = "Hiện các bản tự động lưu" +"Show behind transparent areas" = "Hiện phía sau vùng trong suốt" +"Show comments (Shift+C)" = "Hiện bình luận (Shift+C)" +"Show device frame" = "Hiện khung thiết bị" +"Show dimensions on objects" = "Hiện kích thước trên đối tượng" +"Show hotspot hints" = "Hiện gợi ý vùng tương tác" +"Show in Explorer" = "Hiện trong Explorer" +"Show in Finder" = "Hiện trong Finder" +"Show in folder" = "Hiện trong thư mục" +"Show only this on the canvas" = "Chỉ hiện mục này trên vùng vẽ" +"Show open" = "Hiện mục đang mở" +"Show section contents" = "Hiện nội dung phần" +"Show this layer" = "Hiện lớp này" +"Show usage" = "Hiện nơi sử dụng" +"Show/Hide" = "Hiện/Ẩn" +"Show/Hide UI" = "Hiện/Ẩn giao diện" +"Side by side" = "Cạnh nhau" +"Sidebar" = "Thanh bên" +"Simplify stroke" = "Đơn giản hóa nét" +"Size" = "Kích thước" +"Size jitter" = "Độ ngẫu nhiên kích thước" +"Skip ink" = "Chừa nét chữ dưới" +"Slashed zero" = "Số 0 gạch chéo" +"Slice" = "Lát cắt" +"Slices" = "Các lát cắt" +"Slide in" = "Trượt vào" +"Slide out" = "Trượt ra" +"Slot" = "Ô chứa" +"Slot content" = "Nội dung ô chứa" +"Small capitals" = "Chữ hoa nhỏ" +"Small caps" = "Chữ hoa nhỏ" +"Smart animate" = "Hoạt ảnh thông minh" +"Snap to pixel grid" = "Bắt dính vào lưới pixel" +"Social and slides" = "Mạng xã hội và trang chiếu" +"Social media" = "Mạng xã hội" +"Social post sizes" = "Kích thước bài đăng mạng xã hội" +"Soft light" = "Ánh sáng dịu" +"Solid" = "Đặc" +"Some variants have the same property values: give each one a unique combination" = "Một số biến thể có cùng giá trị thuộc tính: hãy đặt cho mỗi biến thể một tổ hợp duy nhất" +"Sort" = "Sắp xếp" +"Sort by" = "Sắp xếp theo" +"Sort by date" = "Sắp xếp theo ngày" +"Sort by page" = "Sắp xếp theo trang" +"Sort by {column}" = "Sắp xếp theo {column}" +"Space around" = "Cách đều xung quanh" +"Space between" = "Cách đều giữa các mục" +"Space drag" = "Space kéo" +"Space evenly" = "Cách đều bằng nhau" +"Space+drag" = "Space+kéo" +"Spacing" = "Khoảng cách" +"Spam, stolen work, abuse…" = "Thư rác, đánh cắp tác phẩm, lạm dụng…" +"Splay" = "Xòe tia" +"Spotlight me" = "Chiếu tâm điểm vào tôi" +"Spread" = "Độ lan" +"Square" = "Hình vuông" +"Standard" = "Tiêu chuẩn" +"Standard ligatures" = "Chữ ghép tiêu chuẩn" +"Star" = "Ngôi sao" +"Stars" = "Các ngôi sao" +"Start" = "Bắt đầu" +"Start ascending from / stop descending at" = "Bắt đầu tăng dần từ / dừng giảm dần tại" +"Start from a blank canvas" = "Bắt đầu từ vùng vẽ trống" +"Start from a template" = "Bắt đầu từ một mẫu" +"Start on path" = "Điểm bắt đầu trên đường dẫn" +"Start on the Home screen" = "Khởi động tại màn hình Trang chủ" +"Start point" = "Điểm đầu" +"Start sharing" = "Bắt đầu chia sẻ" +"Start sharing with the Share button, then pass on the code it gives" = "Bắt đầu chia sẻ bằng nút Chia sẻ, sau đó gửi mã nhận được cho người khác" +"Starting to share…" = "Đang bắt đầu chia sẻ…" +"Status" = "Trạng thái" +"Sticky" = "Ghim cố định khi cuộn" +"Stop" = "Dừng" +"Stop following {name}" = "Dừng theo dõi {name}" +"Stop session" = "Dừng phiên" +"Stop sharing" = "Dừng chia sẻ" +"Stop spotlighting" = "Dừng chiếu tâm điểm" +"Stop {n} {hex}" = "Điểm dừng {n} {hex}" +"Stretch" = "Kéo giãn" +"Strikethrough" = "Gạch ngang chữ" +"String" = "Chuỗi" +"Stroke" = "Nét" +"Stroke style" = "Kiểu nét" +"Stroke weight" = "Độ dày nét" +"Strokes" = "Các nét" +"Style" = "Kiểu" +"Styles" = "Các kiểu" +"Stylistic set 1" = "Bộ kiểu chữ 1" +"Stylistic set 2" = "Bộ kiểu chữ 2" +"Stylistic set 3" = "Bộ kiểu chữ 3" +"Stylistic set 4" = "Bộ kiểu chữ 4" +"Stylistic set 5" = "Bộ kiểu chữ 5" +"Subtract" = "Trừ" +"Subtracts" = "Phép trừ" +"Suffix" = "Hậu tố" +"Suspend snapping while dragging" = "Tạm ngừng bắt dính khi đang kéo" +"Swap instance" = "Hoán đổi bản thể" +"Swap library: use another library's assets of the same names" = "Hoán đổi thư viện: dùng tài nguyên cùng tên từ một thư viện khác" +"Swap orientation" = "Đổi chiều ngang/dọc" +"Swap overlay" = "Hoán đổi lớp phủ" +"Swap to {name}" = "Hoán đổi sang {name}" +"Swap width and height" = "Đổi chiều rộng và chiều cao" +"Swapped instance" = "Bản thể đã hoán đổi" +"Swapped {n} assets" = { other = "Đã hoán đổi {n} tài nguyên" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { other = "Đã hoán đổi {n} tài nguyên; {missed} mục không khớp và vẫn dùng thư viện cũ" } +"Sweep" = "Góc quét" +"System" = "Hệ thống" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Bảng" +"Tablet" = "Máy tính bảng" +"Tabular numbers" = "Chữ số thẳng cột" +"Take the values from the first selected layer" = "Lấy giá trị từ lớp được chọn đầu tiên" +"Taper" = "Thuôn nhọn" +"Taper both ends" = "Thuôn nhọn cả hai đầu" +"Tell the server's operator what is wrong with it" = "Cho người vận hành máy chủ biết vấn đề của tệp này" +"Temperature" = "Nhiệt độ màu" +"Template" = "Mẫu" +"Templates" = "Các mẫu" +"Terms" = "Điều khoản" +"Text" = "Văn bản" +"Text align" = "Căn chỉnh văn bản" +"Text content" = "Nội dung văn bản" +"Text style" = "Kiểu văn bản" +"Text styles" = "Kiểu văn bản" +"Texts" = "Văn bản" +"Texture" = "Kết cấu" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Vùng vẽ xem trước màu theo sRGB; các tệp PNG, JPG và WebP xuất ra được gắn thẻ Display P3." +"The destination document is unavailable. Nothing was imported." = "Tài liệu đích không khả dụng. Không có nội dung nào được nhập." +"The destination document was closed. Nothing was imported." = "Tài liệu đích đã đóng. Không có nội dung nào được nhập." +"The destination page was removed. Nothing was imported." = "Trang đích đã bị xóa. Không có nội dung nào được nhập." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Tệp sẽ bị xóa khỏi máy chủ: không ai có thể mở hoặc tham gia tệp từ đó nữa, và thao tác này không thể hoàn tác. Các bản sao đang mở vẫn được giữ lại ở máy người dùng." +"The file now holds another library" = "Tệp hiện chứa một thư viện khác" +"The file now holds another library (\"{name}\")" = "Tệp hiện chứa một thư viện khác (\"{name}\")" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Các tệp bạn đã chia sẻ hoặc xuất bản trực tuyến từ máy tính này. Chúng sẽ được lưu trên máy chủ cho đến khi bạn xóa." +"The link carries the session's secret: share it only with people you invite." = "Liên kết chứa khóa bí mật của phiên: chỉ chia sẻ với những người bạn mời." +"The name in your online files and on the server. The document inside keeps its own name." = "Tên hiển thị trong danh sách tệp trực tuyến của bạn và trên máy chủ. Tài liệu bên trong vẫn giữ tên riêng của nó." +"The selection uses several text styles: click to apply one" = "Vùng chọn đang dùng nhiều kiểu văn bản: nhấp để áp dụng một kiểu" +"The server deletes this file if nobody opens it before {date}." = "Máy chủ sẽ xóa tệp này nếu không ai mở trước {date}." +"The whole file" = "Toàn bộ tệp" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "Cửa sổ quá nhỏ cho mức {size}%, nên giao diện được hiển thị ở mức {drawn}%. Hãy phóng rộng cửa sổ để dùng mức {size}%." +"Theme" = "Giao diện" +"There's no destination for that action" = "Không có đích đến cho hành động đó" +"Thickness" = "Độ dày" +"This branch can't be reviewed: {e}" = "Không thể xem xét nhánh này: {e}" +"This branch was merged or archived." = "Nhánh này đã được hợp nhất hoặc lưu trữ." +"This computer only" = "Chỉ máy tính này" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Máy tính này ghi nhớ mã thông báo chủ sở hữu để bạn có thể quản lý tệp từ danh sách Trực tuyến ở màn hình Trang chủ. Hãy sao chép mã này nếu muốn quản lý tệp từ nơi khác: đây là mật khẩu của tệp và sẽ không hiển thị lại." +"This condition is edited as an expression" = "Điều kiện này được chỉnh sửa dưới dạng biểu thức" +"This file" = "Tệp này" +"This file has unsaved changes" = "Tệp này có thay đổi chưa lưu" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Tệp này đang ở trên {server} và chỉ mình bạn mở được. Hãy chia sẻ để người khác tham gia bằng mã mới." +"This file is too large to join here (up to {mb} MB)" = "Tệp này quá lớn để tham gia tại đây (tối đa {mb} MB)" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Tệp này sử dụng phông chữ chưa được cài đặt trên máy tính này. Văn bản sẽ hiển thị bằng phông chữ dự phòng cho đến khi bạn cài đặt chúng." +"This frame is new on the branch." = "Khung này là mới trên nhánh." +"This frame isn't on the branch." = "Khung này không có trên nhánh." +"This server is open to the public: no key needed." = "Máy chủ này mở công khai: không cần khóa." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Máy chủ này yêu cầu khóa: hãy thêm ?key=… vào địa chỉ (`figcraft-server key` sẽ in khóa trên máy chủ)." +"This server takes files only with its key: see Advanced." = "Máy chủ này chỉ nhận tệp khi có khóa: xem phần Nâng cao." +"Tidy up" = "Sắp xếp gọn" +"Tile" = "Lặp ô" +"Tile type" = "Kiểu lặp ô" +"Tint" = "Sắc thái" +"Title" = "Tiêu đề" +"Title case" = "Viết hoa đầu từ" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Để chia sẻ thư viện này với nhóm của bạn, hãy xuất bản tệp lên máy chủ (Chia sẻ ▸ Xuất bản lên máy chủ…), sau đó xuất bản thư viện tại đó." +"Toggle" = "Bật/tắt" +"Toggle light/dark theme" = "Chuyển đổi giao diện sáng/tối" +"Toggle visibility" = "Bật/tắt hiển thị" +"Tools" = "Công cụ" +"Top" = "Trên" +"Top & bottom" = "Trên & dưới" +"Top left radius" = "Bán kính góc trên trái" +"Top padding" = "Đệm trên" +"Top right radius" = "Bán kính góc trên phải" +"Transform" = "Biến đổi" +"Triangle arrow" = "Mũi tên tam giác" +"Trigger" = "Kích hoạt" +"True" = "Đúng" +"Truncate text" = "Cắt bớt văn bản" +"Truncate with an ellipsis" = "Cắt bớt bằng dấu ba chấm" +"Try again" = "Thử lại" +"Try another search or filter." = "Hãy thử từ khóa hoặc bộ lọc khác." +"Try another search, or browse templates." = "Hãy thử tìm kiếm khác, hoặc duyệt các mẫu." +"Try another word, or look at every kind." = "Hãy thử từ khác, hoặc xem tất cả các loại." +"Turn a layer into a component, reuse it, and change every copy at once" = "Biến một lớp thành thành phần, tái sử dụng và cập nhật mọi bản sao cùng lúc" +"Two digits" = "Hai chữ số" +"Type" = "Loại" +"Type settings" = "Thiết lập kiểu chữ" +"Type the join code you were given, or paste an invite link." = "Nhập mã tham gia bạn được cấp, hoặc dán liên kết mời." +"Typography" = "Kiểu chữ" +"Underline" = "Gạch chân" +"Undo takes back only your own changes; what others changed since stays." = "Hoàn tác chỉ quay lại các thay đổi của riêng bạn; những gì người khác đã thay đổi vẫn giữ nguyên." +"Ungroup" = "Bỏ nhóm" +"Uniform" = "Đồng đều" +"Union" = "Hợp" +"Unions" = "Phép hợp" +"Unknown — treated as sRGB" = "Không rõ — được xử lý như sRGB" +"Unread" = "Chưa đọc" +"Unresolve" = "Mở lại bình luận" +"Unsaved changes" = "Thay đổi chưa lưu" +"Unsaved changes in {names}. Close the window and lose them?" = "Có thay đổi chưa lưu trong {names}. Đóng cửa sổ và hủy bỏ các thay đổi này?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Có thay đổi chưa lưu: hãy lưu trước ({save}), hoặc giữ {alt} khi nhấp đóng thẻ để hủy bỏ" +"Unsaved changes: save first, or close again with {key} to discard" = "Có thay đổi chưa lưu: hãy lưu trước, hoặc đóng lại lần nữa bằng {key} để hủy bỏ" +"Up" = "Lên" +"Update" = "Cập nhật" +"Update from main" = "Cập nhật từ nhánh chính" +"Update from selection" = "Cập nhật từ vùng chọn" +"Update plugin" = "Cập nhật plugin" +"Updated {components} and {styles}" = "Đã cập nhật {components} và {styles}" +"Updated {name}" = "Đã cập nhật {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Việc cập nhật trình điều khiển đồ họa thường sẽ khắc phục được lỗi. Để chỉ sử dụng OpenGL, hãy khởi chạy FigCraft từ Command Prompt bằng:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Việc cập nhật trình điều khiển đồ họa thường sẽ khắc phục được lỗi. Biến WGPU_BACKEND (vulkan, gl, metal) cho phép thử một API đồ họa khác." +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Tải tệp này lên máy chủ FigCraft và giữ thẻ này đồng bộ với máy chủ. Bạn sẽ nhận được một liên kết chỉnh sửa và một liên kết xem." +"Uppercase" = "Chữ hoa" +"Use a fixed value" = "Dùng giá trị cố định" +"Use an expression, e.g. count + 1" = "Dùng biểu thức, ví dụ: count + 1" +"Use as mask" = "Dùng làm mặt nạ" +"Use auto layout" = "Dùng bố cục tự động" +"Use number keys for opacity" = "Dùng phím số để chỉnh độ mờ" +"Use the .figcraft or .fig extension." = "Hãy dùng phần mở rộng .figcraft hoặc .fig." +"Use the layer's fill" = "Dùng màu tô của lớp" +"Use your browser's zoom to change the interface size." = "Hãy dùng tính năng thu phóng của trình duyệt để thay đổi kích thước giao diện." +"Used in {files} · {instances}" = "Được dùng trong {files} · {instances}" +"Username" = "Tên người dùng" +"Uses variables: each layer shows them in its own mode" = "Có sử dụng biến: mỗi lớp hiển thị giá trị theo chế độ riêng của lớp đó" +"Value" = "Giá trị" +"Variable" = "Biến" +"Variable collections" = "Bộ sưu tập biến" +"Variable modes" = "Chế độ biến" +"Variable width" = "Độ rộng biến thiên" +"Variables" = "Các biến" +"Variables JSON…" = "JSON biến…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Biến lưu trữ các giá trị tái sử dụng (màu sắc, số, chuỗi, boolean) theo các chế độ như Sáng và Tối." +"Variant" = "Biến thể" +"Vector" = "Vector" +"Vectors" = "Vector" +"Version" = "Phiên bản" +"Version history" = "Lịch sử phiên bản" +"Version {version}" = "Phiên bản {version}" +"Version {v} · {components} · {styles} · {variables}" = "Phiên bản {v} · {components} · {styles} · {variables}" +"Vertical" = "Dọc" +"Vertical align" = "Căn dọc" +"Vertical hex" = "Lục giác dọc" +"Vertical padding" = "Đệm dọc" +"Vertical spacing" = "Khoảng cách dọc" +"Vertical trim" = "Cắt gọn khoảng đệm dọc" +"View" = "Xem" +"View link" = "Liên kết xem" +"View link copied" = "Đã sao chép liên kết xem" +"View only" = "Chỉ xem" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Chỉ xem: bạn thấy mọi thay đổi theo thời gian thực nhưng không thể chỉnh sửa. Hãy rời phiên để giữ lại một bản sao có thể chỉnh sửa." +"Viewing “{name}”" = "Đang xem “{name}”" +"Violet" = "Tím" +"Visibility" = "Hiển thị" +"Visible" = "Hiển thị" +"Vivid" = "Rực rỡ" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Biến WGPU_BACKEND đang được đặt để chỉ định API đồ họa; hãy bỏ đặt biến này để FigCraft tự chọn." +"Warm" = "Ấm" +"Watch" = "Đồng hồ" +"Wavy" = "Lượn sóng" +"Web" = "Web" +"WebSocket URL" = "URL WebSocket" +"WebSocket URL copied" = "Đã sao chép URL WebSocket" +"Websites" = "Trang web" +"Wedge" = "Hình nêm" +"Weight" = "Độ dày" +"What changed since it was marked ready for dev" = "Những thay đổi kể từ khi được đánh dấu sẵn sàng cho dev" +"What is wrong with this file? The server's operator gets your report." = "Tệp này có vấn đề gì? Người vận hành máy chủ sẽ nhận được báo cáo của bạn." +"What it's for" = "Mục đích sử dụng" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Không rõ trạng thái trước khi thay đổi: khung đã thay đổi trước khi FigCraft lưu bản sao của các khung sẵn sàng cho dev. Hãy giải quyết thay đổi (hoặc đánh dấu sẵn sàng lại) để so sánh từ nay về sau." +"What you try here changes this preview and the code below, not the file" = "Những gì bạn thử tại đây chỉ làm thay đổi bản xem trước này và đoạn mã bên dưới, không thay đổi tệp" +"While hovering" = "Khi di chuột qua" +"While pressing" = "Khi nhấn giữ" +"White" = "Trắng" +"Who runs it, its terms and how long it keeps files" = "Đơn vị vận hành, điều khoản và thời gian lưu giữ tệp" +"Why it changed" = "Lý do thay đổi" +"Widget" = "Tiện ích" +"Widgets" = "Các tiện ích" +"Width" = "Chiều rộng" +"Width point" = "Điểm độ rộng" +"Width profile" = "Biên dạng độ rộng" +"Wireframe kit" = "Bộ khung dây (Wireframe)" +"With the layers' export settings" = "Theo thiết lập xuất của các lớp" +"Wrap" = "Ngắt dòng" +"Wrap in new section" = "Bọc trong phần mới" +"Yellow" = "Vàng" +"Yesterday" = "Hôm qua" +"You" = "Bạn" +"You'll get the shared file as a new tab and edit it together." = "Bạn sẽ nhận được tệp chia sẻ trong một thẻ mới và cùng nhau chỉnh sửa." +"You're spotlighting: everyone follows your view" = "Bạn đang chiếu tâm điểm: mọi người đang theo dõi khung nhìn của bạn" +"Your copy of the file stays open" = "Bản sao tệp của bạn vẫn mở" +"Your copy stays open; the file stays on the server" = "Bản sao của bạn vẫn mở; tệp vẫn được lưu trên máy chủ" +"Your name" = "Tên của bạn" +"Your name in comments" = "Tên của bạn trong bình luận" +"Your system asks for reduced motion: animations stay off." = "Hệ thống của bạn yêu cầu giảm chuyển động: các hoạt ảnh sẽ được tắt." +"Z" = "Z" +"Z to A" = "Z đến A" +"Zoom" = "Thu phóng" +"Zoom around the pointer" = "Thu phóng quanh con trỏ" +"Zoom device to fill window" = "Thu phóng thiết bị lấp đầy cửa sổ" +"Zoom in" = "Phóng to" +"Zoom out" = "Thu nhỏ" +"Zoom to 100%" = "Thu phóng về 100%" +"Zoom to 200%" = "Thu phóng về 200%" +"Zoom to 50%" = "Thu phóng về 50%" +"Zoom to fit" = "Thu phóng vừa khít" +"Zoom to selection" = "Thu phóng vừa vùng chọn" +"Zoom tool: click to zoom in, drag an area" = "Công cụ thu phóng: nhấp để phóng to, kéo chọn vùng" +"Zoom tool: zoom out" = "Công cụ thu phóng: thu nhỏ" +"and {n} more" = { other = "và {n} mục nữa" } +"bleed marks" = "dấu tràn lề" +"by {author}" = "bởi {author}" +"call any command its access allows" = "gọi bất kỳ lệnh nào mà quyền truy cập cho phép" +"call only: {commands}" = "chỉ gọi: {commands}" +"can edit the document" = "có thể chỉnh sửa tài liệu" +"conditional prototype actions nested too deeply were skipped" = "đã bỏ qua các hành động nguyên mẫu có điều kiện lồng quá sâu" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "đã bỏ qua các hành động nguyên mẫu có điều kiện không thể tính giá trị" +"connector label background was skipped: style data is missing" = "đã bỏ qua nền nhãn đường nối: thiếu dữ liệu kiểu" +"connector label was skipped: background bounds are invalid" = "đã bỏ qua nhãn đường nối: khung giới hạn nền không hợp lệ" +"connector label was skipped: background transform is invalid" = "đã bỏ qua nhãn đường nối: phép biến đổi nền không hợp lệ" +"connector label was skipped: composed text transform is invalid" = "đã bỏ qua nhãn đường nối: phép biến đổi văn bản tổng hợp không hợp lệ" +"connector label was skipped: derived background bounds are missing" = "đã bỏ qua nhãn đường nối: thiếu khung giới hạn nền suy dẫn" +"connector label was skipped: derived text bounds are missing" = "đã bỏ qua nhãn đường nối: thiếu khung giới hạn văn bản suy dẫn" +"connector label was skipped: text bounds are invalid" = "đã bỏ qua nhãn đường nối: khung giới hạn văn bản không hợp lệ" +"connector label was skipped: text style data is missing" = "đã bỏ qua nhãn đường nối: thiếu dữ liệu kiểu văn bản" +"connector label was skipped: text transform is invalid" = "đã bỏ qua nhãn đường nối: phép biến đổi văn bản không hợp lệ" +"connector labels were skipped" = "đã bỏ qua các nhãn đường nối" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "các lớp đường nối được nhập dưới dạng khung/vector tĩnh kèm theo nhãn nếu có; liên kết động ở hai đầu mút không được giữ lại" +"connector snapshot vector data is missing; connector was left empty" = "thiếu dữ liệu vector tĩnh của đường nối; đường nối được để trống" +"connectors whose ends can't be placed are empty" = "các đường nối không thể đặt hai đầu mút sẽ được để trống" +"crop marks" = "dấu xén" +"damaged vector geometry was skipped" = "đã bỏ qua hình học vector bị hỏng" +"damaged vector networks import as outlines" = "mạng vector bị hỏng được nhập dưới dạng đường viền" +"development: {path}" = "phát triển: {path}" +"document children other than pages were skipped" = "đã bỏ qua các mục con của tài liệu không phải là trang" +"duplicate grid track sizing entries use equal tracks" = "các mục kích thước dải lưới trùng lặp sẽ dùng kích thước dải bằng nhau" +"e.g. count + 1" = "ví dụ: count + 1" +"e.g. count >= 3 and not done" = "ví dụ: count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "các đường nối gấp khúc và cong không có đường dẫn đã lưu được nhập thành đường thẳng" +"entries without a node type were skipped" = "đã bỏ qua các mục không có loại nút" +"figcraft:// links from a browser, chat or email open this app and join the session." = "Liên kết figcraft:// từ trình duyệt, ứng dụng trò chuyện hoặc email sẽ mở ứng dụng này và tham gia phiên." +"filled areas of a vector with their own fill use the layer's fill" = "các vùng tô riêng trong một vector sẽ dùng màu tô của lớp" +"from {server}" = "từ {server}" +"gamepad triggers without exactly one known controller button were skipped" = "đã bỏ qua các kích hoạt tay cầm không có đúng một nút điều khiển đã biết" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "danh sách dải lưới vượt quá 1000 mục; các dải sẽ dùng kích thước đều nhau mặc định" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "kích thước dải lưới không được hỗ trợ hoặc sai định dạng; các dải bị ảnh hưởng sẽ dùng kích thước đều nhau" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "danh sách kích thước dải lưới vượt quá 1000 mục; các dải sẽ dùng kích thước đều nhau mặc định" +"hidden" = "đã ẩn" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "bộ lọc hình ảnh không rõ ràng do có cả hai vùng chứa bộ lọc; đã bỏ qua chuyển đổi bộ lọc" +"image filters use FigCraft's approximate renderer" = "bộ lọc hình ảnh sử dụng bộ kết xuất xấp xỉ của FigCraft" +"instance swap properties whose default component isn't in the file were skipped" = "đã bỏ qua các thuộc tính hoán đổi bản thể có thành phần mặc định không nằm trong tệp" +"instances inside their own main component were imported as frames" = "các bản thể nằm bên trong chính thành phần chính của chúng đã được nhập thành khung" +"instances of components that aren't in the file show no layers" = "các bản thể của thành phần không có trong tệp sẽ không hiển thị lớp nào" +"instances of components that aren't in the file were imported as frames" = "các bản thể của thành phần không có trong tệp đã được nhập thành khung" +"instances that would nest a component inside itself were imported as frames" = "các bản thể gây lồng thành phần vào chính nó đã được nhập thành khung" +"instances without a main component were imported as empty frames" = "các bản thể không có thành phần chính đã được nhập thành khung trống" +"invalid internal .fig text path data was ignored" = "đã bỏ qua dữ liệu đường dẫn văn bản .fig nội bộ không hợp lệ" +"invalid or oversized vector networks import as outlines" = "mạng vector không hợp lệ hoặc quá lớn được nhập dưới dạng đường viền" +"invalid or oversized vector path data was skipped" = "đã bỏ qua dữ liệu đường dẫn vector không hợp lệ hoặc quá lớn" +"just now" = "vừa xong" +"key triggers without a supported key were skipped" = "đã bỏ qua các kích hoạt phím không có phím được hỗ trợ" +"key up triggers import as key presses" = "kích hoạt nhả phím được nhập thành nhấn phím" +"layers without a position were placed at their parent's origin" = "các lớp không có vị trí đã được đặt tại gốc tọa độ của lớp cha" +"layers without a type were skipped" = "đã bỏ qua các lớp không có loại" +"legacy image filter container is unsupported; filter conversion was skipped" = "vùng chứa bộ lọc hình ảnh kiểu cũ không được hỗ trợ; đã bỏ qua chuyển đổi bộ lọc" +"locked" = "đã khóa" +"malformed image filters container was skipped" = "đã bỏ qua vùng chứa bộ lọc hình ảnh bị lỗi định dạng" +"malformed image paintFilter container was skipped" = "đã bỏ qua vùng chứa paintFilter hình ảnh bị lỗi định dạng" +"malformed slot content assignment was skipped" = "đã bỏ qua phép gán nội dung ô chứa bị lỗi định dạng" +"no bleed" = "không tràn lề" +"nobody else here yet" = "chưa có ai khác ở đây" +"non-default slot settings are not represented and were skipped" = "các thiết lập ô chứa không mặc định không được biểu diễn và đã bị bỏ qua" +"now" = "bây giờ" +"only in a text style or variable" = "chỉ trong kiểu văn bản hoặc biến" +"or" = "hoặc" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "khớp nối nét tại điểm được giữ lại; nét đứt hoặc nét có độ rộng biến thiên sẽ kết xuất theo khớp nối của lớp" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "không thể phân giải khóa thành phần ưu tiên của ô chứa thành nút trong tệp nên đã bỏ qua" +"prototype actions of unknown types were skipped" = "đã bỏ qua các hành động nguyên mẫu thuộc loại không xác định" +"prototype actions without a destination in the file were skipped" = "đã bỏ qua các hành động nguyên mẫu không có đích đến trong tệp" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "đã bỏ qua các biểu thức nguyên mẫu dùng biến không có trong JSON biến" +"prototype expressions with unsupported functions were skipped" = "đã bỏ qua các biểu thức nguyên mẫu có hàm không được hỗ trợ" +"prototype interactions without supported actions were skipped" = "đã bỏ qua các tương tác nguyên mẫu không có hành động được hỗ trợ" +"prototype navigations of unknown types were skipped" = "đã bỏ qua các điều hướng nguyên mẫu thuộc loại không xác định" +"prototype transitions of unknown types play instantly" = "các chuyển cảnh nguyên mẫu thuộc loại không xác định sẽ phát tức thì" +"prototype triggers of unknown types were skipped" = "đã bỏ qua các kích hoạt nguyên mẫu thuộc loại không xác định" +"put a widget on the canvas" = "đặt một tiện ích lên vùng vẽ" +"read and edit the document" = "đọc và chỉnh sửa tài liệu" +"read the document" = "đọc tài liệu" +"reads the document" = "đọc tài liệu" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "đã bỏ qua các hành động đặt biến trên biến không có trong JSON biến" +"set-variable actions with values that can't be evaluated were skipped" = "đã bỏ qua các hành động đặt biến có giá trị không thể tính toán" +"set-variable actions with values that don't fit the variable were skipped" = "đã bỏ qua các hành động đặt biến có giá trị không khớp kiểu biến" +"set-variable actions without a value were skipped" = "đã bỏ qua các hành động đặt biến không có giá trị" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "đã bỏ qua các hành động đặt chế độ biến trên bộ sưu tập hoặc chế độ không có trong JSON biến" +"slot content assignment target is missing or is not slot content" = "đích gán nội dung ô chứa bị thiếu hoặc không phải là nội dung ô chứa" +"slot content assignment uses the empty GUID sentinel and was skipped" = "phép gán nội dung ô chứa dùng GUID rỗng nên đã bị bỏ qua" +"slot layers without a matching slot property were imported as frames" = "các lớp ô chứa không có thuộc tính ô chứa tương ứng đã được nhập thành khung" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "một số điểm vector tham chiếu đến thiết lập điểm bị thiếu trong tệp; chúng sẽ dùng khớp nối của lớp và góc thường" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "một số đoạn vector tham chiếu đến thiết lập kiểu bị thiếu trong tệp; chúng sẽ dùng nét của lớp" +"stale or invalid grid track sizing entries were ignored" = "đã bỏ qua các mục kích thước dải lưới cũ hoặc không hợp lệ" +"stars and polygons without point counts or geometry use the default point count" = "ngôi sao và đa giác không có số đỉnh hoặc hình học sẽ dùng số đỉnh mặc định" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "thiết lập kiểu của một số đoạn vector không được hỗ trợ; các đoạn đó sẽ dùng nét của lớp" +"text path underline or strikethrough decoration is not rendered" = "không kết xuất gạch chân hoặc gạch ngang chữ đối với văn bản trên đường dẫn" +"text style overrides refer to missing table entries" = "các ghi đè kiểu văn bản tham chiếu đến mục bảng bị thiếu" +"the file has no pages" = "tệp không có trang nào" +"the plugin" = "plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "JSON biến không có `variableCollections` (cần phản hồi biến cục bộ) nên đã bị bỏ qua" +"this session only" = "chỉ trong phiên này" +"unknown component property types were skipped" = "đã bỏ qua các loại thuộc tính thành phần không xác định" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "đã bỏ qua các bí danh biến trỏ tới biến không có trong JSON biến (hoặc khác kiểu)" +"variable bindings to properties the layer doesn't have were skipped" = "đã bỏ qua các gắn kết biến vào thuộc tính mà lớp không có" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "đã bỏ qua các gắn kết biến trỏ tới biến không có trong JSON biến (biến thư viện)" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "đã bỏ qua các gắn kết biến: JSON tệp không có định nghĩa biến (hãy truyền JSON biến cục bộ vào `variables`, hoặc nhúng `meta` của nó cạnh JSON tệp)" +"variable bindings whose variable type doesn't fit the property were skipped" = "đã bỏ qua các gắn kết biến có kiểu biến không phù hợp với thuộc tính" +"variable collections without modes were skipped" = "đã bỏ qua các bộ sưu tập biến không có chế độ" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "đã bỏ qua các tra cứu chế độ biến trong biểu thức nguyên mẫu không phải là một biến và một chế độ của nó" +"variable modes of collections that aren't in the file were skipped" = "đã bỏ qua các chế độ biến của bộ sưu tập không có trong tệp" +"variable modes of collections that aren't in the variables JSON were skipped" = "đã bỏ qua các chế độ biến của bộ sưu tập không có trong JSON biến" +"variable values that don't match their type (or composed colours) were skipped" = "đã bỏ qua các giá trị biến không khớp kiểu (hoặc màu tổng hợp)" +"variables of unknown types were skipped" = "đã bỏ qua các biến thuộc kiểu không xác định" +"variables whose collection isn't in the variables JSON were skipped" = "đã bỏ qua các biến có bộ sưu tập không nằm trong JSON biến" +"variables without readable values were skipped" = "đã bỏ qua các biến không có giá trị đọc được" +"vector points with unknown stroke joins use the layer join" = "các điểm vector có khớp nối nét không xác định sẽ dùng khớp nối của lớp" +"vectors without geometry are empty" = "các vector không có dữ liệu hình học sẽ để trống" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "các vector không có dữ liệu hình học sẽ để trống (hãy xuất JSON tệp với geometry=paths)" +"very long text was cut to 1,048,576 characters" = "văn bản quá dài đã được cắt còn 1.048.576 ký tự" +"very long vector paths were skipped" = "đã bỏ qua các đường dẫn vector quá dài" +"video actions are not supported and were skipped" = "hành động video không được hỗ trợ và đã bị bỏ qua" +"video triggers are not supported and were skipped" = "kích hoạt video không được hỗ trợ và đã bị bỏ qua" +"yesterday" = "hôm qua" +"{instances} in {files}" = "{instances} trong {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: bản dịch nháp, đang chờ người bản ngữ hiệu đính." +"{length} bleed" = "tràn lề {length}" +"{month} {day}, {year}" = "{day} {month}, {year}" +"{name}\nClick to apply to the selection" = "{name}\nNhấp để áp dụng cho vùng chọn" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nKéo vào vùng vẽ, hoặc nhấp để đặt ở giữa khung nhìn.\nGiữ Alt (Option) khi thả vào một bản thể để hoán đổi." +"{name} (from {library})" = "{name} (từ {library})" +"{name} (you)" = "{name} (bạn)" +"{name} ({layers} layers)" = "{name} ({layers} lớp)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, mặc định {default}. Nhấp đúp vào thanh trượt để quay lại giá trị của kiểu; nhấn Escape khi đang kéo để hủy." +"{name} cleared from the list" = "Đã xóa {name} khỏi danh sách" +"{name} from {path}" = "{name} từ {path}" +"{name} is forgotten on this computer; the file stays on {server}" = "Đã quên {name} trên máy tính này; tệp vẫn còn trên {server}" +"{name} is on the server." = "{name} đang ở trên máy chủ." +"{name} is spotlighting" = "{name} đang chiếu tâm điểm" +"{name} is too large to open in the browser (over 512 MB)." = "{name} quá lớn để mở trong trình duyệt (trên 512 MB)." +"{name} was removed from the server" = "Đã xóa {name} khỏi máy chủ" +"{name} · deleted on main" = "{name} · đã xóa trên nhánh chính" +"{name} · deleted on the branch" = "{name} · đã xóa trên nhánh" +"{name} · not published" = "{name} · chưa xuất bản" +"{name} · on a server" = "{name} · trên máy chủ" +"{name} · published version {v}" = "{name} · đã xuất bản phiên bản {v}" +"{name} · version {v} on the server" = "{name} · phiên bản {v} trên máy chủ" +"{name}, in a live session" = "{name}, trong phiên trực tiếp" +"{name}, online, open only to you" = "{name}, trực tuyến, chỉ mình bạn mở được" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, đã chọn" +"{n} added" = { other = "{n} đã thêm" } +"{n} autosave versions" = { other = "{n} phiên bản tự động lưu" } +"{n} changed" = { other = "{n} đã thay đổi" } +"{n} columns" = { other = "{n} cột" } +"{n} components" = { other = "{n} thành phần" } +"{n} components and styles have newer versions" = { other = "{n} thành phần và kiểu có phiên bản mới hơn" } +"{n} conflicts to resolve" = { other = "{n} xung đột cần giải quyết" } +"{n} days ago" = { other = "{n} ngày trước" } +"{n} files" = { other = "{n} tệp" } +"{n} hours ago" = { other = "{n} giờ trước" } +"{n} instances" = { other = "{n} bản thể" } +"{n} layers" = { other = "{n} lớp" } +"{n} layers as one file" = { other = "{n} lớp thành một tệp" } +"{n} layers selected" = { other = "Đã chọn {n} lớp" } +"{n} layers · {parts}" = { other = "{n} lớp · {parts}" } +"{n} matches" = { other = "{n} kết quả khớp" } +"{n} minutes ago" = { other = "{n} phút trước" } +"{n} months ago" = { other = "{n} tháng trước" } +"{n} more notes omitted from this report" = { other = "{n} ghi chú khác đã được lược bớt khỏi báo cáo này" } +"{n} of {total}" = "{n} / {total}" +"{n} people joined" = { other = "{n} người đã tham gia" } +"{n} preferred components" = { other = "{n} thành phần ưu tiên" } +"{n} ready" = { other = "{n} sẵn sàng" } +"{n} removed" = { other = "{n} đã xóa" } +"{n} replies" = { other = "{n} phản hồi" } +"{n} rows" = { other = "{n} hàng" } +"{n} styles" = { other = "{n} kiểu" } +"{n} text layers" = { other = "{n} lớp văn bản" } +"{n} variables" = { other = "{n} biến" } +"{n} weeks ago" = { other = "{n} tuần trước" } +"{n} years ago" = { other = "{n} năm trước" } +"{n}d" = "{n}ng" +"{n}h" = "{n}g" +"{n}m" = "{n}ph" +"{n}w" = "{n}t" +"{page}, nothing selected" = "{page}, chưa chọn gì" +"{percent}%" = "{percent}%" +"{profile} (current)" = "{profile} (hiện tại)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} là giá trị thuộc tính; {property ? \"a\" : \"b\"} chọn một chuỗi. Các {…} khác được giữ nguyên như đã viết." +"{server} can't say what became of your files (it is older than this app)." = "{server} không thể cho biết trạng thái các tệp của bạn (máy chủ cũ hơn ứng dụng này)." +"{server} · No longer on the server" = "{server} · Không còn trên máy chủ" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Đã mở {date}" +"{tool} hold {key}" = "{tool} giữ {key}" +"{value} isn't a colour" = "{value} không phải là một màu" +"{when} · edited" = "{when} · đã chỉnh sửa" +"←" = "←" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"→, Space or N" = "→, Space hoặc N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ mũi tên" +"⌘ click" = "⌘ nhấp" +"⌘ scroll" = "⌘ cuộn" +"⌥ drag" = "⌥ kéo" + +[commands] +"align.bottom" = "Căn dưới" +"align.hcenter" = "Căn tâm ngang" +"align.left" = "Căn trái" +"align.right" = "Căn phải" +"align.top" = "Căn trên" +"align.vcenter" = "Căn tâm dọc" +"annotation.add" = "Thêm chú thích" +"annotation.addCategory" = "Thêm danh mục chú thích" +"annotation.categories" = "Danh mục chú thích" +"annotation.edit" = "Chỉnh sửa chú thích" +"annotation.editCategory" = "Chỉnh sửa danh mục chú thích" +"annotation.list" = "Danh sách chú thích" +"annotation.remove" = "Xóa chú thích" +"annotation.removeCategory" = "Xóa danh mục chú thích" +"app.actions" = "Hành động" +"app.preferences" = "Tùy chọn" +"arrange.back" = "Đưa xuống dưới cùng" +"arrange.backward" = "Đưa lùi một lớp" +"arrange.forward" = "Đưa tiến một lớp" +"arrange.front" = "Đưa lên trên cùng" +"arrange.packH" = "Xếp sát ngang" +"arrange.packV" = "Xếp sát dọc" +"arrange.reorder" = "Sắp xếp lại vùng chọn" +"arrange.roundToPixels" = "Làm tròn theo pixel" +"arrange.spacing" = "Khoảng cách" +"arrange.tidy" = "Sắp xếp gọn" +"bool.exclude" = "Loại trừ vùng chọn" +"bool.flatten" = "Làm phẳng nhóm boolean" +"bool.intersect" = "Giao vùng chọn" +"bool.subtract" = "Trừ vùng chọn" +"bool.union" = "Hợp vùng chọn" +"branch.archive" = "Lưu trữ nhánh" +"branch.create" = "Tạo nhánh" +"branch.list" = "Danh sách nhánh" +"branch.merge" = "Hợp nhất nhánh" +"branch.open" = "Mở nhánh" +"branch.review" = "Xem xét nhánh" +"branch.updateFromMain" = "Cập nhật từ nhánh chính" +"codeConnect.export" = "Xuất kết nối mã" +"codeConnect.import" = "Nhập kết nối mã" +"codeConnect.remove" = "Ngắt kết nối mã" +"codeConnect.set" = "Kết nối với mã" +"collab.cursor" = "Hiện con trỏ tại" +"collab.cursorChat" = "Trò chuyện con trỏ" +"collab.diff" = "Hiện thay đổi đồng bộ" +"collab.files" = "Tệp trực tuyến của bạn" +"collab.follow" = "Theo dõi" +"collab.forgetFile" = "Quên tệp trực tuyến" +"collab.host" = "Bắt đầu phiên trực tiếp" +"collab.join" = "Tham gia phiên trực tiếp" +"collab.leave" = "Rời phiên trực tiếp" +"collab.manageFile" = "Quản lý tệp đã xuất bản" +"collab.openFile" = "Mở tệp trực tuyến" +"collab.peers" = "Những người trong tệp này" +"collab.publish" = "Xuất bản lên máy chủ" +"collab.recoverFile" = "Khôi phục tệp trực tuyến" +"collab.registerLinks" = "Mở liên kết mời trong FigCraft" +"collab.removePerson" = "Xóa khỏi phiên" +"collab.report" = "Báo cáo tệp cho người vận hành máy chủ" +"collab.serverInfo" = "Thông tin máy chủ" +"collab.share" = "Chia sẻ bằng mã tham gia" +"collab.spotlight" = "Chiếu tâm điểm vào tôi" +"collab.stop" = "Dừng phiên trực tiếp" +"command.batch" = "Chạy nhiều lệnh" +"comment.add" = "Thêm bình luận" +"comment.delete" = "Xóa bình luận" +"comment.edit" = "Chỉnh sửa bình luận" +"comment.link" = "Sao chép liên kết đến bình luận" +"comment.list" = "Danh sách bình luận" +"comment.markRead" = "Đánh dấu bình luận đã đọc" +"comment.move" = "Di chuyển bình luận" +"comment.next" = "Bình luận tiếp theo" +"comment.open" = "Mở bình luận" +"comment.people" = "Người trong bình luận" +"comment.react" = "Thả cảm xúc cho bình luận" +"comment.reply" = "Trả lời bình luận" +"comment.resolve" = "Giải quyết bình luận" +"comment.zoom" = "Thu phóng đến bình luận" +"component.addProperty" = "Thêm thuộc tính thành phần" +"component.addVariant" = "Thêm biến thể" +"component.bindProperty" = "Gắn lớp vào thuộc tính" +"component.combineVariants" = "Kết hợp thành các biến thể" +"component.create" = "Tạo thành phần" +"component.createMultiple" = "Tạo nhiều thành phần" +"component.deleteProperty" = "Xóa thuộc tính thành phần" +"component.detach" = "Tách bản thể" +"component.editProperty" = "Chỉnh sửa thuộc tính thành phần" +"component.exposeInstance" = "Hiển thị bản thể lồng nhau" +"component.goToMain" = "Đi tới thành phần chính" +"component.hideWhenPublishing" = "Ẩn khi xuất bản" +"component.list" = "Danh sách thành phần" +"component.makeSlot" = "Tạo ô chứa" +"component.playground" = "Sân thử nghiệm thành phần" +"component.properties" = "Thuộc tính thành phần" +"component.pushChanges" = "Đẩy thay đổi lên thành phần chính" +"component.removeSlot" = "Xóa ô chứa" +"component.resetInstance" = "Đặt lại mọi thay đổi" +"component.resetOverride" = "Đặt lại ghi đè" +"component.restoreMain" = "Khôi phục thành phần chính" +"dev.compare" = "So sánh thay đổi" +"dev.focus" = "Chế độ xem tập trung" +"dev.resolveChange" = "Giải quyết thay đổi" +"dev.unfocus" = "Thoát chế độ xem tập trung" +"devResource.add" = "Thêm tài nguyên dev" +"devResource.edit" = "Chỉnh sửa tài nguyên dev" +"devResource.list" = "Danh sách tài nguyên dev" +"devResource.remove" = "Xóa tài nguyên dev" +"distribute.horizontal" = "Phân bổ đều khoảng cách ngang" +"distribute.vertical" = "Phân bổ đều khoảng cách dọc" +"doc.home" = "Trang chủ" +"doc.list" = "Danh sách tài liệu" +"doc.nextTab" = "Thẻ tiếp theo" +"doc.previousTab" = "Thẻ trước" +"doc.switch" = "Chuyển tài liệu" +"edit.copy" = "Sao chép" +"edit.copyAsCss" = "Sao chép dưới dạng CSS" +"edit.copyAsPng" = "Sao chép dưới dạng PNG" +"edit.copyAsSvg" = "Sao chép dưới dạng SVG" +"edit.copyAsText" = "Sao chép dưới dạng văn bản" +"edit.copyLink" = "Sao chép liên kết đến vùng chọn" +"edit.copyProperties" = "Sao chép thuộc tính" +"edit.cut" = "Cắt" +"edit.delete" = "Xóa" +"edit.duplicate" = "Nhân bản" +"edit.findReplace" = "Tìm và thay thế" +"edit.paste" = "Dán" +"edit.pasteDesignLayers" = "Dán các lớp thiết kế" +"edit.pasteOver" = "Dán đè lên vùng chọn" +"edit.pasteProperties" = "Dán thuộc tính" +"edit.pasteReplace" = "Dán để thay thế" +"edit.pickColor" = "Chọn màu" +"edit.redo" = "Làm lại" +"edit.repeat" = "Lặp lại lệnh cuối" +"edit.setDefaultProperties" = "Đặt thuộc tính mặc định" +"edit.undo" = "Hoàn tác" +"effect.add" = "Thêm hiệu ứng" +"effect.move" = "Di chuyển hiệu ứng" +"effect.remove" = "Xóa hiệu ứng" +"effect.set" = "Đặt hiệu ứng" +"export.add" = "Thêm thiết lập xuất" +"export.remove" = "Xóa thiết lập xuất" +"export.update" = "Chỉnh sửa thiết lập xuất" +"file.close" = "Đóng thẻ" +"file.colorProfile" = "Hồ sơ màu" +"file.duplicate" = "Nhân bản tệp" +"file.export" = "Xuất" +"file.exportFramesPdf" = "Xuất các khung ra PDF" +"file.exportLayers" = "Xuất các lớp" +"file.exportSvg" = "Xuất vùng chọn ra SVG" +"file.import" = "Nhập" +"file.moveTab" = "Di chuyển thẻ" +"file.new" = "Tệp thiết kế mới" +"file.newFromTemplate" = "Mới từ mẫu" +"file.open" = "Mở" +"file.openRecent" = "Mở tệp gần đây" +"file.placeImage" = "Đặt hình ảnh" +"file.recover" = "Khôi phục tệp" +"file.rename" = "Đổi tên tệp" +"file.revert" = "Hoàn nguyên về bản đã lưu" +"file.save" = "Lưu" +"file.saveAs" = "Lưu thành" +"file.saveVersion" = "Lưu vào lịch sử phiên bản" +"file.versionHistory" = "Hiện lịch sử phiên bản" +"fill.add" = "Thêm màu tô" +"fill.move" = "Di chuyển màu tô" +"fill.pattern" = "Tô họa tiết" +"fill.remove" = "Xóa màu tô" +"fill.set" = "Đặt màu tô" +"frame.create" = "Tạo khung" +"frame.presets" = "Danh sách kích thước khung dựng sẵn" +"frame.swapOrientation" = "Đổi chiều ngang/dọc" +"grid.add" = "Thêm lưới bố cục" +"grid.move" = "Di chuyển lưới bố cục" +"grid.remove" = "Xóa lưới bố cục" +"guide.add" = "Thêm đường gióng" +"guide.clear" = "Xóa mọi đường gióng" +"guide.move" = "Di chuyển đường gióng" +"guide.remove" = "Xóa đường gióng" +"guide.select" = "Chọn đường gióng" +"help.about" = "Giới thiệu về FigCraft" +"help.gettingStarted" = "Bắt đầu" +"help.page" = "Trang trợ giúp" +"help.shortcuts" = "Phím tắt" +"image.adjust" = "Điều chỉnh hình ảnh" +"image.deletePreset" = "Xóa thiết lập sẵn hình ảnh" +"image.place" = "Đặt hình ảnh" +"image.presets" = "Thiết lập sẵn hình ảnh" +"image.savePreset" = "Lưu thiết lập sẵn hình ảnh" +"inspect.assets" = "Kiểm tra tài nguyên" +"inspect.code" = "Mã cho lớp" +"inspect.componentCode" = "Mã thành phần" +"inspect.properties" = "Kiểm tra thuộc tính" +"inspect.readyForDev" = "Sẵn sàng cho dev" +"instance.create" = "Tạo bản thể" +"instance.exposed" = "Các bản thể lồng nhau được hiển thị" +"instance.place" = "Đặt bản thể" +"instance.resetSlot" = "Đặt lại ô chứa" +"instance.setProperties" = "Đặt thuộc tính bản thể" +"instance.slotInsert" = "Chèn vào ô chứa" +"instance.swap" = "Hoán đổi bản thể" +"instance.swapAt" = "Hoán đổi bản thể tại điểm" +"layers.collapseAll" = "Thu gọn các lớp" +"layout.add" = "Thêm bố cục tự động" +"layout.remove" = "Xóa bố cục tự động" +"layout.setTrack" = "Đặt kích thước dải lưới" +"layout.suggest" = "Gợi ý bố cục tự động" +"libraries.open" = "Thư viện" +"libraries.publish" = "Xuất bản thư viện…" +"library.acceptUpdates" = "Chấp nhận cập nhật thư viện" +"library.add" = "Thêm thư viện" +"library.aliasVariable" = "Tạo bí danh biến thư viện" +"library.applyStyle" = "Áp dụng kiểu thư viện" +"library.bindVariable" = "Gắn biến thư viện" +"library.extendCollection" = "Mở rộng bộ sưu tập thư viện" +"library.import" = "Nhập thành phần thư viện" +"library.importVariable" = "Nhập biến thư viện" +"library.list" = "Danh sách thư viện" +"library.place" = "Đặt thành phần thư viện" +"library.publish" = "Xuất bản thư viện" +"library.publishToServer" = "Xuất bản thư viện lên máy chủ" +"library.remove" = "Xóa thư viện" +"library.setVariableMode" = "Đặt chế độ biến thư viện" +"library.swap" = "Hoán đổi thư viện" +"library.swapAt" = "Hoán đổi bản thể sang thành phần thư viện" +"library.updates" = "Cập nhật thư viện" +"library.usage" = "Mức sử dụng thư viện" +"measure.distances" = "Đo khoảng cách" +"measure.padding" = "Đo khoảng đệm bố cục tự động" +"node.create" = "Tạo lớp" +"node.get" = "Lấy lớp" +"node.reorder" = "Sắp xếp lại lớp" +"node.reparent" = "Di chuyển lớp vào trong" +"node.set" = "Đặt thuộc tính" +"object.batchRename" = "Đổi tên hàng loạt" +"object.convertToFrame" = "Chuyển thành khung" +"object.convertToGroup" = "Chuyển thành nhóm" +"object.convertToSection" = "Chuyển thành phần" +"object.flipH" = "Lật ngang" +"object.flipV" = "Lật dọc" +"object.frameSelection" = "Đóng khung vùng chọn" +"object.group" = "Nhóm vùng chọn" +"object.hideOthers" = "Ẩn các lớp khác" +"object.mask" = "Dùng làm mặt nạ" +"object.moveToPage" = "Di chuyển sang trang" +"object.opacityKeys" = "Độ mờ từ phím số" +"object.rasterize" = "Điểm ảnh hóa vùng chọn" +"object.releaseMask" = "Bỏ mặt nạ" +"object.removeFill" = "Xóa màu tô" +"object.removeFrame" = "Bỏ khung" +"object.removeStroke" = "Xóa nét" +"object.rename" = "Đổi tên" +"object.resizeToFit" = "Đổi kích thước vừa khít" +"object.rotate180" = "Xoay 180°" +"object.rotateLeft" = "Xoay 90° sang trái" +"object.rotateRight" = "Xoay 90° sang phải" +"object.setThumbnail" = "Đặt làm ảnh thu nhỏ" +"object.swapFillStroke" = "Đổi chỗ màu tô và nét" +"object.toggleLock" = "Khóa/Mở khóa vùng chọn" +"object.toggleVisible" = "Hiện/Ẩn vùng chọn" +"object.ungroup" = "Bỏ nhóm vùng chọn" +"object.wrapInSection" = "Bọc trong phần mới" +"page.add" = "Thêm trang" +"page.delete" = "Xóa trang" +"page.duplicate" = "Nhân bản trang" +"page.list" = "Danh sách trang" +"page.move" = "Di chuyển trang" +"page.rename" = "Đổi tên trang" +"page.select" = "Đi tới trang" +"page.setBackground" = "Màu nền trang" +"paint.documentColors" = "Màu trong tài liệu" +"plugin.close" = "Đóng bảng plugin" +"plugin.directory.install" = "Cài đặt plugin từ thư mục" +"plugin.directory.list" = "Duyệt thư mục plugin" +"plugin.directory.publish" = "Xuất bản plugin lên thư mục" +"plugin.directory.unpublish" = "Gỡ xuất bản plugin khỏi thư mục" +"plugin.event" = "Dùng điều khiển bảng plugin" +"plugin.import" = "Nhập plugin từ tệp kê khai…" +"plugin.list" = "Danh sách plugin" +"plugin.remove" = "Gỡ plugin" +"plugin.run" = "Chạy plugin" +"plugin.ui" = "Bảng plugin" +"plugins.manage" = "Quản lý plugin…" +"plugins.runLast" = "Chạy plugin vừa dùng" +"pref.autoSave" = "Tự động lưu tệp" +"pref.highlightHover" = "Làm nổi bật lớp khi di chuột qua" +"pref.keepTool" = "Giữ công cụ được chọn sau khi dùng" +"pref.language" = "Ngôn ngữ giao diện" +"pref.nudge" = "Mức dịch chuyển nhẹ" +"pref.numberKeysOpacity" = "Dùng phím số để chỉnh độ mờ" +"pref.publishServer" = "Máy chủ chia sẻ trực tuyến" +"pref.reduceMotion" = "Giảm chuyển động" +"pref.renameDuplicates" = "Đổi tên các lớp nhân bản" +"pref.showDimensions" = "Hiện kích thước trên đối tượng" +"pref.startOnHome" = "Khởi động tại màn hình Trang chủ" +"pref.uiScale" = "Kích thước giao diện" +"pref.userName" = "Tên của bạn trong bình luận" +"proto.action.add" = "Thêm hành động nguyên mẫu" +"proto.action.move" = "Sắp xếp lại hành động nguyên mẫu" +"proto.action.remove" = "Xóa hành động nguyên mẫu" +"proto.action.set" = "Chỉnh sửa hành động nguyên mẫu" +"proto.closePreview" = "Đóng xem trước nguyên mẫu" +"proto.conditional.addBlock" = "Thêm nhánh else if / else" +"proto.conditional.removeBlock" = "Xóa nhánh if / else if / else" +"proto.conditional.setCondition" = "Chỉnh sửa điều kiện" +"proto.evaluate" = "Tính biểu thức nguyên mẫu" +"proto.flow.add" = "Thêm điểm bắt đầu luồng" +"proto.flow.remove" = "Xóa luồng" +"proto.flow.rename" = "Đổi tên luồng" +"proto.interaction.add" = "Thêm tương tác" +"proto.interaction.clear" = "Xóa mọi tương tác" +"proto.interaction.remove" = "Xóa tương tác" +"proto.interaction.set" = "Chỉnh sửa tương tác" +"proto.present" = "Trình chiếu" +"proto.presentOptions" = "Tùy chọn trình chiếu" +"proto.preview" = "Mở xem trước nguyên mẫu" +"proto.settings" = "Thiết lập nguyên mẫu" +"section.toggleContents" = "Ẩn/Hiện nội dung phần" +"select.add" = "Thêm vào vùng chọn" +"select.all" = "Chọn tất cả" +"select.area" = "Chọn vùng" +"select.byName" = "Chọn lớp theo tên" +"select.children" = "Chọn các lớp con" +"select.inverse" = "Đảo ngược vùng chọn" +"select.layersAt" = "Các lớp tại một điểm" +"select.matching" = "Chọn các lớp tương ứng" +"select.next" = "Chọn lớp đồng cấp tiếp theo" +"select.none" = "Bỏ chọn tất cả" +"select.parent" = "Chọn lớp cha" +"select.previous" = "Chọn lớp đồng cấp trước đó" +"select.remove" = "Bỏ khỏi vùng chọn" +"select.sameEffect" = "Chọn cùng hiệu ứng" +"select.sameFill" = "Chọn cùng màu tô" +"select.sameFont" = "Chọn cùng phông chữ" +"select.sameInstance" = "Chọn cùng bản thể" +"select.sameProperties" = "Chọn cùng thuộc tính" +"select.sameStroke" = "Chọn cùng nét" +"select.sameText" = "Chọn cùng thuộc tính văn bản" +"select.set" = "Chọn" +"selection.applyColorStyle" = "Áp dụng kiểu màu cho vùng chọn" +"selection.colors" = "Màu của vùng chọn" +"selection.selectColor" = "Chọn các lớp có màu này" +"selection.setPaint" = "Đổi màu vùng chọn" +"shape.arrow" = "Mũi tên" +"shape.ellipse" = "Hình elip" +"shape.line" = "Đường thẳng" +"shape.polygon" = "Đa giác" +"shape.rectangle" = "Hình chữ nhật" +"shape.star" = "Ngôi sao" +"shapeBuilder.merge" = "Hợp nhất các vùng" +"shapeBuilder.mode" = "Chế độ tạo hình" +"shapeBuilder.regions" = "Các vùng tạo hình" +"shapeBuilder.remove" = "Xóa các vùng" +"stroke.add" = "Thêm nét" +"stroke.brush" = "Cọ vẽ" +"stroke.move" = "Di chuyển nét" +"stroke.remove" = "Xóa nét" +"stroke.set" = "Đặt nét" +"stroke.widthProfile" = "Biên dạng độ rộng" +"style.apply" = "Áp dụng kiểu" +"style.bind" = "Gắn biến trong kiểu" +"style.create" = "Tạo kiểu" +"style.delete" = "Xóa kiểu" +"style.detach" = "Tách kiểu" +"style.duplicate" = "Nhân bản kiểu" +"style.list" = "Danh sách kiểu" +"style.move" = "Di chuyển kiểu" +"style.renameFolder" = "Đổi tên thư mục kiểu" +"style.update" = "Chỉnh sửa kiểu" +"template.list" = "Danh sách mẫu" +"text.alignCenter" = "Căn giữa" +"text.alignJustify" = "Căn đều" +"text.alignLeft" = "Căn trái" +"text.alignRight" = "Căn phải" +"text.bold" = "Đậm" +"text.bulletList" = "Danh sách dấu đầu dòng" +"text.case" = "Kiểu chữ hoa/thường" +"text.caseLower" = "Chữ thường" +"text.caseOriginal" = "Giữ nguyên chữ hoa/thường" +"text.caseSmallCaps" = "Chữ hoa nhỏ" +"text.caseTitle" = "Viết hoa đầu từ" +"text.caseUpper" = "Chữ hoa" +"text.create" = "Văn bản" +"text.decoration" = "Trang trí chữ" +"text.edit.begin" = "Chỉnh sửa văn bản" +"text.edit.caret" = "Đặt con trỏ văn bản" +"text.edit.end" = "Dừng chỉnh sửa văn bản" +"text.edit.insert" = "Nhập văn bản" +"text.edit.key" = "Phím văn bản" +"text.edit.preedit" = "Soạn văn bản (IME)" +"text.edit.selectLine" = "Chọn dòng" +"text.edit.selectWord" = "Chọn từ" +"text.edit.selection" = "Vùng chọn văn bản" +"text.endPreview" = "Kết thúc xem trước văn bản" +"text.fonts" = "Danh sách phông chữ" +"text.italic" = "Nghiêng" +"text.lineHeightDown" = "Giảm chiều cao dòng" +"text.lineHeightUp" = "Tăng chiều cao dòng" +"text.link" = "Tạo liên kết" +"text.listIndent" = "Thụt lề danh sách" +"text.listOutdent" = "Nhô lề danh sách" +"text.listStyle" = "Kiểu danh sách" +"text.loadFont" = "Tải tệp phông chữ" +"text.maxLines" = "Số dòng tối đa" +"text.missingFonts" = "Phông chữ bị thiếu" +"text.numberList" = "Danh sách đánh số" +"text.onPath" = "Văn bản trên đường dẫn" +"text.paragraphIndent" = "Thụt lề đoạn văn" +"text.paragraphSpacing" = "Khoảng cách đoạn văn" +"text.pathOptions" = "Tùy chọn đường dẫn văn bản" +"text.preview" = "Xem trước thay đổi văn bản" +"text.refit" = "Khớp lại văn bản theo phông chữ" +"text.removeFromPath" = "Gỡ văn bản khỏi đường dẫn" +"text.rescanFonts" = "Quét lại phông chữ đã cài đặt" +"text.set" = "Đặt văn bản" +"text.setFeatures" = "Tính năng OpenType" +"text.setVariations" = "Trục phông chữ biến thiên" +"text.sizeDown" = "Giảm cỡ chữ" +"text.sizeUp" = "Tăng cỡ chữ" +"text.strikethrough" = "Gạch ngang chữ" +"text.style" = "Định kiểu đoạn văn bản" +"text.trackingDown" = "Giảm khoảng cách chữ" +"text.trackingUp" = "Tăng khoảng cách chữ" +"text.underline" = "Gạch chân" +"text.weightDown" = "Giảm độ đậm chữ" +"text.weightUp" = "Tăng độ đậm chữ" +"tool.arrow" = "Mũi tên" +"tool.comment" = "Bình luận" +"tool.ellipse" = "Hình elip" +"tool.frame" = "Khung" +"tool.hand" = "Bàn tay" +"tool.line" = "Đường thẳng" +"tool.move" = "Di chuyển" +"tool.pen" = "Bút" +"tool.pencil" = "Bút chì" +"tool.pencilBrush" = "Cọ bút chì" +"tool.place" = "Đặt bằng công cụ" +"tool.polygon" = "Đa giác" +"tool.rectangle" = "Hình chữ nhật" +"tool.scale" = "Tỷ lệ" +"tool.scaleAnchor" = "Điểm neo tỷ lệ" +"tool.section" = "Phần" +"tool.set" = "Chọn công cụ" +"tool.shapeBuilder" = "Tạo hình" +"tool.slice" = "Lát cắt" +"tool.star" = "Ngôi sao" +"tool.text" = "Văn bản" +"transform.move" = "Di chuyển" +"transform.moveTo" = "Di chuyển tới" +"transform.nudge" = "Dịch chuyển nhẹ" +"transform.nudgeSize" = "Đổi kích thước theo bước dịch nhẹ" +"transform.resize" = "Đổi kích thước" +"transform.rotate" = "Xoay" +"transform.rotationOrigin" = "Di chuyển tâm xoay" +"transform.scale" = "Tỷ lệ" +"transform.setRotationOrigin" = "Đặt tâm xoay" +"variable.addMode" = "Thêm chế độ" +"variable.bind" = "Gắn biến" +"variable.create" = "Tạo biến" +"variable.createCollection" = "Tạo bộ sưu tập biến" +"variable.delete" = "Xóa biến" +"variable.deleteCollection" = "Xóa bộ sưu tập" +"variable.duplicate" = "Nhân bản biến" +"variable.export" = "Xuất các biến" +"variable.import" = "Nhập các biến" +"variable.list" = "Danh sách biến" +"variable.moveMode" = "Di chuyển chế độ" +"variable.removeMode" = "Xóa chế độ" +"variable.renameCollection" = "Đổi tên bộ sưu tập" +"variable.renameMode" = "Đổi tên chế độ" +"variable.resetOverride" = "Đặt lại ghi đè biến" +"variable.set" = "Đặt giá trị biến" +"variable.setMode" = "Đặt chế độ biến" +"variable.update" = "Chỉnh sửa biến" +"vector.create" = "Vector" +"vector.deleteHeal" = "Xóa và nối liền vùng chọn" +"vector.edit.addVertex" = "Thêm điểm" +"vector.edit.begin" = "Chỉnh sửa vector" +"vector.edit.bend" = "Uốn đoạn" +"vector.edit.bounds" = "Khung giới hạn các điểm đã chọn" +"vector.edit.connect" = "Nối các điểm" +"vector.edit.cornerRadius" = "Bán kính góc của điểm" +"vector.edit.cut" = "Cắt đường dẫn" +"vector.edit.delete" = "Xóa các điểm" +"vector.edit.end" = "Hoàn tất chỉnh sửa" +"vector.edit.erase" = "Tẩy xóa" +"vector.edit.lasso" = "Chọn bằng dây quây" +"vector.edit.mirroring" = "Đối xứng tay nắm" +"vector.edit.moveHandles" = "Di chuyển tay nắm" +"vector.edit.moveVertices" = "Di chuyển các điểm" +"vector.edit.nudge" = "Dịch nhẹ các điểm" +"vector.edit.paint" = "Thùng sơn" +"vector.edit.pen" = "Điểm bút" +"vector.edit.penEnd" = "Kết thúc đường dẫn bút" +"vector.edit.position" = "Vị trí điểm" +"vector.edit.resize" = "Đổi kích thước các điểm" +"vector.edit.rotate" = "Xoay các điểm" +"vector.edit.select" = "Chọn các điểm" +"vector.edit.selectAll" = "Chọn tất cả các điểm" +"vector.edit.setHandle" = "Đặt tay nắm" +"vector.edit.smooth" = "Làm mượt hoặc làm nhọn điểm" +"vector.edit.state" = "Trạng thái chỉnh sửa vector" +"vector.edit.strokeCap" = "Đầu mút nét tại điểm" +"vector.edit.strokeJoin" = "Khớp nối nét tại điểm" +"vector.edit.tool" = "Công cụ chỉnh sửa vector" +"vector.edit.transform" = "Biến đổi các điểm" +"vector.edit.widthPoint" = "Điểm độ rộng" +"vector.flatten" = "Làm phẳng" +"vector.join" = "Nối vùng chọn" +"vector.offset" = "Đường dẫn lệch" +"vector.outlineStroke" = "Chuyển nét thành đường viền" +"vector.reverse" = "Đảo chiều đường dẫn" +"vector.simplify" = "Đơn giản hóa vector" +"vector.smoothJoin" = "Nối mượt vùng chọn" +"version.autosave" = "Phiên bản tự động lưu" +"version.delete" = "Xóa phiên bản" +"version.duplicate" = "Nhân bản phiên bản" +"version.exitPreview" = "Quay lại phiên bản hiện tại" +"version.list" = "Danh sách phiên bản" +"version.preview" = "Xem phiên bản" +"version.rename" = "Đổi tên phiên bản" +"version.restore" = "Khôi phục phiên bản" +"view.assets" = "Bảng Tài nguyên" +"view.comments" = "Bình luận" +"view.contextMenu" = "Hiện trình đơn ngữ cảnh" +"view.design" = "Bảng Thiết kế" +"view.designPrototype" = "Chuyển đổi giữa Thiết kế và Nguyên mẫu" +"view.devMode" = "Dev Mode" +"view.frameOutlines" = "Đường viền khung" +"view.fullscreen" = "Toàn màn hình" +"view.get" = "Lấy khung nhìn" +"view.layers" = "Bảng Các lớp" +"view.layoutGrids" = "Lưới bố cục" +"view.leftSidebar" = "Hiện/Ẩn thanh bên trái" +"view.maskOutlines" = "Đường viền mặt nạ" +"view.multiplayerCursors" = "Con trỏ nhiều người dùng" +"view.nextFrame" = "Thu phóng đến khung tiếp theo" +"view.nextPage" = "Trang tiếp theo" +"view.nextRegion" = "Chuyển tiêu điểm sang vùng tiếp theo" +"view.outlines" = "Đường viền nét" +"view.outlinesHiddenLayers" = "Bao gồm các lớp ẩn trong đường viền nét" +"view.outlinesObjectBounds" = "Bao gồm khung giới hạn đối tượng trong đường viền nét" +"view.outlinesSelection" = "Đường viền nét cho vùng chọn" +"view.pan" = "Di chuyển khung nhìn" +"view.panelWidths" = "Đặt lại độ rộng bảng" +"view.pixelGrid" = "Lưới pixel" +"view.pixelPreview" = "Xem trước pixel" +"view.prevFrame" = "Thu phóng đến khung trước" +"view.prevPage" = "Trang trước" +"view.previousRegion" = "Chuyển tiêu điểm về vùng trước" +"view.proofSrgb" = "Kiểm thử mềm sRGB" +"view.prototype" = "Bảng Nguyên mẫu" +"view.rulers" = "Thước" +"view.set" = "Đặt khung nhìn" +"view.slices" = "Lát cắt" +"view.snapGeometry" = "Bắt dính vào hình học" +"view.snapObjects" = "Bắt dính vào đối tượng" +"view.snapPixel" = "Bắt dính vào lưới pixel" +"view.themeDark" = "Giao diện tối" +"view.themeLight" = "Giao diện sáng" +"view.themeSystem" = "Theo giao diện hệ thống" +"view.toggleUi" = "Hiện/Ẩn giao diện" +"view.zoom100" = "Thu phóng về 100%" +"view.zoom200" = "Thu phóng về 200%" +"view.zoom50" = "Thu phóng về 50%" +"view.zoomAt" = "Công cụ thu phóng" +"view.zoomFit" = "Thu phóng vừa khít" +"view.zoomIn" = "Phóng to" +"view.zoomOut" = "Thu nhỏ" +"view.zoomSelection" = "Thu phóng vừa vùng chọn" +"widget.click" = "Nhấp tiện ích" +"widget.insert" = "Chèn tiện ích" +"widget.list" = "Danh sách tiện ích" +"window.focus" = "Đưa cửa sổ lên trước" +"window.new" = "Cửa sổ mới" + +[menu] +"bool.exclude" = "Loại trừ" +"bool.flatten" = "Làm phẳng" +"bool.intersect" = "Giao nhau" +"bool.subtract" = "Trừ" +"bool.union" = "Hợp" +"file.duplicate" = "Nhân bản" +"file.import" = "Nhập từ tệp…" +"file.rename" = "Đổi tên" +"libraries.open" = "Mở thư viện" +"select.sameEffect" = "Hiệu ứng" +"select.sameFill" = "Màu tô" +"select.sameFont" = "Phông chữ" +"select.sameInstance" = "Bản thể" +"select.sameProperties" = "Thuộc tính" +"select.sameStroke" = "Nét" +"select.sameText" = "Thuộc tính văn bản" +"text.alignCenter" = "Căn giữa văn bản" +"text.alignJustify" = "Căn đều văn bản" +"text.alignLeft" = "Căn trái văn bản" +"text.alignRight" = "Căn phải văn bản" +"view.assets" = "Tài nguyên" +"view.design" = "Thiết kế" +"view.layers" = "Các lớp" +"view.outlines" = "Hiện đường viền nét" +"view.outlinesHiddenLayers" = "Bao gồm các lớp ẩn" +"view.outlinesObjectBounds" = "Bao gồm khung giới hạn đối tượng" +"view.outlinesSelection" = "Hiện đường viền nét cho vùng chọn" +"view.prototype" = "Nguyên mẫu" +"view.themeDark" = "Tối" +"view.themeLight" = "Sáng" +"view.themeSystem" = "Theo hệ thống" diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index 35323efe..88f5e516 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -157,7 +157,7 @@ mod tests { assert_eq!(language("zh-Hans"), HAN); assert_eq!(language("bn"), BENGALI); assert_eq!(language("fr"), 0); - assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id"].iter().all(|c| can_show(c))); + assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id", "vi"].iter().all(|c| can_show(c))); } #[test] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 9faff001..871f66cf 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -176,7 +176,7 @@ fn plural(code: &str, n: usize) -> Plural { "ru" if n % 10 == 1 && n % 100 != 11 => Plural::One, "ru" | "pl" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, "ru" | "pl" => Plural::Many, - "ja" | "ko" | "zh-Hans" => Plural::Other, + "ja" | "ko" | "zh-Hans" | "vi" => Plural::Other, "fr" | "pt-BR" if n <= 1 => Plural::One, _ if n == 1 => Plural::One, _ => Plural::Other, @@ -187,7 +187,7 @@ fn plural(code: &str, n: usize) -> Plural { // "other" wording. Czech supplies separate few forms; Russian and Polish supply few/many forms. /// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in -/// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules). +/// Japanese, Korean, Chinese and Vietnamese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules). #[cfg(test)] fn plural_one(lang: usize, n: usize) -> bool { plural(LANGS.get(lang).map_or("en", |l| l.code), n) == Plural::One diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 046212c1..85b006ff 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -349,6 +349,7 @@ fn system_languages_pick_the_first_one_with_a_catalog() { assert_eq!(code("system", Some("pt-BR")), "pt-BR"); assert_eq!(code("system", Some("tr_TR.UTF-8")), "tr"); assert_eq!(code("system", Some("id_ID.UTF-8")), "id"); + assert_eq!(code("system", Some("vi_VN.UTF-8")), "vi"); // Portugal's Portuguese and a bare "pt" read the Brazilian catalog. assert_eq!(code("system", Some("pt-PT")), "pt-BR"); assert_eq!(code("pt", None), "pt-BR"); @@ -417,12 +418,15 @@ fn french_and_portuguese_count_zero_as_singular() { } #[test] -fn japanese_korean_and_chinese_have_no_plural_forms() { +fn japanese_korean_chinese_and_vietnamese_have_no_plural_forms() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; assert_eq!(code("system", Some("ja-JP")), "ja"); assert_eq!(code("system", Some("ko_KR.UTF-8")), "ko"); + assert_eq!(code("system", Some("vi-VN")), "vi"); assert_eq!(code("system", Some("ja,en-US")), "ja"); - for code in ["ja", "ko", "zh-Hans"] { + assert_eq!(LANGS[resolve("vi", None)].name, "Tiếng Việt"); + assert!(LANGS[resolve("vi", None)].draft); + for code in ["ja", "ko", "zh-Hans", "vi"] { let lang = LANGS.iter().position(|l| l.code == code).unwrap(); assert!(!plural_one(lang, 0) && !plural_one(lang, 1) && !plural_one(lang, 2), "{code}"); let ones: Vec<_> = LANGS[lang].ui.iter().filter(|(_, t)| matches!(t, Text::Plural { one: Some(_), .. })).map(|(k, _)| k).collect(); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 50fcd4ec..05596fa3 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -4,7 +4,7 @@ FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian -Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts (machine-drafted, +Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Czech drafts (machine-drafted, waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key languages of the craftrules standard, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). @@ -32,7 +32,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, then each catalog by its own name: English, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語, - 한국어, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a + 한국어, Português (Brasil), Русский, Svenska, Türkçe, Tiếng Việt, 简体中文), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (on Windows only the first preferred language counts, since that list also holds the languages added for a keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional @@ -53,7 +53,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v - **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules (`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian - Portuguese take the singular for 0 as well; Japanese, Korean and Chinese have one form; + Portuguese take the singular for 0 as well; Japanese, Korean, Chinese and Vietnamese have one form; CLDR's "many" for millions in French, Spanish and Portuguese reads as the plural). - **Script support in documents:** Unicode bidi with Arabic shaping and mirrored brackets in text layers; right-to-left list markers and indents; right-to-left names in the layers panel, tabs @@ -96,7 +96,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Japanese | ja | 2,254 / 2,272 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | German | de | 2,176 / 2,272 (95.8%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | | Korean | ko | 2,166 / 2,272 (95.3%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | -| Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | +| Vietnamese | vi | 2,352 / 2,352 (100%, draft) | yes | Latin with stacked diacritics | no | draft | 1–2 (after native review) | Other languages (measured 2026-10-17): Russian (`ru`) 2,244 / 2,272 (98.8%), reviewed and approved by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,264 (99.6%), its command labels and menu @@ -113,8 +113,8 @@ right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h** every string adds). Priority: native reviews of the drafts and the es-419 overlay (every one of Figma's languages -and variants has one, plus Simplified Chinese, Indonesian and Czech, which Figma doesn't ship), then -Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users see English). +and variants has one, plus Simplified Chinese, Indonesian, Vietnamese and Czech, which Figma doesn't ship), then +Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see English). ## Revision history diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..d8fb52a7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -436,7 +436,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: - `pref.startOnHome {on?}`: the desktop app starts on the Home screen instead of in a new file (off by default; files named on the command line still open, and `--control` starts in a new file anyway, so control callers have a document). -- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system": +- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|vi|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. diff --git a/docs/translating.md b/docs/translating.md index eaa59755..73ee5136 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -17,7 +17,7 @@ Simplified. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish -(`fi`), Polish (`pl`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Georgian +(`fi`), Polish (`pl`), Bengali (`bn`) and Vietnamese (`vi`), machine-drafted and waiting for native speakers' review. Georgian (`ka`) was written with AI assistance and reviewed by a native speaker, and stays a draft until a maintainer changes its status. Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar @@ -61,7 +61,9 @@ formal Anda (never kamu), keeps established design terms in English (frame, laye instance, auto layout, plugin, file) and keeps a noun unchanged after a count, so its plural entries give only `other`. Georgian has no letter case (never write Mtavruli for capitals), uses verbal nouns for commands (გვერდის დამატება) and keeps a noun singular after a numeral, so counts read -"შრე: {n}" and its plural entries give the same text for `one` and `other`. +"შრე: {n}" and its plural entries give the same text for `one` and `other`. Vietnamese uses concise +imperatives for buttons and menu items (Lưu, Xóa) and keeps a noun unchanged after a count, so its +plural entries give only `other`. CJK text needs a CJK font, which egui and Inter lack: the desktop app adds an installed one when something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts, @@ -80,8 +82,8 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its set `[meta] name` and `status = "draft — needs native review"`. 2. Translate every value; keep the keys. Keep `{placeholders}` exactly, "…" where English has it, brand names and keyboard glyphs. English labels of commands: `figcraft-cli commands`. -3. Plurals: give `one` only if the language has a singular form (Japanese, Korean and Chinese give - only `other`). `other` is required; `one`, `few` and `many` are optional. `trn` picks `one` for +3. Plurals: give `one` only if the language has a singular form (Japanese, Korean, Chinese and + Vietnamese give only `other`). `other` is required; `one`, `few` and `many` are optional. `trn` picks `one` for 1 (also 0 in French and Brazilian Portuguese). Russian uses `one` for 1, 21, 31… except 11; `few` for 2–4, 22–24… except 12–14; and `many` for the other integers, including 0. Keep `{n}` in all Russian forms. Add new rules to `plural` in @@ -94,8 +96,8 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its 4. To require full coverage, add the code to `COMPLETE` in [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that adds a string then has to translate it into each complete language, so only Spanish is there: - French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and - Georgian are registered as drafts, Russian and Turkish are reviewed but not required, and a string added + French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, + Georgian and Vietnamese are registered as drafts, Russian and Turkish are reviewed but not required, and a string added later shows English in them until it is translated. To list what a draft lacks, add its code to `COMPLETE` locally and run the check. 5. Run the check and look at the panels in the app (longer words must fit the right panel): From d3a1538614ee897ce408b9b70fffb681e425e496 Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 13:00:27 +0800 Subject: [PATCH 062/208] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=9F=A2?= =?UTF-8?q?=E9=87=8F=E7=BC=96=E8=BE=91=E6=A8=A1=E5=BC=8F=E4=B8=8B=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E7=A9=BA=E7=94=BB=E5=B8=83=E7=9A=84=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/vector_behaviour_tests.rs | 4 ++- .../src/vector_multi_behaviour_tests.rs | 7 +++-- crates/engine/src/vectoredit/pointer.rs | 8 ++++-- crates/ui-egui/src/canvas.rs | 17 +++++++++--- .../ui-egui/src/canvas_double_click_tests.rs | 26 ++++++++++++++++--- docs/ui-parity.md | 3 ++- 6 files changed, 53 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 230d2998..60cf960e 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -94,7 +94,8 @@ fn entering_and_leaving_vector_edit_mode() { ("Enter", false, "Enter leaves edit mode"), ("Escape", false, "Escape leaves edit mode"), ("Escape with an open pen path", true, "Escape first ends the pen's path and stays in edit mode"), - ("click outside", false, "silent: a click away from the shape leaves edit mode (pinned)"), + ("click outside", true, "silent: a click away from the shape only clears the points (pinned)"), + ("double-click outside", false, "a double-click away from the shape leaves edit mode"), ]; for (how, after, why) in rows { let mut s = Session::new(); @@ -113,6 +114,7 @@ fn entering_and_leaving_vector_edit_mode() { key(&mut s, "Escape", false); assert!(editing(&s).is_some_and(|e| e.anchor.is_none()), "{why}: the path ended"); } + "double-click outside" => s.pointer(&ev(PointerKind::DoubleClick, (600.0, 600.0), NONE)).unwrap(), _ => click(&mut s, (600.0, 600.0), NONE), } assert_eq!(editing(&s).is_some(), after, "{why}"); diff --git a/crates/engine/src/vector_multi_behaviour_tests.rs b/crates/engine/src/vector_multi_behaviour_tests.rs index 1d2075b1..28559a48 100644 --- a/crates/engine/src/vector_multi_behaviour_tests.rs +++ b/crates/engine/src/vector_multi_behaviour_tests.rs @@ -203,13 +203,16 @@ fn editing_several_vectors_at_once() { assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (5, 4), "silent: the pen adds to the active layer (pinned)"); ex(&mut s, "vector.edit.penEnd", json!({})); ex(&mut s, "vector.edit.tool", json!({"tool": "move"})); - // A click on empty canvas inside an edited layer's area keeps edit mode; far away it ends. + // A click on empty canvas inside an edited layer's area keeps edit mode; so does one far + // away, which only clears the points (a double-click or Escape leaves). click(&mut s, (250.0, 50.0), NONE); assert!(editing(&s).is_some(), "a click inside an edited layer clears the points and stays"); let st = ex(&mut s, "vector.edit.state", json!({})); assert_eq!(st["layers"].as_array().map(Vec::len), Some(2), "the state lists every edited layer"); click(&mut s, (900.0, 900.0), NONE); - assert!(editing(&s).is_none(), "a click away from every edited layer leaves edit mode"); + assert!(editing(&s).is_some(), "a click away from every edited layer keeps edit mode"); + s.pointer(&ev(PointerKind::DoubleClick, (900.0, 900.0), NONE)).unwrap(); + assert!(editing(&s).is_none(), "a double-click away from every edited layer leaves edit mode"); } #[test] diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 1684bf26..4bf8477c 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -695,10 +695,14 @@ impl Session { } } None => { - // Outside every edited layer (and not shift): leave edit mode. + // A press on another layer (and not shift) leaves edit mode, so the normal tool + // takes it; empty canvas only clears the points and starts a marquee (a + // double-click or Escape leaves edit mode). let inside = ve.parts().iter().any(|p| doc.world_render_bounds(p.id).is_some_and(|b| b.inflate(tol * 4.0, tol * 4.0).contains(ev.p()))); - if !inside && !ev.shift { + let page = self.ds()?.page; + let other = !inside && self.hit(&doc, page, ev.p(), tol * 2.0).is_some(); + if other && !ev.shift { super::end(self)?; return Ok(false); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 841c95e8..fe9a0344 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -606,9 +606,20 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } // A third quick click is counted as a triple, not a double (a click elsewhere in between still // leaves its time in the sequence): it means the same here — edit, enter, open — unless a - // vector or text is being edited, where a triple already has its own meaning. - let editing = app.session.ds().is_ok_and(|d| d.vector_edit.is_some() || d.text_edit.is_some()); - if resp.double_clicked() || (resp.triple_clicked() && !editing) { + // vector or text is being edited, where a triple already has its own meaning. A triple on + // empty canvas still means a double in vector edit mode: the double-click that entered edit + // mode counts on, so leaving it right away would otherwise take a fourth click or a pause. + let ds = app.session.ds().ok(); + let editing = ds.is_some_and(|d| d.vector_edit.is_some() || d.text_edit.is_some()); + let empty_canvas = ds.is_some_and(|d| { + let Some(ve) = d.vector_edit.as_ref().filter(|v| v.anchor.is_none() && d.text_edit.is_none()) else { + return false; + }; + let p = app.to_page(pos); + let tol = 6.0 / d.view.zoom.max(1e-6); + ve.parts().iter().all(|part| d.doc.world_render_bounds(part.id).is_none_or(|b| !b.inflate(tol * 4.0, tol * 4.0).contains(p))) + }); + if resp.double_clicked() || (resp.triple_clicked() && (!editing || empty_canvas)) { let ev = pointer_event(app, PointerKind::DoubleClick, pos, &mods); if let Err(e) = app.session.pointer(&ev) { app.toast(e.to_string()); diff --git a/crates/ui-egui/src/canvas_double_click_tests.rs b/crates/ui-egui/src/canvas_double_click_tests.rs index c47faa74..72f91b76 100644 --- a/crates/ui-egui/src/canvas_double_click_tests.rs +++ b/crates/ui-egui/src/canvas_double_click_tests.rs @@ -85,7 +85,23 @@ fn double_clicking_an_unselected_shape_edits_its_points() { } #[test] -fn a_click_that_leaves_vector_edit_does_not_keep_the_next_double_click_out() { +fn a_double_click_on_empty_canvas_right_after_entering_leaves_vector_edit() { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); + let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + let shape = u.app.to_screen(250.0, 200.0); + let empty = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0); + // The double-click that enters edit mode, then straight away one on empty canvas. + double_click_at(&mut u, shape); + assert_eq!(u.app.session.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(r)); + double_click_at(&mut u, empty); + assert!(u.app.session.ds().unwrap().vector_edit.is_none(), "a double-click on empty canvas leaves vector edit at once"); +} + +#[test] +fn a_click_on_empty_canvas_keeps_vector_edit_until_a_double_click() { let mut s = Session::new(); let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 300, "height": 200})).unwrap(); let r: NodeId = figcraft_engine::params::id_of(&r["id"]).unwrap(); @@ -95,9 +111,13 @@ fn a_click_that_leaves_vector_edit_does_not_keep_the_next_double_click_out() { let empty = Pos2::new(u.app.canvas.rect.left() + 30.0, u.app.canvas.rect.bottom() - 30.0); double_click_at(&mut u, shape); assert!(u.app.session.ds().unwrap().vector_edit.is_some()); - // One click on empty canvas leaves the edit mode. + // One click on empty canvas only clears the points: edit mode stays. click(&mut u, empty); - assert!(u.app.session.ds().unwrap().vector_edit.is_none(), "a click outside leaves vector edit"); + assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a click on empty canvas keeps vector edit"); + // A double-click on empty canvas leaves it, the earlier click and the counts from entering + // included. + double_click_at(&mut u, empty); + assert!(u.app.session.ds().unwrap().vector_edit.is_none(), "a double-click on empty canvas leaves vector edit"); // The next double-click edits the shape again. double_click_at(&mut u, shape); assert!(u.app.session.ds().unwrap().vector_edit.is_some(), "a double-click edits it again"); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d87103f0..2355de89 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -82,7 +82,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). A click on empty canvas only clears the points and starts a marquee (edit mode stays; a double-click or Escape leaves it), while a click on another layer leaves it and selects that layer. Not yet: the transform box around points of several layers; areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | | Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Vector edit mode stays on a click on empty canvas: with the Move tool it only clears the points and starts a marquee, and a double-click (or Escape) leaves it; a click on another layer still leaves it and selects that layer. A double-click on empty canvas leaves it even straight after the one that entered edit mode (the platform counts those clicks on, so the last one arrives as a triple; on empty canvas a triple means a double here, while triples on the vector keep their own meaning) (`vector_behaviour_tests`, `canvas_double_click_tests`). Checked in the app on Windows with real mouse clicks over the control channel | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | From 80b9b491092bc21d8dae7d7251c9db0bafbd415e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=E1=BB=91ng=20Huy=20S=C6=A1n?= Date: Thu, 22 Oct 2026 12:02:08 +0700 Subject: [PATCH 063/208] docs(i18n): reconcile catalog entry counts to 2,352 in localization-parity.md --- docs/localization-parity.md | 40 ++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 05596fa3..86c09178 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Vietnamese draft catalog; string counts re-measured: 2,352 entries; macOS finds its downloadable CJK system fonts (#406); Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -18,16 +18,16 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6 Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text. -## What FigCraft has (measured 2026-10-17, from source) +## What FigCraft has (measured 2026-10-22, from source) - **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text (`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/few/many/other plural forms), so a missing translation shows English, never a raw key. One English text that needs two translations takes a context (`"Fill|sizing"`). -- **Strings:** 1,680 UI keys in `en.toml` (41 with plural forms; ~110 are labels from engine and - document data, such as tool names, blend modes and Dev Mode properties) plus 561 command labels - and 31 menu wordings by command id: 2,272 entries (counted against `en.toml` and the complete +- **Strings:** 1,747 UI keys in `en.toml` (42 with plural forms; ~110 are labels from engine and + document data, such as tool names, blend modes and Dev Mode properties) plus 574 command labels + and 31 menu wordings by command id: 2,352 entries (counted against `en.toml` and the complete Spanish catalog's command and menu tables). Command ids, the CLI, MCP and the control channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, then each catalog by its own name: @@ -48,8 +48,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v lacks, keys no code uses, unknown command ids, placeholders that differ from English, overlay entries that read as in their base language and, for complete languages (Spanish), anything untranslated. The other catalogs are not held to it: a string added later shows English there - until someone translates it. Every draft translated every string on 2026-10-12; by 2026-10-17 - the strings added since left them at 95–99% (table below). + until someone translates it. Every draft translated every string on 2026-10-12; by 2026-10-22 + the strings added since left them at 94–99% (table below). - **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules (`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian @@ -85,23 +85,23 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| -| English | en | 2,272 / 2,272 (100%, source) | yes | Latin | yes (source) | full | 0 | -| Simplified Chinese (Mandarin) | zh-Hans | 2,166 / 2,272 (95.3%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | -| Spanish | es, es-419 | 2,272 / 2,272 (100%, draft); es-419 overlays 101 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | +| English | en | 2,352 / 2,352 (100%, source) | yes | Latin | yes (source) | full | 0 | +| Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | +| Spanish | es, es-419 | 2,352 / 2,352 (100%, draft); es-419 overlays 104 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) | | Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 | | Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 | -| French | fr | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Portuguese | pt-BR | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Indonesian | id | 2,244 / 2,272 (98.8%, draft) | yes | Latin | no | draft | 1–2 (after native review) | -| Japanese | ja | 2,254 / 2,272 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | -| German | de | 2,176 / 2,272 (95.8%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | -| Korean | ko | 2,166 / 2,272 (95.3%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| French | fr | 2,328 / 2,352 (99.0%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Indonesian | id | 2,292 / 2,352 (97.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | +| Japanese | ja | 2,304 / 2,352 (98.0%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | +| German | de | 2,226 / 2,352 (94.6%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | +| Korean | ko | 2,216 / 2,352 (94.2%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 2,352 / 2,352 (100%, draft) | yes | Latin with stacked diacritics | no | draft | 1–2 (after native review) | -Other languages (measured 2026-10-17): Russian (`ru`) 2,244 / 2,272 (98.8%), reviewed and approved -by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,264 (99.6%), its command labels and menu -wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,243 (98.7%), whose header -says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,244 (98.8%), a draft +Other languages (measured 2026-10-22): Russian (`ru`) 2,352 / 2,352 (100%), reviewed and approved +by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,314 / 2,352 (98.4%), its command labels and menu +wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,291 / 2,352 (97.4%), whose header +says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,292 / 2,352 (97.4%), a draft with one/few/other plurals. Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries From 90004dc7cd1cb1ccaad971af75b81f72bbbcab62 Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 13:52:06 +0800 Subject: [PATCH 064/208] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A?= =?UTF-8?q?=E9=80=89=E7=9F=A2=E9=87=8F=E7=82=B9=E6=97=B6=E5=8F=98=E6=8D=A2?= =?UTF-8?q?=E6=A1=86=E7=9A=84=E4=BA=A4=E4=BA=92=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复了多选矢量点时,变换框角点会误判为可拖动的点的问题,同时更新了相关注释和测试用例,确保未被点占据的角点和边缘仍可用于缩放操作。 --- crates/engine/src/vectoredit/transform.rs | 8 ++- .../engine/src/vectoredit/transform_tests.rs | 55 +++++++++++++------ crates/ui-egui/src/canvas.rs | 4 +- docs/ui-parity.md | 2 +- 4 files changed, 48 insertions(+), 21 deletions(-) diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/vectoredit/transform.rs index 1d5f27a6..8ec9826a 100644 --- a/crates/engine/src/vectoredit/transform.rs +++ b/crates/engine/src/vectoredit/transform.rs @@ -85,14 +85,16 @@ pub enum BoxHit { } /// The box's part under page point `p` when it wins over `h`, the network's part there: Bezier -/// handles come first, then the box's corners, points, the box's edges, segments, the rotation -/// zones, and the inside of the box last. Edges along an axis without extent are left out. +/// handles come first, then points, the box's corners, its edges, segments and the rotation zones, +/// and the inside of the box last. A point under a corner or an edge is dragged with the others +/// (the box draws no handles of its own), so it keeps the box off. Edges along an axis without +/// extent are left out. pub fn box_hit(doc: &Document, ve: &VectorEdit, h: Option, p: Point, zoom: f64) -> Option { let b = shown_box(doc, ve)?; match handle_at_corners(&b.corners(), p, zoom, false) { Some(handle) => { let wins = match (handle, h) { - (_, Some(VHit::Handle { .. })) => false, + (_, Some(VHit::Handle { .. } | VHit::Vertex { .. })) => false, (Handle::Corner(_), _) => true, (Handle::Edge(e), None | Some(VHit::Segment { .. })) => { if e % 2 == 0 { diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/vectoredit/transform_tests.rs index d69baf52..c608c3aa 100644 --- a/crates/engine/src/vectoredit/transform_tests.rs +++ b/crates/engine/src/vectoredit/transform_tests.rs @@ -7,6 +7,7 @@ use super::tests::{click, drag, ev, ex, net, pt, rect, ve, vertex_at}; use super::transform::{BoxHit, box_hit, shown_box}; use super::*; use crate::EngineError; +use crate::params::id_of; use crate::tools::{Handle, PointerKind, ToolId}; /// Page position of point `v` of vector `id`. @@ -47,13 +48,15 @@ fn the_box_shows_for_two_or_more_points_with_the_move_tool() { let b = shown_box(s.doc().unwrap(), &ve(&s)).unwrap(); assert_eq!((b.width, b.height, b.points.len()), (100.0, 100.0, 4)); assert!(near(b.corners()[2], pt(100.0, 100.0))); - // Corners win over the points under them, points over edges, edges over segments. + // A point under a corner is dragged with the others, not scaled (the box draws no handles of + // its own), so it keeps the box off; corners and edges clear of points still scale, and edges + // win over segments. let doc = s.doc().unwrap().clone(); let at = |x, y| { let h = hit(&doc, &ve(&s), pt(x, y), 6.0); box_hit(&doc, &ve(&s), h, pt(x, y), 1.0) }; - assert_eq!(at(100.0, 100.0), Some(BoxHit::Handle(Handle::Corner(2)))); + assert_eq!(at(100.0, 100.0), None, "a point under the corner: a drag moves the points"); assert_eq!(at(100.0, 50.0), Some(BoxHit::Handle(Handle::Edge(1)))); assert_eq!(at(110.0, -10.0), Some(BoxHit::Handle(Handle::Rotate(1)))); assert_eq!(at(40.0, 60.0), Some(BoxHit::Inside)); @@ -64,27 +67,47 @@ fn the_box_shows_for_two_or_more_points_with_the_move_tool() { } #[test] -fn corners_scale_points_in_one_undo_step() { +fn corners_clear_of_points_scale_them_in_one_undo_step() { let mut s = Session::new(); - let id = rect(&mut s); + // An L of three points: the box's bottom-right corner (50, 50) has no point under it. + let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 0], [0, 50]], "closed": false}))["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); select_all(&mut s); - drag(&mut s, (100.0, 100.0), (150.0, 120.0)); - let b = s.doc().unwrap().world_bounds(id).unwrap(); - assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 0.0, 150.0, 120.0)); - assert_eq!(ve(&s).vertices.len(), 4, "the points stay selected"); + drag(&mut s, (50.0, 50.0), (150.0, 120.0)); + assert!(near(page_pt(&s, id, 1), pt(150.0, 0.0)) && near(page_pt(&s, id, 2), pt(0.0, 120.0)), "{:?}", page_pt(&s, id, 1)); + assert_eq!(ve(&s).vertices.len(), 3, "the points stay selected"); s.undo().unwrap(); - let b = s.doc().unwrap().world_bounds(id).unwrap(); - assert_eq!((b.width(), b.height()), (100.0, 100.0), "one undo step"); + assert!(near(page_pt(&s, id, 1), pt(50.0, 0.0)), "one undo step"); // Shift keeps proportions; Alt scales from the centre. select_all(&mut s); - drag_with(&mut s, (100.0, 100.0), (150.0, 110.0), true, false); - let b = s.doc().unwrap().world_bounds(id).unwrap(); - assert_eq!((b.x0, b.y0, b.x1, b.y1), (0.0, 0.0, 150.0, 150.0)); + drag_with(&mut s, (50.0, 50.0), (150.0, 110.0), true, false); + assert!(near(page_pt(&s, id, 1), pt(150.0, 0.0)) && near(page_pt(&s, id, 2), pt(0.0, 150.0)), "{:?}", page_pt(&s, id, 2)); s.undo().unwrap(); select_all(&mut s); - drag_with(&mut s, (100.0, 100.0), (110.0, 120.0), false, true); - let b = s.doc().unwrap().world_bounds(id).unwrap(); - assert_eq!((b.x0, b.y0, b.x1, b.y1), (-10.0, -20.0, 110.0, 120.0)); + drag_with(&mut s, (50.0, 50.0), (60.0, 70.0), false, true); + assert!( + near(page_pt(&s, id, 0), pt(-10.0, -20.0)) && near(page_pt(&s, id, 1), pt(60.0, -20.0)) && near(page_pt(&s, id, 2), pt(-10.0, 70.0)), + "{:?}", + page_pt(&s, id, 2) + ); +} + +#[test] +fn dragging_one_of_several_selected_points_moves_them_all_freely() { + let mut s = Session::new(); + let id = rect(&mut s); + let n = net(&s, id); + let (a, b) = (vertex_at(&n, 0.0, 0.0), vertex_at(&n, 100.0, 0.0)); + // Two points on one side: their box is a flat line, and the press is on a corner of it. + ex(&mut s, "vector.edit.select", json!({"vertices": [a, b]})); + drag(&mut s, (100.0, 0.0), (130.0, 40.0)); + assert!( + near(page_pt(&s, id, a), pt(30.0, 40.0)) && near(page_pt(&s, id, b), pt(130.0, 40.0)), + "{:?} {:?}", + page_pt(&s, id, a), + page_pt(&s, id, b) + ); + assert_eq!(ve(&s).vertices.len(), 2, "both stay selected"); } #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index fe9a0344..e6dbc3c8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1039,8 +1039,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if width_tool { width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { + // The box around several selected points draws no corner handles: they would read as + // points. Its corners and edges still scale the points when dragged. let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot())); - size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, Some(label)); + size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 0, Some(label)); } } // Selection. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2355de89..5421cd68 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -82,7 +82,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). A click on empty canvas only clears the points and starts a marquee (edit mode stays; a double-click or Escape leaves it), while a click on another layer leaves it and selects that layer. Not yet: the transform box around points of several layers; areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). A click on empty canvas only clears the points and starts a marquee (edit mode stays; a double-click or Escape leaves it), while a click on another layer leaves it and selects that layer. The box around several selected points draws its outline and size only, without corner squares (they would read as points); a press on any selected point, corners included, drags them all freely, while its corners and edges clear of points still scale by drag. Not yet: the transform box around points of several layers; areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | | Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | From b13e5c4ff3dee91fcff37607ed9348d5024f1a74 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Thu, 22 Oct 2026 09:00:37 +0200 Subject: [PATCH 065/208] M7: doc comments on the font scan's folders and scan_dir (#298) --- crates/text/src/fontdb.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index edfe5b60..32d34679 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -731,6 +731,7 @@ impl FontDb { } } +/// The folders this platform keeps installed and activated fonts in, walked by the font scan. fn system_font_dirs() -> Vec { #[allow(unused_mut)] let mut v: Vec = Vec::new(); @@ -794,6 +795,9 @@ fn unix_user_font_dirs(home: Option, data_home: Option)>) { if depth > 6 { return; From 7cb4168e13dca45817b31c4ab26d892dd82d8612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jindr=CC=8Cich=20S=CC=8Cars=CC=8Con?= Date: Thu, 22 Oct 2026 09:03:17 +0200 Subject: [PATCH 066/208] M6: Sketch import: fixes from the PR review - --no-system-fonts holds for .sketch imports too: the CLI opts the process out, so the import's font matching doesn't scan. - Shared text styles get the installed family and platform tracking their linked layers get. - Each text run writes its letter spacing, line height and font axes, neutral when it has none, instead of taking the first run's. - The unsupported-drop hint and its catalogs list .sketch. - A test sends damaged .sketch data through file.open and file.import. - Stack items are matched to their source layers through a map. - Docs: .sketch is a convert input; the Carbon kit's Sketch 47 edition is refused (the 68 and 83 ones open). Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-cli/src/main.rs | 3 ++ apps/figcraft-cli/tests/fonts.rs | 26 +++++++++++++ crates/engine/src/cmd/file.rs | 11 ++++++ crates/engine/src/sketch_import_tests.rs | 49 +++++++++++++++++++++++- crates/engine/src/tests.rs | 13 +++++++ crates/format/src/sketch/stack.rs | 11 ++++-- crates/format/src/sketch/text.rs | 12 +++--- crates/format/src/sketch/text_tests.rs | 21 ++++++++++ crates/text/src/fontdb.rs | 13 ++++++- crates/text/src/lib.rs | 4 +- crates/ui-egui/locales/bn.toml | 2 +- crates/ui-egui/locales/de.toml | 2 +- crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 2 +- crates/ui-egui/locales/fi.toml | 2 +- crates/ui-egui/locales/fr.toml | 2 +- crates/ui-egui/locales/ka.toml | 2 +- crates/ui-egui/locales/nl.toml | 2 +- crates/ui-egui/locales/pl.toml | 2 +- crates/ui-egui/locales/pt-BR.toml | 2 +- crates/ui-egui/locales/ru.toml | 2 +- crates/ui-egui/locales/sv.toml | 2 +- crates/ui-egui/src/home.rs | 2 +- crates/ui-egui/src/home_tests.rs | 2 +- docs/cli.md | 2 +- docs/file-format-parity.md | 4 +- 26 files changed, 166 insertions(+), 31 deletions(-) diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index badc5bc3..fb96cd85 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -108,6 +108,9 @@ fn run(argv: &[String]) -> CliResult { // Text lays out with the installed fonts, like the desktop app, so headless renders and // auto layout sizes match what users see. figcraft_text::scan_system_fonts_on_demand(); + } else { + // Nothing scans them then, not even a `.sketch` import matching its fonts. + figcraft_text::opt_out_of_system_fonts(); } let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) }; match cmd.as_str() { diff --git a/apps/figcraft-cli/tests/fonts.rs b/apps/figcraft-cli/tests/fonts.rs index ef5c638f..e01dac10 100644 --- a/apps/figcraft-cli/tests/fonts.rs +++ b/apps/figcraft-cli/tests/fonts.rs @@ -67,3 +67,29 @@ fn info_lists_the_fonts_a_file_uses_that_are_missing() { let shown = String::from_utf8_lossy(&out.stdout); assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}"); } + +/// A synthetic `.sketch` file: one page with one Helvetica text. +const SKETCH: &str = "UEsDBBQAAAAIAOFFVl2B8d55RwAAAFcAAAANAAAAZG9jdW1lbnQuanNvbqtWik/OSSwuVrJSUErJTy7NTc0rUdJRUCpITE8FCUZXIynwDfYK9vdzy8xJDUpNSy1KzUtOBamNL0pNA0mD9egHKNXG1gIAUEsDBBQAAAAIAOFFVl0B3thgPQAAAEkAAAAJAAAAbWV0YS5qc29uq1ZKLChQslJQSs7P1UvKz0jNzUzMS85PycxL1yvOTi1JzjBW0lEAKQpLLSrOzM8DqTUzAYmVwQUMjUxrAVBLAwQUAAAACADhRVZdo5nc2bkAAABnAQAADAAAAHBhZ2VzL1AuanNvbm1PQQrCMBD8StmzB1voxZsgUg+CUG8iJU3XNhITaVZtLf27SWylFEPIzgyzs5sOMi6ZMbAK4M5KhEUAhc50fkVOu42TD05T7IaeDB7JWqxd16mbJBA29CfhOE3w5FJ/WQeNfZdWaYf6EgVVFseOVCjKiiwL495SRlSL/EFYpBao0geYEUIiYGoatpOaMxJaDfkSVekHRHNvB/t0PZuw1Yp+mvfMWsZfJSifSIIzt4IRbyeGUW/P2d0PUEsBAhQDFAAAAAgA4UVWXYHx3nlHAAAAVwAAAA0AAAAAAAAAAAAAAIABAAAAAGRvY3VtZW50Lmpzb25QSwECFAMUAAAACADhRVZdAd7YYD0AAABJAAAACQAAAAAAAAAAAAAAgAFyAAAAbWV0YS5qc29uUEsBAhQDFAAAAAgA4UVWXaOZ3Nm5AAAAZwEAAAwAAAAAAAAAAAAAAIAB1gAAAHBhZ2VzL1AuanNvblBLBQYAAAAAAwADAKwAAAC5AQAAAAA="; + +/// Opening a `.sketch` file (whose import matches fonts against the installed ones) keeps to +/// `--no-system-fonts` too: the run still knows only the built-in families. +#[test] +fn sketch_imports_keep_to_no_system_fonts() { + let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-sketch-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let script = d.join("open.json"); + let steps = json!([{"command": "file.open", "params": {"data": SKETCH, "name": "x.sketch"}}, {"command": "text.fonts"}]); + std::fs::write(&script, steps.to_string()).unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_figcraft-cli")) + .arg("run") + .arg(&script) + .arg("--no-system-fonts") + .env_remove("FIGCRAFT_NO_SYSTEM_FONTS") + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let lines: Vec = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines[0]["ok"], json!(true), "{lines:?}"); + let fonts: Vec = serde_json::from_value(lines[1]["result"].clone()).unwrap(); + assert_eq!(fonts, families(&["--no-system-fonts"], false), "the import didn't scan the system fonts"); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8f7da824..8bbdc735 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -633,6 +633,12 @@ fn match_font_families(doc: &mut Document) { } } } + // Shared text styles too, so they stay like the layers linked to them. + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + fix(style); + } + } } /// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine @@ -668,6 +674,11 @@ fn platform_tracking(doc: &mut Document) { } } } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + track(style); + } + } } /// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index 292cd019..7ecdd467 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -17,13 +17,22 @@ fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec { /// [`sketch`] with `layers` on the first page. fn sketch_with(app_version: &str, pages: &[(&str, &str)], layers: &[Value]) -> Vec { + sketch_with_doc(app_version, pages, layers, json!({})) +} + +/// [`sketch_with`] with the fields of `extra` added to `document.json` (shared styles…). +fn sketch_with_doc(app_version: &str, pages: &[(&str, &str)], layers: &[Value], extra: Value) -> Vec { let refs: Vec = pages.iter().map(|(id, _)| json!({"_class": "MSJSONFileReference", "_ref": format!("pages/{id}")})).collect(); + let mut document = json!({"_class": "document", "pages": refs}); + for (k, v) in extra.as_object().into_iter().flatten() { + document[k] = v.clone(); + } let mut out = Vec::new(); { let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); let o = zip::write::SimpleFileOptions::default(); z.start_file("document.json", o).unwrap(); - z.write_all(json!({"_class": "document", "pages": refs}).to_string().as_bytes()).unwrap(); + z.write_all(document.to_string().as_bytes()).unwrap(); z.start_file("meta.json", o).unwrap(); // The format version that Sketch writes (103 for 50; 110 and up from 52). let version = if app_version.split('.').next().and_then(|v| v.parse::().ok()).is_some_and(|v| v >= 52) { 125 } else { 103 }; @@ -476,3 +485,41 @@ fn sketch_text_in_a_stack_stays_put() { let after = doc.world_bounds(label).unwrap(); assert!((before.y0 - after.y0).abs() < 1e-6, "{before:?} → {after:?}"); } + +/// Shared text styles get what their linked layers get on import: the installed family's name and +/// the platform's tracking (a layer and its style stay alike). +#[test] +fn sketch_shared_text_styles_match_their_layers() { + if !std::path::Path::new("/System/Library/Fonts/SFNS.ttf").exists() { + return; + } + figcraft_text::fontdb::scan_system_fonts(); + let attrs = json!({"MSAttributedStringFontAttribute": {"attributes": {"name": "SFUIText-Regular", "size": 13}}}); + let style = json!({"_class": "style", "textStyle": {"_class": "textStyle", "encodedAttributes": attrs.clone()}}); + let shared = json!({"_class": "sharedStyle", "do_objectID": "TS", "name": "Body", "value": style.clone()}); + let run = json!({"location": 0, "length": 2, "attributes": attrs}); + let mut linked = style; + linked["sharedObjectID"] = json!("TS"); + let t = shape( + "text", + "Label", + [0.0, 0.0, 100.0, 20.0], + json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0, "style": linked}), + ); + let extra = json!({"layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": [shared]}}); + let data = base64::engine::general_purpose::STANDARD.encode(sketch_with_doc("64", &[("A", "Page")], &[t], extra)); + let mut s = Session::new(); + s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap(); + let doc = s.doc().unwrap(); + let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") }; + let st = doc + .styles + .iter() + .find_map(|st| match &st.kind { + figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()), + _ => None, + }) + .unwrap(); + assert_eq!(st.font_family, td.style.font_family); + assert_eq!(st.letter_spacing.resolve(13.0), td.style.letter_spacing.resolve(13.0), "{st:?}"); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eeec9f04..6cf598a0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -633,3 +633,16 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +/// Damaged and too-old `.sketch` data sent to the file commands (which the junk sweep skips) fail +/// with an ordinary error, not a caught panic. +#[test] +fn damaged_sketch_data_fails_without_panicking() { + for data in ["UEsDBBQAAAAIAP////8=", "U1FMaXRlIGZvcm1hdCAzAA=="] { + for cmd in ["file.open", "file.import"] { + let mut s = sess(); + let r = s.execute(cmd, &json!({"data": data, "name": "x.sketch"})); + assert!(matches!(r, Err(ref e) if !matches!(e, EngineError::Internal { .. })), "{cmd}: {r:?}"); + } + } +} diff --git a/crates/format/src/sketch/stack.rs b/crates/format/src/sketch/stack.rs index a6b2ddb2..89f67ca8 100644 --- a/crates/format/src/sketch/stack.rs +++ b/crates/format/src/sketch/stack.rs @@ -62,12 +62,17 @@ pub(crate) fn apply(l: &Value, node: &mut JsonMap) { node.insert("primaryAxisSizingMode".into(), json!(mode(main))); node.insert("counterAxisSizingMode".into(), json!(mode(cross))); // Items: flow order, sizing, absolute ones. - let sources: Vec<&Value> = super::tree::layers(l).iter().collect(); + // Source layers by id (the first of a repeated id). + let mut sources: std::collections::BTreeMap<&str, &Value> = std::collections::BTreeMap::new(); + for src in super::tree::layers(l) { + if let Some(id) = src.get("do_objectID").and_then(Value::as_str) { + sources.entry(id).or_insert(src); + } + } if let Some(kids) = node.get_mut("children").and_then(Value::as_array_mut) { // Children were built one per source layer, skipped ones aside: match them by id. for kid in kids.iter_mut() { - let id = kid.get("id").and_then(Value::as_str).map(str::to_string); - let Some(src) = sources.iter().find(|s| id.is_some() && s.get("do_objectID").and_then(Value::as_str) == id.as_deref()) else { continue }; + let Some(src) = kid.get("id").and_then(Value::as_str).and_then(|id| sources.get(id).copied()) else { continue }; let Some(m) = kid.as_object_mut() else { continue }; item(src, m, row, stretch); } diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 027de660..74cfa21c 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -129,18 +129,18 @@ pub(crate) fn type_style(cx: &mut Cx, a: &Value) -> Value { cx.warn("text without a font uses Helvetica"); } let mut s = json!({"fontFamily": family, "fontStyle": style, "fontSize": size.min(10_000.0)}); - if let Some(v) = font.and_then(|f| f.get("variation")).and_then(variations) { - s["fontVariations"] = v; - } + // Every field is written, neutral when the run has none: a run's style starts from the text's + // (its first run's), and an unwritten field would keep that one's value. + s["fontVariations"] = font.and_then(|f| f.get("variation")).and_then(variations).unwrap_or_else(|| json!({})); let p = a.get("paragraphStyle"); let line = |k: &str| p.and_then(|p| p.get(k)).and_then(Value::as_f64).filter(|v| v.is_finite() && *v > 0.0); if let Some(lh) = line("maximumLineHeight").or_else(|| line("minimumLineHeight")) { s["lineHeightPx"] = json!(lh.min(1e4)); s["lineHeightUnit"] = json!("PIXELS"); + } else { + s["lineHeightUnit"] = json!("INTRINSIC_%"); } - if let Some(k) = a.get("kerning").and_then(Value::as_f64).filter(|k| k.is_finite()) { - s["letterSpacing"] = json!(k); - } + s["letterSpacing"] = json!(a.get("kerning").and_then(Value::as_f64).filter(|k| k.is_finite()).unwrap_or(0.0).clamp(-1e4, 1e4)); let set = |k: &str| a.get(k).and_then(Value::as_i64).is_some_and(|v| v != 0); s["textDecoration"] = json!(if set("underlineStyle") { "UNDERLINE" diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index 8adef1ea..0e8a4a27 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -250,3 +250,24 @@ fn postscript_names_find_installed_fonts() { let (doc, _) = import(&z, "f").unwrap(); assert_eq!(data(&doc, "A").style.font_family, "Courier New PSMT"); } + +/// A run without kerning, a fixed line height or font axes has none: it doesn't take the first +/// run's (the text's base style) as its own. +#[test] +fn later_runs_dont_inherit_the_first_runs_settings() { + let mut t = text("Mixed", 0.0, 0.0, 200.0, 40.0, "AaBb", &[(0, 2, "Skia", 20.0, BLACK), (2, 2, "Helvetica", 14.0, BLACK)]); + let first = &mut t["attributedString"]["attributes"][0]["attributes"]; + first["kerning"] = json!(2); + first["paragraphStyle"]["minimumLineHeight"] = json!(24); + first["paragraphStyle"]["maximumLineHeight"] = json!(24); + first["MSAttributedStringFontAttribute"]["attributes"]["variation"] = json!({"2003265652": 3}); + let (doc, _) = open(vec![t]); + let td = data(&doc, "Mixed"); + let at = |i: usize| td.runs.iter().find(|r| r.start <= i && i < r.end).map_or(&td.style, |r| &r.style); + assert_eq!(at(0).letter_spacing.resolve(20.0), 2.0); + let second = at(2); + assert_eq!(second.font_family, "Helvetica"); + assert_eq!(second.letter_spacing.resolve(14.0), 0.0, "{second:?}"); + assert_eq!(second.line_height, figcraft_doc::LineHeight::Auto, "{second:?}"); + assert!(second.variations.is_empty(), "{second:?}"); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 34d76b12..b004d7e1 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -526,6 +526,8 @@ pub struct Rescan { static DB: OnceLock> = OnceLock::new(); /// Set by [`scan_system_fonts_on_demand`]. static ON_DEMAND: AtomicBool = AtomicBool::new(false); +/// Set by [`opt_out_of_system_fonts`]. +static OPTED_OUT: AtomicBool = AtomicBool::new(false); /// Done once per process: concurrent callers wait for the one scan. static SCANNED: OnceLock<()> = OnceLock::new(); /// Directory walks done (at most one). @@ -1111,10 +1113,17 @@ pub fn load_font_bytes(bytes: Vec) -> Vec { families } -/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts. +/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`), or the process opted out +/// ([`opt_out_of_system_fonts`]): renders use only the embedded fonts. #[cfg(not(target_arch = "wasm32"))] fn system_fonts_opted_out() -> bool { - std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0") + OPTED_OUT.load(Ordering::Relaxed) || std::env::var_os("FIGCRAFT_NO_SYSTEM_FONTS").is_some_and(|v| !v.is_empty() && v != "0") +} + +/// Use only the embedded fonts for the rest of the process, as `FIGCRAFT_NO_SYSTEM_FONTS` does +/// (headless runs with `--no-system-fonts`): no call scans the system fonts after this. +pub fn opt_out_of_system_fonts() { + OPTED_OUT.store(true, Ordering::Relaxed); } /// Every font text can use is known: the system fonts were indexed, or there are none to index diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 50cd28dd..1663ab13 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -17,8 +17,8 @@ pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face, - has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, - system_fonts_scanned, + has_family, installed_fonts_known, load_font_bytes, no_coords, opt_out_of_system_fonts, rescan_system_fonts, scan_system_fonts, + scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index adedc983..eb3a88db 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -416,7 +416,7 @@ status = "draft — needs native review" "Draw" = "আঁকুন" "Draw a frame" = "একটি ফ্রেম আঁকুন" "Drawing tools" = "অঙ্কন টুলস" -"Drop a .figcraft, .fig, .json, .svg or image file." = "একটি .figcraft, .fig, .json, .svg বা ছবির ফাইল ড্রপ করুন।" +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "একটি .figcraft, .fig, .sketch, .json, .svg বা ছবির ফাইল ড্রপ করুন।" "Drop to import" = "ইম্পোর্ট করতে ড্রপ করুন" "Drop to open" = "খুলতে ড্রপ করুন" "Drop shadow" = "ড্রপ শ্যাডো" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index f61464b8..04d1af62 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -397,7 +397,7 @@ status = "draft — needs native review" "Drag the stroke to add a width point" = "Ziehen Sie an der Kontur, um einen Breitenpunkt hinzuzufügen" "Draw" = "Zeichnen" "Drawing tools" = "Zeichenwerkzeuge" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Legen Sie eine .figcraft-, .fig-, .json-, .svg- oder Bilddatei ab." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Legen Sie eine .figcraft-, .fig-, .sketch-, .json-, .svg- oder Bilddatei ab." "Drop to import" = "Zum Importieren ablegen" "Drop to open" = "Zum Öffnen ablegen" "Drop shadow" = "Schlagschatten" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..1180bf41 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -417,7 +417,7 @@ name = "English" "Draw" = "Draw" "Draw a frame" = "Draw a frame" "Drawing tools" = "Drawing tools" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Drop a .figcraft, .fig, .json, .svg or image file." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Drop a .figcraft, .fig, .sketch, .json, .svg or image file." "Drop to import" = "Drop to import" "Drop to open" = "Drop to open" "Drop shadow" = "Drop shadow" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..7ed208b0 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -418,7 +418,7 @@ status = "draft — needs native review" "Draw" = "Dibujar" "Draw a frame" = "Dibuja un marco" "Drawing tools" = "Herramientas de dibujo" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Suelta un archivo .figcraft, .fig, .json, .svg o de imagen." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Suelta un archivo .figcraft, .fig, .sketch, .json, .svg o de imagen." "Drop to import" = "Suelta para importar" "Drop to open" = "Suelta para abrir" "Drop shadow" = "Sombra paralela" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 205a9d29..3102fbf4 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -418,7 +418,7 @@ status = "draft — needs native review" "Draw" = "Piirrä" "Draw a frame" = "Piirrä kehys" "Drawing tools" = "Piirtotyökalut" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Pudota .figcraft-, .fig-, .json-, .svg- tai kuvatiedosto." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Pudota .figcraft-, .fig-, .sketch-, .json-, .svg- tai kuvatiedosto." "Drop shadow" = "Heittovarjo" "Drop to import" = "Tuo pudottamalla" "Drop to open" = "Avaa pudottamalla" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b038b335..0cc87213 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -415,7 +415,7 @@ status = "draft — needs native review" "Draw" = "Dessin" "Draw a frame" = "Tracer un cadre" "Drawing tools" = "Outils de dessin" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .json, .svg ou une image." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .sketch, .json, .svg ou une image." "Drop shadow" = "Ombre portée" "Drop to import" = "Déposer pour importer" "Drop to open" = "Déposer pour ouvrir" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 5b73b4fb..20b4bce6 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -418,7 +418,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Draw" = "ხატვა" "Draw a frame" = "დახატეთ ჩარჩო" "Drawing tools" = "ხატვის ხელსაწყოები" -"Drop a .figcraft, .fig, .json, .svg or image file." = "ჩააგდეთ .figcraft, .fig, .json, .svg ან გამოსახულების ფაილი." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "ჩააგდეთ .figcraft, .fig, .sketch, .json, .svg ან გამოსახულების ფაილი." "Drop to import" = "ჩააგდეთ იმპორტისთვის" "Drop to open" = "ჩააგდეთ გასახსნელად" "Drop shadow" = "ჩრდილი" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 54851043..6d276911 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -418,7 +418,7 @@ status = "draft — needs native review" "Draw" = "Tekenen" "Draw a frame" = "Teken een frame" "Drawing tools" = "Tekengereedschap" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Laat een .figcraft-, .fig-, .json-, .svg- of afbeeldingsbestand los." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Laat een .figcraft-, .fig-, .sketch-, .json-, .svg- of afbeeldingsbestand los." "Drop shadow" = "Slagschaduw" "Drop to import" = "Loslaten om te importeren" "Drop to open" = "Loslaten om te openen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 23f7dee8..0453ee7b 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -415,7 +415,7 @@ status = "draft — needs native review" "Draw" = "Rysuj" "Draw a frame" = "Narysuj ramkę" "Drawing tools" = "Narzędzia rysowania" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Upuść plik .figcraft, .fig, .json, .svg lub plik obrazu." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Upuść plik .figcraft, .fig, .sketch, .json, .svg lub plik obrazu." "Drop to import" = "Upuść, aby zaimportować" "Drop to open" = "Upuść, aby otworzyć" "Drop shadow" = "Cień" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d8f857e0..424a4337 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1662,7 +1662,7 @@ status = "draft — needs native review" "Dec" = "dez." "Design system starter" = "Ponto de partida para sistema de design" "Draw a frame" = "Desenhe um quadro" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Solte um arquivo .figcraft, .fig, .json, .svg ou uma imagem." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Solte um arquivo .figcraft, .fig, .sketch, .json, .svg ou uma imagem." "Drop to import" = "Solte para importar" "Drop to open" = "Solte para abrir" "Edited {when}" = "Editado {when}" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 6e173a92..2d5169a0 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -417,7 +417,7 @@ name = "Русский" "Draw" = "Рисование" "Draw a frame" = "Нарисовать Frame" "Drawing tools" = "Инструменты рисования" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Перетащите файл .figcraft, .fig, .json, .svg или изображение." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Перетащите файл .figcraft, .fig, .sketch, .json, .svg или изображение." "Drop shadow" = "Внешняя тень" "Drop to import" = "Отпустите, чтобы импортировать" "Drop to open" = "Отпустите, чтобы открыть" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d86779e0..2f8f3d95 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -412,7 +412,7 @@ name = "Svenska" "Draw" = "Rita" "Draw a frame" = "Rita en ram" "Drawing tools" = "Ritverktyg" -"Drop a .figcraft, .fig, .json, .svg or image file." = "Släpp en .figcraft-, .fig-, .json- eller .svg-fil eller en bildfil." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = "Släpp en .figcraft-, .fig-, .sketch-, .json- eller .svg-fil eller en bildfil." "Drop to import" = "Släpp för att importera" "Drop to open" = "Släpp för att öppna" "Drop shadow" = "Släppskugga" diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index b532d9a7..664ca159 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -181,7 +181,7 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { let files = ctx.input(|i| i.raw.dropped_files.clone()); for f in files { if !crate::opens(f.path()) { - app.toast(tr("Drop a .figcraft, .fig, .json, .svg or image file.")); + app.toast(tr("Drop a .figcraft, .fig, .sketch, .json, .svg or image file.")); continue; } crate::pending_open::open_dropped(app, &f); diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index fc51bfb1..71225db4 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -898,7 +898,7 @@ fn dropping_an_unsupported_file_on_the_home_screen_opens_nothing() { drop_file(&mut u, &path); assert!(u.app.session.docs.is_empty()); assert!(u.app.ui.home); - assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .json, .svg or image file.")); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .sketch, .json, .svg or image file.")); } #[test] diff --git a/docs/cli.md b/docs/cli.md index bf783d11..84f4617f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -87,7 +87,7 @@ figcraft-cli export app.figcraft -o handoff.fig ### `convert ` -By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.fig` (best effort), `.svg` (an editable frame: groups, +By extension. Inputs: `.figcraft`, `.json` (FigCraft or REST API file JSON), `.fig` (best effort), `.sketch` (Sketch 48 or newer, best effort), `.svg` (an editable frame: groups, shapes, text, gradients, masks and effects; `info` lists what it left out as `note:` lines), `.png/.jpg/.webp/.gif/.tif/.bmp` (an image layer). Outputs: `.figcraft`, `.json` (FigCraft JSON, pretty-printed), `.svg`, `.png/.jpg/.webp` (the current page rendered at 1x with its background), `.pdf` (the current page's diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 11867d88..5fb7532c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | -| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 47, 68 and 83 (70,000 layers open in ~3 s, debug build); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 70 in at most 4.4%, a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | +| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (with their colour adjustments as image filters), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer and background blurs, opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, layer and text styles, nested paths), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only where Sketch shows them; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open. Warnings: noise fills, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session) | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | @@ -166,7 +166,7 @@ each. | Date | Change | Summary | |---|---|---| -| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Sketch 2025 and newer: frames, graphics, stacks as auto layout, corners, pins and sizing, tints, per-side borders, blur lists. Checked on fixtures and locally on real Sketch 50, 64 and 2026 files and the IBM Carbon Design Kit (Sketch 47, 68, 83). Numbers unchanged | +| 2026-10-21 | minor | `.sketch` import (best effort): files saved by Sketch 48 and newer open and import like `.fig` files (open, import, drop, CLI, MCP), mapped onto the REST importer: layers, paths, shape groups and booleans, styles and effects, masks, text, symbols with overrides (live instances), shared styles, Color Variables, prototype links, export settings and grids; older files are refused with a message. Sketch 2025 and newer: frames, graphics, stacks as auto layout, corners, pins and sizing, tints, per-side borders, blur lists. Checked on fixtures and locally on real Sketch 50, 64 and 2026 files and the IBM Carbon Design Kit (Sketch 68 and 83 open; the Sketch 47 edition is refused). Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | From 85010e491afbb1fd1cbbd94281e7e8a92884015f Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 21 Oct 2026 09:52:50 +0700 Subject: [PATCH 067/208] M7: missing fonts: follow each font variable's aliases once (#752) A file whose font family variables alias each other in several modes made the missing-fonts walk branch in every mode, billions of steps before the depth limit stopped it, freezing the app on open and `figcraft-cli info`. The walk now visits each variable once. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/fonts.rs | 12 ++++++++---- crates/engine/src/fonts_tests.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 672d4f2c..bdda411c 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -64,16 +64,20 @@ pub fn missing(doc: &Document) -> Vec { used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect() } -/// Every string value of variable `id` in any mode, following aliases (at most 32 deep). +/// Every string value of variable `id` in any mode, following aliases (each variable once). fn string_values(vars: &Variables, id: &str) -> Vec { let mut out = Vec::new(); - let mut todo = vec![(id.to_string(), 0)]; - while let Some((id, depth)) = todo.pop() { + let mut seen = BTreeSet::new(); + let mut todo = vec![id.to_string()]; + while let Some(id) = todo.pop() { + if !seen.insert(id.clone()) { + continue; + } let Some(var) = vars.get(&id) else { continue }; for v in var.values.values() { match v { VariableValue::String(s) => out.push(s.clone()), - VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)), + VariableValue::Alias(a) => todo.push(a.clone()), _ => {} } } diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index 0fe6dcb3..5df9a854 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -119,6 +119,25 @@ fn blank_families_and_aliases_are_handled() { assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); } +/// Aliases in several modes are followed once per variable: a chain that branches in every mode +/// would otherwise grow to billions of steps (a file can hold one). +#[test] +fn branching_alias_chains_are_walked_once_per_variable() { + let mut s = Session::new(); + s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap(); + for i in 0..32 { + s.execute("variable.create", &json!({"collection": "Type", "name": format!("V{i}"), "type": "string", "value": "x"})).unwrap(); + } + let doc = s.doc_mut().unwrap(); + let ids: Vec = doc.variables.variables.iter().map(|v| v.id.clone()).collect(); + for (i, var) in doc.variables.variables.iter_mut().enumerate() { + let next = ids.get(i + 1).cloned().unwrap_or_else(|| "FigCraft Missing H".into()); + let leaf = |v: String| if i + 1 < ids.len() { VariableValue::Alias(v) } else { VariableValue::String(v) }; + var.values = [("m1".to_string(), leaf(next.clone())), ("m2".to_string(), leaf(next))].into(); + } + assert_eq!(string_values(&doc.variables, &ids[0]), vec!["FigCraft Missing H", "FigCraft Missing H"]); +} + #[test] fn needs_an_open_document() { let mut s = Session::empty(); From 785dc437fada6c3ebe127f7a8e31109543764360 Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 19:11:39 +0800 Subject: [PATCH 068/208] =?UTF-8?q?fix(vectoredit):=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E7=BB=93=E6=9D=9F=E6=97=B6=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=97=A0=E4=BB=BB=E4=BD=95=E7=BA=BF=E6=AE=B5=E7=9A=84=E7=A9=BA?= =?UTF-8?q?=E5=9B=BE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增空图层检测函数,在结束矢量编辑模式时自动删除没有任何线段的图层,避免留下空图层或空框。同时更新相关测试用例和文档,确保该行为符合预期。 --- crates/engine/src/vectoredit.rs | 19 +++++++++++++++ crates/engine/src/vectoredit/tests.rs | 35 +++++++++++++++++++++++++++ docs/ui-parity.md | 3 ++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index a3fbf803..22755c4d 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -539,9 +539,28 @@ pub fn begin_many(s: &mut Session, ids: &[NodeId]) -> Result<()> { /// At most this many layers are edited at once. const MAX_EDITED: usize = 500; +/// The edited layers left without a single segment: they draw nothing (isolated points included), +/// so their layer would only leave an empty box behind. +fn empty_layers(doc: &Document, ids: &[NodeId]) -> Vec { + ids.iter().copied().filter(|id| network(doc, *id).is_some_and(|n| n.segments.is_empty())).collect() +} + +/// Leave edit mode. Layers whose every point was deleted go with it (the undo step that deleted +/// the last points brings them back). pub fn end(s: &mut Session) -> Result<()> { + let empty = { + let ds = s.ds()?; + ds.vector_edit.as_ref().map(|ve| empty_layers(&ds.doc, &ve.parts().iter().map(|p| p.id).collect::>())).unwrap_or_default() + }; + if !empty.is_empty() { + let doc = s.doc_mut()?; + for id in &empty { + let _ = doc.remove(*id); + } + } let ds = s.ds_mut()?; ds.vector_edit = None; + ds.selection.retain(|id| !empty.contains(id)); ds.revision += 1; s.tool.overlay = Default::default(); Ok(()) diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/vectoredit/tests.rs index ff3c4cfa..07ebd56f 100644 --- a/crates/engine/src/vectoredit/tests.rs +++ b/crates/engine/src/vectoredit/tests.rs @@ -310,6 +310,41 @@ fn a_point_dropped_on_another_merges_into_it() { assert_eq!((n.vertices.len(), n.segments.len()), (2, 1), "still a line: {n:?}"); } +#[test] +fn deleting_every_point_takes_the_layer_with_the_edit_mode() { + // Delete: no point is left at all. + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.selectAll", json!({})); + ex(&mut s, "vector.edit.delete", json!({})); + assert!(net(&s, id).vertices.is_empty() && net(&s, id).segments.is_empty(), "no point left"); + assert!(s.doc().unwrap().node(id).is_some(), "the layer stays while editing"); + ex(&mut s, "vector.edit.end", json!({})); + assert!(s.doc().unwrap().node(id).is_none(), "it goes with edit mode instead of leaving an empty box"); + assert!(s.selection().is_empty()); + // The step that deleted the last points brings the shape back. + s.undo().unwrap(); + assert_eq!(net(&s, id).vertices.len(), 4); + // Shift+Delete (heal): the last point is left on its own with no segment, drawing nothing either. + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.selectAll", json!({})); + ex(&mut s, "vector.deleteHeal", json!({})); + assert!(net(&s, id).segments.is_empty(), "nothing draws: {:?}", net(&s, id)); + ex(&mut s, "vector.edit.end", json!({})); + assert!(s.doc().unwrap().node(id).is_none(), "an unconnected point left behind doesn't keep the layer"); + // Escape ends edit mode the same way, for every layer edited along that is left empty. + let mut s = Session::new(); + let a = crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100}))["id"]).unwrap(); + let b = crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": 200, "y": 0, "width": 50, "height": 50}))["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]})); + ex(&mut s, "vector.edit.selectAll", json!({})); + ex(&mut s, "vector.edit.delete", json!({})); + s.escape().unwrap(); + assert!(s.ds().unwrap().vector_edit.is_none()); + assert!(s.doc().unwrap().node(a).is_none() && s.doc().unwrap().node(b).is_none()); +} + #[test] fn undo_back_past_the_conversion_leaves_edit_mode() { let mut s = Session::new(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5421cd68..e2e2318a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -82,7 +82,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). A click on empty canvas only clears the points and starts a marquee (edit mode stays; a double-click or Escape leaves it), while a click on another layer leaves it and selects that layer. The box around several selected points draws its outline and size only, without corner squares (they would read as points); a press on any selected point, corners included, drags them all freely, while its corners and edges clear of points still scale by drag. Not yet: the transform box around points of several layers; areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; a layer left with no point at all goes when edit mode ends (no empty layer or box behind); rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). A click on empty canvas only clears the points and starts a marquee (edit mode stays; a double-click or Escape leaves it), while a click on another layer leaves it and selects that layer. The box around several selected points draws its outline and size only, without corner squares (they would read as points); a press on any selected point, corners included, drags them all freely, while its corners and edges clear of points still scale by drag. Not yet: the transform box around points of several layers; areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | | Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | A layer whose every point was deleted goes when edit mode ends (Escape, Enter, a double-click or a click on another layer), instead of staying as an empty layer with the old box; the undo step that deleted the last points brings the shape back (`vectoredit::tests`) | | 2026-10-22 | minor | Vector edit mode stays on a click on empty canvas: with the Move tool it only clears the points and starts a marquee, and a double-click (or Escape) leaves it; a click on another layer still leaves it and selects that layer. A double-click on empty canvas leaves it even straight after the one that entered edit mode (the platform counts those clicks on, so the last one arrives as a triple; on empty canvas a triple means a double here, while triples on the vector keep their own meaning) (`vector_behaviour_tests`, `canvas_double_click_tests`). Checked in the app on Windows with real mouse clicks over the control channel | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | From 37921f48ceeb19466cf7fe656f8f553a36ec542c Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 19:51:25 +0800 Subject: [PATCH 069/208] =?UTF-8?q?feat(inspect):=20=E4=B8=BA=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E5=88=97=E8=A1=A8=E6=B7=BB=E5=8A=A0=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E4=B8=8B=E6=8B=89=E8=8F=9C=E5=8D=95=E5=92=8C?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E7=BC=A9=E7=95=A5=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/dev_mode_tests.rs | 25 +++++++ crates/ui-egui/src/right.rs | 104 +++++++++++++++++++++------ docs/dev-mode-parity.md | 5 +- 6 files changed, 115 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index aa9f97b3..f9fa6437 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -544,6 +544,7 @@ name = "English" "Font size" = "Font size" "Font style" = "Font style" "Forget on this computer" = "Forget on this computer" +"Format" = "Format" "Four screens of a trail app with components and a prototype flow" = "Four screens of a trail app with components and a prototype flow" "Fractions" = "Fractions" "Frame" = "Frame" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index f47ab965..fd36f60f 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -545,6 +545,7 @@ status = "draft — needs native review" "Font size" = "Tamaño de fuente" "Font style" = "Estilo de fuente" "Forget on this computer" = "Olvidar en este ordenador" +"Format" = "Formato" "Four screens of a trail app with components and a prototype flow" = "Cuatro pantallas de una app de senderos con componentes y un flujo de prototipo" "Fractions" = "Fracciones" "Frame" = "Marco" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index befa9685..ce1c6d65 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -517,6 +517,7 @@ status = "draft — needs native review" "Font size" = "字号" "Font style" = "字体样式" "Forget on this computer" = "在这台电脑上忘记" +"Format" = "格式" "Fractions" = "分数" "Frame" = "画框" "Frame selection" = "为所选项添加画框" diff --git a/crates/ui-egui/src/dev_mode_tests.rs b/crates/ui-egui/src/dev_mode_tests.rs index 941f1b2c..9c62f8c9 100644 --- a/crates/ui-egui/src/dev_mode_tests.rs +++ b/crates/ui-egui/src/dev_mode_tests.rs @@ -80,3 +80,28 @@ fn annotation_filter_and_categories_panel() { assert_eq!(CategoryFilter::parse(&doc, "none"), Some(CategoryFilter::Uncategorized)); assert_eq!(CategoryFilter::parse(&doc, "nope"), None); } + +#[test] +fn the_assets_list_shows_a_slice_with_a_preview_name_and_export_format() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200, "name": "Login"})).unwrap()); + s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 80, "height": 40, "parent": f, "props": {"fill": "#FF0000"}})).unwrap(); + // A slice with one export setting: its own format is what the row offers first. + let slice = id(&s.execute("node.create", &json!({"type": "slice", "x": 10, "y": 10, "width": 100, "height": 60, "name": "Avatar"})).unwrap()); + s.execute("export.add", &json!({"ids": [slice], "setting": {"format": "webp", "size": "2x"}})).unwrap(); + s.execute("select.set", &json!({"ids": [slice]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.ctx.enable_accesskit(); + let out = u.frame_output(vec![]); + let nodes: Vec = + out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect(); + let find = |role: egui::accesskit::Role, label: &str| { + nodes.iter().find(|n| n.role() == role && n.label() == Some(label)).unwrap_or_else(|| panic!("no {role:?} named {label:?}")) + }; + // The row exports by a click, and the format under the name is a dropdown. + find(egui::accesskit::Role::Button, "Export Avatar"); + let fmt = find(egui::accesskit::Role::ComboBox, "Format"); + assert_eq!(fmt.value(), Some("WebP")); + assert_eq!(fmt.is_expanded(), Some(false)); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index b2babc56..60aade81 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -2251,9 +2251,23 @@ pub(crate) fn prop_row(app: &mut FigApp, ui: &mut Ui, p: &figcraft_engine::devin } } -/// The selection's assets (layers with export settings, icons, images): click one to export it, -/// or export them all. -fn inspect_assets(app: &mut FigApp, ui: &mut Ui, assets: &[figcraft_engine::devinspect::Asset]) { +/// The thumbnails and chosen export formats of the Inspect tab's assets, kept between frames and +/// dropped when the document changes. +#[derive(Clone, Default)] +struct AssetUi { + revision: u64, + thumbs: std::collections::HashMap>, + formats: std::collections::HashMap, +} + +/// An asset row's height and its preview's side. +const ASSET_ROW_H: f32 = 56.0; +const ASSET_THUMB: f32 = 40.0; + +/// The selection's assets (layers with export settings, icons, images) as a list of a preview, a +/// name and a format: click one to export it in that format, or export them all with their own +/// export settings. +fn inspect_assets(app: &mut FigApp, ui: &mut Ui, doc: &Document, assets: &[figcraft_engine::devinspect::Asset]) { let t = Tokens::get(ui.ctx()); widgets::separator(ui); let export_all = [(Icon::Export, tr("Export all"))]; @@ -2262,30 +2276,80 @@ fn inspect_assets(app: &mut FigApp, ui: &mut Ui, assets: &[figcraft_engine::devi widgets::note(ui, tr("No assets: no export settings, icons or images. Add export settings in the Design tab."), t.text_secondary); return; } - let mut export: Option> = all.then(|| assets.iter().map(|a| a.id).collect()); + let mut export_all_ids: Option> = all.then(|| assets.iter().map(|a| a.id).collect()); + let mut export_one: Option<(NodeId, ExportSetting, String)> = None; + let revision = app.session.ds().map(|d| d.revision).unwrap_or(0); + let cache_id = Id::new("inspect_asset_ui"); + let mut state: AssetUi = ui.data(|d| d.get_temp(cache_id)).unwrap_or_default(); + if state.revision != revision { + state.revision = revision; + state.thumbs.clear(); + state.formats.clear(); + } + let px = ASSET_THUMB * ui.ctx().pixels_per_point(); for a in assets { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ASSET_ROW_H), Sense::click()); let resp = resp.tip(trf("Export {name}", &[("name", &a.name)])); if resp.hovered() { - ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover); + ui.painter().rect_filled(r.shrink2(vec2(8.0, 1.0)), CornerRadius::same(6), t.hover); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - PAD - 6.0, r.center().y), vec2(12.0, 12.0)), Icon::Export, t.icon); } - let name_r = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y), pos2(r.min.x + 96.0 - 4.0, r.max.y)); - ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x, r.center().y), Align2::LEFT_CENTER, &a.name, regular(11.0), t.text); - let fmt_r = Rect::from_min_max(pos2(r.min.x + 96.0, r.min.y), pos2(r.max.x - PAD - 16.0, r.max.y)); - ui.painter().with_clip_rect(fmt_r).text( - pos2(fmt_r.min.x, r.center().y), - Align2::LEFT_CENTER, - a.exports.join(", "), - regular(11.0), - t.text_secondary, - ); - if resp.clicked() { - export = Some(vec![a.id]); + // The preview, as exporting it paints it (a slice shows the page under it). + let thumb = Rect::from_min_size(pos2(r.min.x + PAD, r.center().y - ASSET_THUMB / 2.0), vec2(ASSET_THUMB, ASSET_THUMB)); + ui.painter().rect_filled(thumb, CornerRadius::same(4), t.field); + let tex = state.thumbs.entry(a.id).or_insert_with(|| crate::assets_panel::thumbnail(ui.ctx(), doc, a.id, px)).clone(); + if let Some(tex) = tex { + let size = tex.size_vec2(); + let inner = thumb.shrink(3.0); + // Never past 1:1 physical pixels, so small assets stay crisp. + let k = (inner.width() / size.x).min(inner.height() / size.y).min(1.0 / ui.ctx().pixels_per_point()).max(0.0); + let img = Rect::from_center_size(thumb.center(), size * k); + ui.painter().with_clip_rect(inner).image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + ui.painter().rect_stroke(thumb, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + // The name, and the format to export it as (its first export setting's, until chosen). + let x = thumb.max.x + 10.0; + let name_r = Rect::from_min_max(pos2(x, r.min.y + 5.0), pos2(r.max.x - PAD, r.min.y + 27.0)); + let name = widgets::elide(ui, &a.name, name_r.width()); + ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x, name_r.center().y), Align2::LEFT_CENTER, &name, regular(11.0), t.text); + let idx = state + .formats + .get(&a.id) + .copied() + .filter(|i| *i < ExportFormat::ALL.len()) + .unwrap_or_else(|| ExportFormat::ALL.iter().position(|f| Some(*f) == a.settings.first().map(|s| s.format)).unwrap_or(0)); + let fmt = ExportFormat::ALL[idx]; + let btn = Rect::from_min_size(pos2(x, r.min.y + 31.0), vec2(74.0, 20.0)); + let btn_resp = ui.interact(btn, Id::new(("inspect_asset_format", a.id)), Sense::click()); + a11y::combo(&btn_resp, tr("Format"), fmt.label(), a11y::menu_open(&btn_resp)); + if btn_resp.hovered() { + ui.painter().rect_filled(btn, CornerRadius::same(5), t.field); + ui.painter().rect_stroke(btn, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), egui::StrokeKind::Inside); + } else { + ui.painter().rect_filled(btn, CornerRadius::same(5), t.field); + } + ui.painter().text(pos2(btn.min.x + 8.0, btn.center().y), Align2::LEFT_CENTER, fmt.label(), regular(11.0), t.text); + icons::paint(ui.painter(), Rect::from_center_size(pos2(btn.max.x - 9.0, btn.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); + menu_popup(&btn_resp).show(|ui| { + for (i, f) in ExportFormat::ALL.iter().enumerate() { + if menu_item(ui, f.label(), "") { + state.formats.insert(a.id, i); + ui.close(); + } + } + }); + if resp.clicked() && !btn_resp.hovered() { + let mut setting = a.settings.first().cloned().unwrap_or_default(); + setting.format = fmt; + let file = format!("{}{}.{}", a.name.rsplit('/').next().unwrap_or(&a.name), setting.file_suffix(), fmt.ext()); + export_one = Some((a.id, setting, file)); } } - if let Some(ids) = export { + ui.data_mut(|d| d.insert_temp(cache_id, state)); + if let Some(ids) = export_all_ids.take() { crate::export_panel::export_layers(app, &ids, true); + } else if let Some((id, setting, file)) = export_one { + crate::export_panel::save_one(app, &[id], &setting, &file); } ui.add_space(4.0); } @@ -2329,7 +2393,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) { for s in figcraft_engine::devinspect::properties_with(&doc, first, fmt) { inspect_section(app, ui, &s); } - inspect_assets(app, ui, &figcraft_engine::devinspect::assets(&doc, &sel)); + inspect_assets(app, ui, &doc, &figcraft_engine::devinspect::assets(&doc, &sel)); use figcraft_engine::codegen::CodeLang; let lang_id = Id::new("inspect_lang"); let lang = ui.data(|d| d.get_temp::(lang_id)).and_then(|i| CodeLang::ALL.get(i).copied()).unwrap_or_default(); diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index cba5e81d..7d08f90a 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -1,6 +1,6 @@ # Dev Mode parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (dev resources links; inspect behaviour suite and fixes: colour formats, rem, blend, positions from the frame, CSS for rotation, gradients and text, icons and images as assets; code connect and Dev Mode plugins) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (the assets list shows a preview, the name and an export format per asset) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) **Breadth ~96%, ready ~72% (estimated).** The inspect surface is broad, components connect to the code components of a repository and layers carry dev resources links; left: import lines @@ -11,7 +11,7 @@ inside generated layer trees. Remaining: 0–1 h. | Dev Mode toggle (Shift+D), Inspect panel | done | | | Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) | | Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration | -| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours | +| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG) as a list of a preview, the name and a format to pick (its first export setting's until changed); a click exports the asset in that format, "Export all" uses each layer's own export settings; the icon rule is ours | | Annotations: pinned properties, categories, coloured callouts, filter | done | | | Ready for dev list, statuses, "Changed" after edits | done | | | Focus view (one frame, previous / next) | done | | @@ -25,6 +25,7 @@ inside generated layer trees. Remaining: 0–1 h. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | The Inspect tab's assets are a list of a thumbnail, the name and a format dropdown, instead of a name with a text summary: a slice shows the page content under it (as its export does), the format starts from the asset's first export setting and picking another exports that asset in it; "Export all" is unchanged (`dev_mode_tests::the_assets_list_shows_a_slice_with_a_preview_name_and_export_format`) | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | | 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Dev Mode breadth 90% → 93%, ready 64% → 70%, hours unchanged (1–3, dev resources); feature breadth and ready for real work unchanged at this rounding | | 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–720 h | From 7e28d58b3fddcda543e6386d4abd3218e280c946 Mon Sep 17 00:00:00 2001 From: Nicek Date: Thu, 22 Oct 2026 20:36:45 +0800 Subject: [PATCH 070/208] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20ArkUI=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E7=94=9F=E6=88=90=E5=92=8C=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加 ArkUI 作为新的代码语言支持,包括: - 代码生成:支持生成 ArkTS 组件代码,包括 `@Component` 结构体、`@Prop` 参数、`@BuilderParam` 构建器等 - 代码连接:支持将组件连接到 ArkTS 代码,包括导入路径和模板配置 - 测试:添加 ArkUI 相关测试用例,验证代码生成和连接功能的正确性 - 更新文档:更新开发模式 parity 文档、文件格式一致性文档等,添加 ArkUI 相关说明 - 更新国际化:在所有支持的语言中添加 ArkUI 选项提示 --- crates/engine/src/code_connect.rs | 15 +- crates/engine/src/code_connect_tests.rs | 12 +- crates/engine/src/codegen.rs | 270 ++++++++++++++++++- crates/engine/src/component_codegen.rs | 126 +++++++-- crates/engine/src/component_codegen_tests.rs | 36 +++ crates/engine/src/playground.rs | 4 +- crates/ui-egui/locales/bn.toml | 2 +- crates/ui-egui/locales/cs.toml | 2 +- crates/ui-egui/locales/de.toml | 2 +- crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 2 +- crates/ui-egui/locales/fi.toml | 2 +- crates/ui-egui/locales/fr.toml | 2 +- crates/ui-egui/locales/id.toml | 2 +- crates/ui-egui/locales/ja.toml | 2 +- crates/ui-egui/locales/ka.toml | 2 +- crates/ui-egui/locales/ko.toml | 2 +- crates/ui-egui/locales/nl.toml | 2 +- crates/ui-egui/locales/pl.toml | 2 +- crates/ui-egui/locales/pt-BR.toml | 2 +- crates/ui-egui/locales/ru.toml | 2 +- crates/ui-egui/locales/sv.toml | 2 +- crates/ui-egui/locales/tr.toml | 2 +- crates/ui-egui/locales/zh-Hans.toml | 2 +- crates/ui-egui/src/dev_mode.rs | 2 +- docs/dev-mode-parity.md | 7 +- docs/file-format-parity.md | 2 +- docs/gaps.md | 2 +- docs/mcp.md | 15 +- docs/target-app-parity.md | 2 +- 30 files changed, 464 insertions(+), 65 deletions(-) diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs index 46340636..d63f4978 100644 --- a/crates/engine/src/code_connect.rs +++ b/crates/engine/src/code_connect.rs @@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50; /// The languages a component can be connected in. pub fn language(s: &str) -> Option { - CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose)) + CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi)) } /// The layer `id`'s links are stored on: its component set, else its component (an instance's @@ -64,7 +64,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String { let path = &link.import_path; match lang { _ if path.is_empty() => String::new(), - CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), + CodeLang::React | CodeLang::ArkUi => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()), _ => format!("import {path}"), } @@ -175,6 +175,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) -> (Some(l), CodeLang::React) => format!("<{} />", l.name), (Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name), (Some(l), CodeLang::SwiftUi) => format!("{}()", l.name), + (Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name), (Some(l), _) => format!("{{ {}() }}", l.name), (None, _) => value_expr(doc, c, p, v, lang), } @@ -222,6 +223,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String { format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::()) } CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), + CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), _ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::>().join(", ")), } } @@ -239,13 +241,14 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res } let path = &link.import_path; let path_ok = match lang { - CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'), + CodeLang::React | CodeLang::ArkUi => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'), CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'), _ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'), }; if path.chars().count() > MAX_IMPORT || !path_ok { return Err(match lang { CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"), + CodeLang::ArkUi => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"), CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"), _ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"), }); @@ -282,7 +285,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result> { /// The `language` parameter (default React). fn lang_param(cmd: &str, p: &Value) -> Result { match text(cmd, p, "language")? { - Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")), + Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose|arkui")), None => Ok(CodeLang::React), } } @@ -312,7 +315,7 @@ pub fn specs() -> Vec { spec!( "codeConnect.set", "Connect to code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}", + "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}", true, |s, p| { let cmd = "codeConnect.set"; @@ -427,7 +430,7 @@ fn import(s: &mut Session, p: &Value) -> Result { let (Some(component), Some(lang), Some(name), Some(template)) = (field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str)) else { - warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1)); + warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose|arkui), name and template strings", i + 1)); continue; }; let link = CodeLink { diff --git a/crates/engine/src/code_connect_tests.rs b/crates/engine/src/code_connect_tests.rs index 9d657d5f..6cdd8e67 100644 --- a/crates/engine/src/code_connect_tests.rs +++ b/crates/engine/src/code_connect_tests.rs @@ -72,6 +72,16 @@ fn connected_instances_show_the_real_snippet() { json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": ""}), ); assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n\n"); + // ArkUI: an ArkTS import and an object-literal call. + ex( + &mut s, + "codeConnect.set", + json!({"id": c, "language": "arkui", "importPath": "@acme/arkui", "template": "Card({ title: {title}, dotted: {Show dot} })"}), + ); + assert_eq!(code(&mut s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); + let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "arkui"})); + assert_eq!(v["usage"], "Card({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); + assert_eq!(v["connected"]["import"], "import { Card } from \"@acme/arkui\";"); assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path"); ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"})); // inspect.componentCode: the snippet as the usage, and the link. @@ -105,7 +115,7 @@ fn connected_instances_show_the_real_snippet() { assert!(code(&mut s, i, "swiftui").starts_with("import AcmeUI")); ex(&mut s, "edit.undo", json!({})); assert!(code(&mut s, i, "react").starts_with("import { Card }")); - assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 2); + assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 3); assert!(s.execute("codeConnect.remove", &json!({"id": c})).is_err(), "nothing left"); } diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 0871f886..efca85d4 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -1,9 +1,9 @@ //! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the -//! layer itself, HTML or React for its subtree, and SwiftUI or Jetpack Compose (stacks for auto -//! layout, absolute offsets otherwise). Components become components whose properties are -//! parameters and instances become calls passing their values (see [`crate::component_codegen`]); -//! variables bound to a layer become CSS custom properties. The output is a starting point, not a -//! pixel-exact translation. +//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS; +//! stacks for auto layout, absolute offsets otherwise). Components become components whose +//! properties are parameters and instances become calls passing their values (see +//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The +//! output is a starting point, not a pixel-exact translation. use figcraft_color::{Color, PaintKind}; use figcraft_doc::*; @@ -18,13 +18,13 @@ pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", - "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", + "{id?, language?: css|html|react|leptos|swiftui|compose|arkui (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", false, |s, p| { let cmd = "inspect.code"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; let lang = match string(p, "language") { - Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?, + Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose|arkui"))?, None => CodeLang::Css, }; let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { @@ -63,10 +63,13 @@ pub enum CodeLang { Leptos, SwiftUi, Compose, + /// HarmonyOS ArkUI: an ArkTS struct (`@Component`, `build`). + ArkUi, } impl CodeLang { - pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 7] = + [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", @@ -75,6 +78,7 @@ impl CodeLang { CodeLang::Leptos => "leptos", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", + CodeLang::ArkUi => "arkui", } } pub fn label(self) -> &'static str { @@ -85,6 +89,7 @@ impl CodeLang { CodeLang::Leptos => "Leptos", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", + CodeLang::ArkUi => "ArkUI", } } /// Languages whose lengths are CSS pixels (they can be shown in rem). @@ -95,6 +100,9 @@ impl CodeLang { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { return Some(CodeLang::React); } + if s.eq_ignore_ascii_case("arkts") { + return Some(CodeLang::ArkUi); + } CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s)) } } @@ -194,7 +202,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), - CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), + CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => crate::component_codegen::code(doc, id, lang), } } @@ -1135,6 +1143,218 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { } } +// ------------------------------------------------------------------ ArkUI (ArkTS) + +/// A colour as ArkUI writes it: `'#RRGGBB'`, or `'#AARRGGBB'` with transparency. +fn ark_color(c: Color) -> String { + let [r, g, b, a] = c.to_rgba8(); + if a == 255 { format!("'#{r:02X}{g:02X}{b:02X}'") } else { format!("'#{a:02X}{r:02X}{g:02X}{b:02X}'") } +} + +/// A layer's corner radii as `.borderRadius(...)` (uniform or per corner). +fn ark_radius(n: &Node, mods: &mut Vec) { + if let Some(r) = n.corners.uniform() { + if r > 0.0 { + mods.push(format!(".borderRadius({})", num(r))); + } + } else if n.corners.radii.iter().any(|r| *r > 0.0) { + let [tl, tr, br, bl] = n.corners.radii; + mods.push(format!(".borderRadius({{ topLeft: {}, topRight: {}, bottomRight: {}, bottomLeft: {} }})", num(tl), num(tr), num(br), num(bl))); + } +} + +/// ArkUI for a layer and its subtree at `depth`; `extra` attributes go last (a free child's +/// position). A layer a boolean parameter shows sits in an `if`. +pub(crate) fn arkui(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra: &[String]) { + let Some(n) = ctx.doc.node(id) else { return }; + if id != ctx.root + && let Some(p) = ctx.param(n, "visible") + { + let pad = indent(depth); + out.push_str(&format!("{pad}if (this.{}) {{\n", p.ident)); + arkui_layer(ctx, n, depth + 1, out, extra); + out.push_str(&format!("{pad}}}\n")); + return; + } + arkui_layer(ctx, n, depth, out, extra); +} + +fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) { + let doc = ctx.doc; + let pad = indent(depth); + if ctx.too_deep(depth) { + out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); + return; + } + // Nested instances are calls; an instance swap parameter is a builder. + let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) { + Some(p) => Some(format!("{pad}this.{}()\n", p.ident)), + None => ctx.call(n, CodeLang::ArkUi, depth), + }; + if let Some(call) = call { + out.push_str(&call); + for m in extra { + out.push_str(&format!("{pad} {m}\n")); + } + return; + } + let mut mods: Vec = Vec::new(); + match &n.kind { + NodeKind::Text(t) => { + let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident)); + out.push_str(&format!("{pad}Text({text})\n")); + let s = &t.style; + mods.push(format!(".fontSize({})", num(s.font_size))); + mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900))); + mods.push(format!(".fontFamily({})", literal(&s.font_family, false))); + if s.italic() { + mods.push(".fontStyle(FontStyle.Italic)".into()); + } + if let Some(c) = first_fill(n) { + mods.push(format!(".fontColor({})", ark_color(c))); + } + match s.line_height { + LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))), + LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))), + LineHeight::Auto => {} + } + match s.letter_spacing { + LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))), + LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))), + _ => {} + } + match t.align_h { + TextAlignH::Left => {} + TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()), + TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()), + TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()), + } + match s.case { + TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()), + TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()), + _ => {} + } + match s.decoration { + TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()), + TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()), + TextDecoration::None => {} + } + } + NodeKind::Ellipse { .. } => { + out.push_str(&format!("{pad}Ellipse({{ width: {}, height: {} }})\n", num(n.width), num(n.height))); + if let Some(c) = first_fill(n) { + mods.push(format!(".fill({})", ark_color(c))); + } + if let Some(c) = stroke_color(n) { + mods.push(format!(".stroke({})", ark_color(c))); + mods.push(format!(".strokeWidth({})", num(n.stroke.weight))); + } + } + NodeKind::Rectangle => { + out.push_str(&format!("{pad}Column()\n")); + if let Some(c) = first_fill(n) { + mods.push(format!(".backgroundColor({})", ark_color(c))); + } + ark_radius(n, &mut mods); + mods.push(format!(".width({})", num(n.width))); + mods.push(format!(".height({})", num(n.height))); + } + k if k.is_frame_like() || matches!(k, NodeKind::Group) => { + let open = match n.layout.as_ref().map(|l| (l.mode, l)) { + Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)), + Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)), + None => "Stack({ alignContent: Alignment.TopStart })".to_string(), + }; + out.push_str(&format!("{pad}{open} {{\n")); + match ctx.param(n, "slot") { + // A slot shows the instance's content, or else the main's (its default builder). + Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)), + None => { + for c in ctx.kids(n) { + // Free children sit at their position inside a Stack. + let pos: Vec = match doc.node(c).filter(|_| n.layout.is_none()) { + Some(cn) => { + let [_, _, _, _, x, y] = cn.transform.as_coeffs(); + vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))] + } + None => Vec::new(), + }; + arkui(ctx, c, depth + 1, out, &pos); + } + } + } + out.push_str(&format!("{pad}}}\n")); + if let Some(l) = &n.layout { + let [t, r, b, lft] = l.padding; + if [t, r, b, lft].iter().any(|v| *v > 0.0) { + mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft))); + } + let justify = match l.main_align { + MainAlign::Min => None, + MainAlign::Center => Some("FlexAlign.Center"), + MainAlign::Max => Some("FlexAlign.End"), + MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"), + MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"), + MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"), + }; + if let Some(j) = justify { + mods.push(format!(".justifyContent({j})")); + } + let cross = match l.cross_align { + CrossAlign::Min => None, + CrossAlign::Center => Some("Center"), + CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }), + CrossAlign::Baseline => None, + }; + if let Some(c) = cross { + let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" }; + mods.push(format!(".alignItems({ty}.{c})")); + } + } + mods.push(format!(".width({})", num(n.width))); + mods.push(format!(".height({})", num(n.height))); + if let Some(c) = first_fill(n) { + mods.push(format!(".backgroundColor({})", ark_color(c))); + } + if radius(n) > 0.0 { + mods.push(".clip(true)".into()); + } + ark_radius(n, &mut mods); + } + _ => { + out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image($r('app.media.asset'))\n", n.kind.label())); + mods.push(format!(".width({})", num(n.width))); + mods.push(format!(".height({})", num(n.height))); + } + } + // Ellipses take their stroke as a shape attribute (above). + if !matches!(n.kind, NodeKind::Ellipse { .. }) + && let Some(c) = stroke_color(n) + { + mods.push(format!(".border({{ width: {}, color: {} }})", num(n.stroke.weight), ark_color(c))); + } + if let Some(s) = drop_shadow(n) { + mods.push(format!( + ".shadow({{ radius: {}, color: {}, offsetX: {}, offsetY: {} }})", + num(s.radius / 2.0), + ark_color(s.color), + num(s.offset.0), + num(s.offset.1) + )); + } + if n.opacity < 1.0 { + mods.push(format!(".opacity({})", num(f64::from(n.opacity)))); + } + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 0.01 && rot.is_finite() { + mods.push(format!(".rotate({{ angle: {} }})", num(-rot))); + } + mods.extend(extra.iter().cloned()); + for m in mods { + out.push_str(&format!("{pad} {m}\n")); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1174,6 +1394,33 @@ mod tests { assert!(s.execute("inspect.code", &json!({"id": f, "language": "cobol"})).is_err()); } + #[test] + fn arkui_for_an_auto_layout_card() { + let mut s = Session::new(); + let f = card(&mut s); + let doc = s.doc().unwrap(); + let ark = code_for(doc, f, CodeLang::ArkUi); + for want in [ + "Row({ space: ", + "Text(\"Say \\\"hi\\\" $5\\nnow\")", + ".fontSize(", + ".fontWeight(", + "Ellipse({ width: 24, height: 24 })", + ".backgroundColor('#FFFFFF')", + ".borderRadius(12)", + ".width(", + ".height(100)", + ".clip(true)", + ] { + assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}"); + } + // Through the command, and "arkts" reads as ArkUI too. + let v = s.execute("inspect.code", &json!({"id": f, "language": "arkui"})).unwrap(); + assert_eq!(v["code"].as_str().unwrap(), ark); + assert_eq!(v["language"], "arkui"); + assert_eq!(s.execute("inspect.code", &json!({"id": f, "language": "arkts"})).unwrap()["language"], "arkui"); + } + #[test] fn free_children_get_offsets_and_deep_trees_are_capped() { let mut s = Session::new(); @@ -1189,6 +1436,9 @@ mod tests { assert!(sw.contains("…"), "depth capped"); let kt = code_for(doc, root, CodeLang::Compose); assert!(kt.contains("Modifier.offset(7.dp, 9.dp)")); + let ark = code_for(doc, root, CodeLang::ArkUi); + assert!(ark.contains(".position({ x: 7, y: 9 })"), "{ark}"); + assert!(ark.contains("…"), "depth capped"); assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty()); } @@ -1204,7 +1454,7 @@ mod tests { .unwrap(); } let doc = s.doc().unwrap(); - for lang in [CodeLang::SwiftUi, CodeLang::Compose] { + for lang in [CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi] { let code = code_for(doc, root, lang); assert!(code.contains("// deep evil() more() …\n"), "{code}"); for line in code.lines() { diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index 1416b15f..c52693c5 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -1,16 +1,17 @@ -//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack -//! Compose component whose properties are its parameters (a variant property → a union or enum, boolean → -//! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot → -//! optional content), and an instance becomes a call passing the values it sets. Variants of a set -//! share one definition (the set's default variant); in React and Leptos their differences become CSS -//! rules on `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component -//! connected to code (see [`crate::code_connect`]) shows its real snippet instead. +//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI, Jetpack +//! Compose or ArkUI component whose properties are its parameters (a variant property → a union or +//! enum, boolean → a flag showing the layers bound to it, text → a string, instance swap → a view +//! parameter, slot → optional content), and an instance becomes a call passing the values it sets. +//! Variants of a set share one definition (the set's default variant); in React and Leptos their +//! differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode` and +//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its +//! real snippet instead. use figcraft_doc::*; use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::codegen::{CodeLang, Ctx, Markup, class_name, compose, indent, literal, swift}; +use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift}; use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set}; use crate::params::*; use crate::{bad, spec}; @@ -67,14 +68,14 @@ pub fn specs() -> Vec { vec![spec!( "inspect.componentCode", "Component code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}", + "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}", false, |s, p| { let cmd = "inspect.componentCode"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?; let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) { - Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l, - Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")), + Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))) => l, + Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose|arkui")), None => CodeLang::React, }; let doc = s.doc()?; @@ -107,9 +108,9 @@ pub fn specs() -> Vec { )] } -/// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's -/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it -/// in a function component). +/// Code for any layer in React, Leptos, SwiftUI, Compose or ArkUI: an instance's call and its +/// component's definition, a component's (or set's) definition, or the layer tree (React and +/// Leptos wrap it in a function component). pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { let Some(n) = doc.node(id) else { return String::new() }; if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) { @@ -129,6 +130,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { swift(&Ctx::new(doc, id), id, 0, &mut out, &[]); out } + CodeLang::ArkUi => { + let mut out = String::new(); + arkui(&Ctx::new(doc, id), id, 0, &mut out, &[]); + out + } _ => { let mut out = String::new(); compose(&Ctx::new(doc, id), id, 0, &mut out); @@ -289,7 +295,7 @@ const RESERVED: &[&str] = &[ "yield", ]; -/// Types the generated SwiftUI and Compose code names: a variant enum may not shadow them. +/// Types the generated SwiftUI, Compose and ArkUI code names: a variant enum may not shadow them. const TYPES: &[&str] = &[ "Alignment", "AnyView", @@ -298,6 +304,7 @@ const TYPES: &[&str] = &[ "Boolean", "Box", "CaseIterable", + "Circle", "CircleShape", "Color", "Column", @@ -306,21 +313,30 @@ const TYPES: &[&str] = &[ "EdgeInsets", "Ellipse", "EmptyView", + "FlexAlign", "Font", + "FontStyle", "FontWeight", "Group", "HStack", + "HorizontalAlign", "Image", "Modifier", + "Rect", "RectangleShape", "RoundedCornerShape", "RoundedRectangle", "Row", "Shape", + "Stack", "String", "Text", + "TextAlign", + "TextCase", + "TextDecorationType", "Unit", "VStack", + "VerticalAlign", "View", "ZStack", ]; @@ -482,6 +498,9 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value (Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)), (None, CodeLang::Leptos) => "|| ()".into(), (Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"), + // ArkUI takes a builder: `@BuilderParam` is a `() => void`. + (Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"), + (None, CodeLang::ArkUi) => "() => {}".into(), (Some(t), _) => format!("{{ {t}() }}"), (None, CodeLang::React) => "null".into(), (None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(), @@ -561,6 +580,11 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option format!("{pad}<{t}{attrs} />\n") } CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", ")), + CodeLang::ArkUi => { + let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", "); + // ArkTS calls take an object literal, or nothing at all. + if list.is_empty() { format!("{pad}{t}()\n") } else { format!("{pad}{t}({{ {list} }})\n") } + } _ => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n} = {e}")).collect::>().join(", ")), }) } @@ -593,6 +617,13 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S } format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1)) } + CodeLang::ArkUi => { + let mut out = String::new(); + for k in kids { + arkui(&ctx, k, depth + 2, &mut out, &[]); + } + format!("() => {{\n{out}{}}}", indent(depth + 1)) + } _ => { let mut out = String::new(); for k in kids { @@ -611,6 +642,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String { CodeLang::SwiftUi => swift_definition(doc, c), CodeLang::Compose => compose_definition(doc, c), CodeLang::Leptos => leptos(doc, c), + CodeLang::ArkUi => arkui_definition(doc, c), _ => react(doc, c), } } @@ -807,3 +839,67 @@ fn compose_definition(doc: &Document, c: &Component) -> String { out.push_str("}\n"); out } + +/// An ArkUI (`@Component`) struct: variant enums, `@Prop` parameters, a default builder for each +/// instance swap and slot, and the `build` body. +fn arkui_definition(doc: &Document, c: &Component) -> String { + let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 1 }; + let mut out = String::new(); + for p in &c.params { + if let ParamKind::Variant(opts) = &p.kind { + out.push_str(&format!("enum {} {{ {} }}\n\n", enum_name(c, p, false), cases(opts, false).join(", "))); + } + } + // A default builder per instance swap or slot, named apart from every parameter. + let mut used: Vec = c.params.iter().map(|p| p.ident.clone()).collect(); + let mut builders: Vec<(String, String)> = Vec::new(); + for p in &c.params { + if !matches!(p.kind, ParamKind::InstanceSwap | ParamKind::Slot) { + continue; + } + let base = format!("default{}Builder", capitalized(&p.ident)); + let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}{k}") }).find(|n| !used.contains(n)).unwrap_or(base); + used.push(name.clone()); + builders.push((p.ident.clone(), name)); + } + out.push_str("@Component\nexport struct "); + out.push_str(&c.type_name); + out.push_str(" {\n"); + for p in &c.params { + match &p.kind { + ParamKind::Variant(_) => { + out.push_str(&format!(" @Prop {}: {} = {}\n", p.ident, enum_name(c, p, false), value_expr(doc, c, p, &p.default, CodeLang::ArkUi))) + } + ParamKind::Boolean => out.push_str(&format!(" @Prop {}: boolean = {}\n", p.ident, p.default.as_bool().unwrap_or(false))), + ParamKind::Text => out.push_str(&format!(" @Prop {}: string = {}\n", p.ident, value_expr(doc, c, p, &p.default, CodeLang::ArkUi))), + ParamKind::InstanceSwap | ParamKind::Slot => { + let name = builders.iter().find(|(ident, _)| *ident == p.ident).map_or("", |(_, name)| name.as_str()); + out.push_str(&format!(" @BuilderParam {}: () => void = this.{name}\n", p.ident)); + } + } + } + for (ident, name) in &builders { + let Some(p) = c.params.iter().find(|p| p.ident == *ident) else { continue }; + out.push_str(&format!("\n @Builder\n {name}() {{\n")); + match &p.kind { + ParamKind::InstanceSwap => { + if let Some(t) = id_of(&p.default).and_then(|i| component_type(doc, i)) { + out.push_str(&format!(" {t}()\n")); + } + } + ParamKind::Slot => { + let layer = slots_of(doc, c.comp).into_iter().find(|(_, slot)| slot == &p.prop).map(|(layer, _)| layer); + let kids = layer.and_then(|l| doc.node(l)).map(|n| ctx.kids(n)).unwrap_or_default(); + for k in kids { + arkui(&ctx, k, 2, &mut out, &[]); + } + } + _ => {} + } + out.push_str(" }\n"); + } + out.push_str("\n build() {\n"); + arkui(&ctx, c.comp, 2, &mut out, &[]); + out.push_str(" }\n}\n"); + out +} diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index 4cd3d776..dc279724 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -71,6 +71,17 @@ fn components_take_their_properties_as_parameters() { for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] { assert!(kt.contains(want), "Compose has {want:?}:\n{kt}"); } + let ark = code(&mut s, c, "arkui"); + for want in [ + "@Component\nexport struct Card {", + " @Prop showDot: boolean = true", + " @Prop title: string = \"Hello\"", + " build() {", + " if (this.showDot) {", + "Text(this.title)", + ] { + assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}"); + } let leptos = code(&mut s, c, "leptos"); for want in [ "use leptos::prelude::*;\n\n#[component]\npub fn Card(\n", @@ -117,12 +128,14 @@ fn instances_are_calls_passing_the_values_they_set() { assert!(react.contains("export function Card({")); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n"); + assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); // Text that needs escaping is an expression in JSX and escaped per language. ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}})); assert_eq!(component_code(&mut s, i, "react")["usage"], "\n"); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n"); + assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); } @@ -173,6 +186,9 @@ fn variant_sets_share_one_definition() { let kt = code(&mut s, set, "compose"); assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}"); assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}"); + let ark = code(&mut s, set, "arkui"); + assert!(ark.contains("enum ButtonState { Default, Hover }"), "{ark}"); + assert!(ark.contains(" @Prop state: ButtonState = ButtonState.Default"), "{ark}"); let leptos = code(&mut s, set, "leptos"); for want in [ "#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}", @@ -189,6 +205,7 @@ fn variant_sets_share_one_definition() { assert_eq!(component_code(&mut s, i, "react")["usage"], "