Commit Graph

152 Commits

Author SHA1 Message Date
bflatastic ac6adc2d54 M7: imported nested instances link to their main's layers
A nested instance opened from a .fig or file JSON document is first linked to its
own component; matching it to the outer main's layer then went by that component's
name instead of the layer's, so nested instances of variants (or under a name of
their own) and their layers stayed unlinked, and a second export lost what they
changed. They go by their own name until they mirror a layer.
2026-10-13 10:05:39 +07:00
bflatastic c910060f36 M7: .fig export: one-sided stroke widths as outlines, bound layer fields name their field; docs
A variable width is written only as the same reach on both sides of the path (a
point with unequal sides drew nothing elsewhere). A stroke wider on one side, on a
shape that shows nothing else, is written as a vector filled with the area it
paints, with the stroke itself kept as plugin data that the import puts back.
Bound layer and text fields also name the number of the field they set. Docs: text,
effect and grid styles and symmetric variable widths are checked; non-colour
bindings come across as values.
2026-10-13 10:05:39 +07:00
bflatastic 3efff16df2 M7: .fig export: stroke reach as a share of the weight; docs
Variable-width points say each side's reach as a share of the stroke weight (in
pixels they drew far too wide elsewhere). Progress docs: instance sizes, variables
with modes and shared styles in the .fig export, and what was checked by opening
written files in other software.
2026-10-13 10:05:39 +07:00
bflatastic 794b83a0db Sharing: the default server is a build-time setting
FIGCRAFT_DEFAULT_SERVER, set when compiling, names another server or, set to nothing, ships a
build with no default: online sharing then stays off until the user enters a server in
Preferences. Normal builds and the release workflow are unchanged.
2026-10-13 03:01:34 +07:00
bflatastic 511213e820 Docs: terms and privacy for online sharing through the default server 2026-10-13 03:01:34 +07:00
bflatastic 9c9ccecbc5 M11: starter templates: parity checklist regenerated after the rebase 2026-10-13 03:01:19 +07:00
bflatastic 5be3012e9b M11: starter templates: docs and the essentials assessment 2026-10-13 03:01:19 +07:00
bflatastic 31815f1d13 M9: online files: crash recovery never drops an unsent edit
The recovery copy of one of your online files is no longer discarded when
the file reopens from its server. It is kept until the outcome is known:

- the server has everything in it: the copy goes;
- the copy has changes that never reached the server and nothing else
  changed there: they are applied on top as one edit and sent to everyone;
- anything else (others changed the file, the server forgot the client):
  the copy opens beside the file as "<name> (recovered copy)", with a toast;
- the server can't be reached: the copy opens as an unsaved document.

The note beside a copy is written after it and records who the app was in
the file and the server's last change it had. The app joins again as that
client (the first hello resumes; a new sync client numbers its batches
after the ones the server processed), so the layers it made are its own to
send. The old copy is removed only once this run has copied what it became.
collab.recoverFile; a tooltip on the tab mark; docs.
2026-10-13 01:41:14 +07:00
bflatastic 501e4a6737 M9: online files: the app remembers the files it put online
A file put on a server without the server's key stays its owner's after its
tab closes or the app quits. The app records it with the preferences (server,
id, name, owner token, the owner's links, what others join with; at most 200;
validated when read back) and never shows the token: not in command results,
ui.inspect, logs or debug output.

- Home > Online lists your online files: open one as its owner (a new tab, or
  its tab comes to the front), start and stop sharing, copy the code or link,
  rename on the server, remove from the server (confirmed), forget here. One
  request per server when the section opens; offline and gone states.
- collab.files, collab.openFile, collab.forgetFile; collab.manageFile takes
  its server and token from the record and gains share / unshare.
- Server: Admin owned (a compatible addition within protocol version 2) says
  what became of the files a list of owner tokens owns. Older servers leave
  the list as recorded.
- Tabs mark files in a live session (shared, or open only to you); toasts
  about a file that isn't in front name it; after a crash a recorded file
  reopens from its server as its owner.
- Draft catalogs (de, fr, ja, ko, pt-BR, zh-Hans) translate the 71 strings
  and 5 command labels they lacked and the new ones; es and es-419 follow.
- Docs: collab-protocol, server, mcp, control-protocol, parity pages, gaps,
  ROADMAP.
2026-10-13 01:41:14 +07:00
BFlat 517b1a02ce
Docs: privacy and online sharing (#167)
The README gains a Privacy and online sharing section (what Share > Online uploads and where, how long it stays, encrypted in transit but not end-to-end, codes and links are the access, local network sharing is direct and unencrypted, no guarantees), its 'Offline and private' blurb no longer says 'no cloud', and docs/server.md's Security section says the same for operators.
2026-10-13 00:49:06 +07:00
bflatastic 39e9e87b74 M1: number fields: scrub rates, arrow steps, arithmetic, relative edits of mixed values
- Scrubbing the label: a step a point, x10 with Shift, x0.1 with Alt, each
  stretch at its own rate, counted from the press; on past the screen edge
  where the platform reports the mouse's motion.
- Arrow keys: pixel fields by the nudge preferences (Shift: the big nudge),
  other fields by their own step and ten of it; also with the keyboard on
  the field; the stepped value stays selected.
- Typing: brackets and units in arithmetic; +10, *2, /4 and (outside
  percentage fields) 50% are relative to the value.
- A field left as it was commits nothing (no undo step, no rounded display
  written back); Tab commits and moves on; Escape restores.
- Mixed values: a relative edit or an arrow key goes to each layer from its
  own value in one undo step (X, Y, W, H, rotation, opacity, corner radius,
  stroke weight).
- Fields take keys in a window that isn't in front (control channel).
- Docs for the everyday editing partials: ui-parity rows, ROADMAP,
  target-app-parity, gaps, mcp, control protocol, the parity checklist
  (211/211).
2026-10-13 00:29:41 +07:00
bflatastic 8f6a123f29 M9: share with a join code: stop sharing keeps the file
Online, the file is its owner's: others get links and codes of their own, and
Stop sharing (collab.stop) revokes those on the server. Whoever joined with
them is disconnected at once and told sharing was stopped (the session's end,
with their copy); the owner stays in the file, which stays on the server, and
Start sharing gives the same file new links and codes. Removing the file is a
separate, confirmed step under Advanced.

The join field is the shared text input: as tall as the Join button beside it.
2026-10-12 19:42:28 +07:00
bflatastic 6720ea4a21 M9: share with a join code: online through the built-in server or on the local network
The Share popover is one flow: Online (the file is published to the server in
Preferences and the server names its links with short codes) or Local network
(the app hosts; the code carries its address and the session's secret), one
Start sharing button, the code large with Copy code and Copy link, who is here,
can edit / can view, and Stop sharing. Join with a code is one field in the
popover, on the home screen and in the Join dialog, for a code of either kind
or an invite link.

Protocol (within version 2, new variants last): Resolve / Resolved and Admin
code. The server keeps a stretched hash and the wrapped token, never the code,
and limits wrong codes per address (with a lockout) and for everyone. 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 server
preference defaults to the built-in server and can be reset or turned off.
2026-10-12 19:42:28 +07:00
bflatastic f77d9a467e M3: text metrics: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only
Measured against documents re-saved by other software. A line's height is rounded to whole
pixels (halves up) with the baseline keeping its fraction inside it; a glyph's advance and its
kerning are each rounded to 1/1024 em before scaling; a line's width and wrapping leave out the
letter spacing after its last visible glyph. Rule tests on built-in fonts, a local-only check over
such documents, and three behaviour expectations updated to the new rules.
2026-10-12 17:54:18 +07:00
bflatastic 94349d86b0 M9: live sessions per document
Live sessions are per document instead of one for the whole app.

- **Engine** (`crates/engine/src/live.rs`): `Session.live` is now `Lives`: the app-wide parts (the
  installed link, the invite-link registration) plus one `Live` per session. One place resolves
  "the session of this document": `Session::live()` (the active document's; status off when it
  has none), `live_mut()`, `live_of(uid)`. Sync, undo, read-only checks, follow, cursor chat,
  spotlight and presence all go through the session of the document they are about, so edits and
  presence never cross. Sessions behind the active tab keep syncing; your presence there is empty.
- **Link** (`LiveLink`): every call takes the `SessionId` the engine picked and `poll` returns
  `(SessionId, Event)`, so one link carries several sessions. The desktop link and the browser
  link keep them in a shared table with one mailbox per session
  (`crates/collab-net/src/sessions.rs`); each hosted document listens on its own port.
- **Commands**: `collab.*` act on the active document's session; no parameters changed.
  `collab.peers` keeps its shape and gains `session` (its id, null when the document isn't in
  one) and `sessions` (every session: `{session, document, status, reason, role, fileName, link,
  peers}`). `collab.join` returns the new session and opens its file in a tab of its own without
  touching other sessions; until then (or if it fails) it shows on the tab it was started from.
  Starting a session for a document replaces only that document's own session. Closing a document
  ends its session only.
- **UI** (`crates/ui-egui/src/live.rs`): the Share popover, avatars, cursors, chat, spotlight
  banner, follow frame and read-only reason read the session of the tab in front, per window. What
  belongs to one session in the window (cursors shown, your chat box, a report being written) is
  dropped when another tab comes to the front. `ui.inspect`'s `live` gains `session` and
  `sessions`. No new strings or command ids.
- **Docs**: `docs/collab-protocol.md`, `docs/mcp.md`, `docs/control-protocol.md`,
  `docs/collaboration-parity.md`, `docs/gaps.md` (gap 2), `ROADMAP.md`.
2026-10-12 17:54:18 +07:00
bflatastic 0e6dc6871b M7: .fig export that opens and draws in other software
- The document and its pages now say they are visible and opaque, and a page carries its background opacity and enabled flag. Left out, a reader took them as hidden and drew nothing: every frame was blank and the page background transparent.
- The schema written into the file uses the format's own names, kinds (struct or message), field ids and enum values for everything the export writes. The schema text parser takes `NAME=n` and `type field=n`.
- Rectangles are written as rounded rectangles with their four radii. Frames, components, instances and sections carry their fill outline; plain strokes carry their stroke outline (a ring around boxes and whole ellipses, the widened path of other centred strokes).
- Text carries its layout settings and one line entry per paragraph. Font styles are written under their standard names ("SemiBold" becomes "Semi Bold", "Regular Italic" becomes "Italic"); the import reads both spellings as the same style.
- A line height that is a share of the font size is written as a raw multiplier: percent units count from the font's own line height, so 140 % was laid out taller than in FigCraft.
- Masks keep their type (alpha, outline, luminance), written and read back.
- The import reads per-side stroke weights whose zero sides are left out (a bottom-only border no longer comes back on all four sides).
- `meta.json` gives the page colour and the thumbnail size; archive members are in the usual order. Texture effects are written under the format's grain type; WebP export settings are written as PNG with a warning (the format has none).
- Docs: `docs/file-format-parity.md`, `docs/gaps.md` (gap 1), `ROADMAP.md`, `docs/target-app-parity.md` (wording only; no number moves).
2026-10-12 17:54:18 +07:00
bflatastic 2cf4510cbd M1: a double-click that enters a text selects all of it
A double-click in the text already being edited still selects the word.
2026-10-12 17:54:18 +07:00
BFlat 47fff74655
M1: double-clicks into text in groups, and a drag threshold (#160)
A move or a resize starts only once the pointer has travelled 3 px on screen, and a double-click drops the gesture its second press began, so double-clicking text inside a group reliably selects it and then edits it instead of sometimes dragging or resizing it.
2026-10-12 16:52:37 +07:00
BFlat 8df9998044
Docs: retake the README screenshots with the new app mark (#158)
The seven docs/images/shot-*.png are captured again from a build with the curled-tail mark: same files, 1440x900, same example documents, views and selections, so README.md is unchanged. The art-*.png renderer exports have no UI and stay as they are.
2026-10-12 15:08:23 +07:00
bflatastic 98c4753118 M9: open hosting: fair limits for shared addresses
Third review:
- a broadcast counts once per recipient block, welcomes don't count
  against block caps, and presence, cursor chat and pings have their own
  per-address budget, so an office behind one address works together
- when everyone's reading budget runs out, the biggest readers are cut
  off until it fits (their bytes freed at once), no cliff
- block counters are forgotten once only the table holds them
- install.sh: root's downloads, clone, swap file and built binary in a
  root-only directory; the build user builds its own copy; the binary is
  copied out with the build user's rights
- recommended open-mode settings in install.sh's config and the docs
2026-10-12 11:40:03 +07:00
bflatastic e0435d4eb4 M9: close the remaining open-hosting DoS paths
Second security review:
- a broadcast counts once against the queued-bytes budget; each block of
  addresses gets at most an eighth; when the budget runs out the biggest
  consumer is shed, not the receiver
- forced compactions at most once a minute per file
- plugin downloads check the per-address share before the global budget
  and before reading
- install.sh never replaces a Caddyfile with other sites (one import line,
  once; install-test.sh in packaging lint); the built binary runs only as
  the server's account
- bytes being read count against per-block and global budgets, the
  biggest readers dropped first
- block multipliers x2 (/56) and x4 (/48), connection caps at most a
  sixteenth/eighth of all; global publish and download budgets eight times
  a /48's share
- problems capped at 200; writes refused below max(1 GiB, two files) free;
  the private-server file cap documented
2026-10-12 11:40:03 +07:00
bflatastic 7edc61b8d2 M9: harden open hosting against resource exhaustion
Fixes from the security review of the public server:
- team libraries only in key-published files on open servers; unpacking
  bounded by the file cap (zip bombs refused)
- byte-bounded send queues per connection and in all, a bound on bytes
  waiting for the hub, per-address and global download budgets
- batches checked against the file, per-address and total caps as files
  grow (refused with a Reject the app rolls back); the core's recent-batch
  memory bounded in bytes; nothing confirmed when a log can't be written
- usage reports kept in memory; per-kind message caps checked while read
- layered IPv6 limits (/64, /56, /48), a global publish rate, connections
  reserved for sessions, short idle timeout for one-shot requests
- revoked links and removed files cut library connections
- data directory 0700, server key 0600 from creation
- install.sh: SSH ports from sshd, ufw left off when other services
  listen, unprivileged builds of the latest tag, settings checked before
  use, own Caddy snippet; systemd memory and network caps; backups check
  free space and say they hold the key
- linger reads raw bytes after an oversized frame (tungstenite asserted)
2026-10-12 11:40:03 +07:00
bflatastic faf2e78c9d M9: install.sh, backup timer and docs for a public figcraft-server
packaging/server/install.sh sets up Debian/Ubuntu behind Caddy: binary from
its release archive, the source tree it sits in, the latest release or a
clone (temporary swap under 2 GB of memory), user, dirs and config only when
missing, systemd unit, Caddy site, nightly backup timer, ufw for 22/80/443;
safe to re-run for upgrades. docs/server.md gains Running a public server
(limits, expiry, moderation, operator responsibility, no end-to-end
encryption, threat model) and an install.sh walk-through; protocol, MCP,
CLI and parity docs updated.
2026-10-12 11:40:03 +07:00
bflatastic f2802cca02 M7: headless renders use system fonts
Headless runs (`figcraft-cli export`, `render`, `convert`, `run`, and the headless MCP server) never indexed the installed fonts, so a text layer in an installed family (Segoe UI, Inter installed per user, ...) was measured and drawn with a fallback face, while the desktop app used the real one. Auto layout sizes from MCP agents therefore differed from what users see in the app.

- `figcraft_text::scan_system_fonts_on_demand()`: the font database indexes the system fonts the first time it is used (first text layout, font lookup or font listing). `figcraft-cli` turns it on at startup for every subcommand, so a run that touches no text never pays for the scan. The desktop app keeps its background scan.
- `scan_system_fonts()` now runs once per process (a `OnceLock`): racing callers wait for the one directory walk instead of each walking the folders.
- Opt-out for reproducible renders: `--no-system-fonts` on any subcommand, or `FIGCRAFT_NO_SYSTEM_FONTS=1` (honoured by the desktop app too). Text then uses only the built-in fonts, as before.
- The scan is cheaper: it reads each file's headers, table directories and `name` tables instead of whole files (about 460 MB on this Windows machine), and takes file metadata from the directory listing instead of a lookup per file. This also speeds up the desktop app's background scan.
- Browser/wasm build unchanged (the scan is a no-op there).
- Docs: `docs/cli.md` (new Fonts section) and `docs/mcp.md` (Fonts under the backends table); CLI usage text mentions the flag.
2026-10-12 09:32:42 +07:00
bflatastic d988a30607 Docs: README showcase screenshots and example files 2026-10-12 08:57:26 +07:00
bflatastic e01f4c1a43 Docs: shortcut counts, draft coverage and the box-selection label after the polish pass 2026-10-12 07:53:24 +07:00
bflatastic 6d1804ddc5 M9: draft catalogs catch up
Every draft catalog translates every string again.

- French, German, Brazilian Portuguese, Japanese and Korean: the 66 UI strings and 17 command labels
  added since they were drafted (cursor chat and spotlight, the plugin directory and its install
  dialog, Rename layers, the presentation, snapping and panning gesture labels, list levels, the
  rotation origin and placing with Enter, widgets, `view.pan`, `view.multiplayerCursors`,
  `proto.evaluate`...).
- Simplified Chinese: the 46 UI strings and 12 command labels it lacked.
- Latin American Spanish overlay: only the entries worded differently there, "Control" for a
  gamepad controller and "presiona" in the plugin directory hint (90 entries with the Enter keycap
  added in the previous commit).
- Translated from our English in each file's existing conventions (French no-break spaces before
  colons, German formal address and "Umschalt"/"Strg", Brazilian terms, concise Japanese and Korean
  without polite endings, full-width punctuation in Japanese and Chinese, keycaps and placeholders
  kept). The catalogs stay drafts: `COMPLETE` still lists only Spanish.
- docs/localization-parity.md: strings re-measured (1,417 UI keys, 536 command labels, 29 menu
  wordings: 1,982 entries), every draft at 100%, the "lacks the newer strings" notes removed, header
  and revision row. Localization grade unchanged.
2026-10-12 07:53:24 +07:00
bflatastic 29e31c2054 M1: rotation origin and place-with-Enter shortcuts
The last two documented shortcuts the parity table listed as missing, and a stale toast.

- **Alt+R: rotation origin** (both platforms). `transform.rotationOrigin {on?}` shows (or hides) a
  handle at the selection's rotation origin: a ring with a cross through it, drawn in code. Drag it
  to move the origin (it snaps to the selection box's corners, edge middles and centre within the
  snap distance); double-click it to put it back at the centre; Escape hides it before it deselects,
  and Alt+R again hides it too. `transform.setRotationOrigin {x, y} | {reset: true}` does the same
  from the CLI, MCP and the control channel. The corner rotation handles, the rotation field
  (`node.set {rotation}` on the selection: each layer's point under the origin stays put, so flipped
  layers work too), Rotate 90/180 and `transform.rotate` without `cx`/`cy` turn about it. The
  origin is stored on the document state per selection, in the first layer's own coordinates, so it
  follows the layers as they move and turn; it is dropped when the selection changes and is not
  saved in the file (the public documentation doesn't say it persists; pinned in the parity row).
- **Enter places an object with the chosen tool.** With a shape or frame tool chosen, Enter on the
  canvas, or Enter again on the toolbar's chosen tool (after picking it from the toolbar with the
  keyboard), places what a click with the tool makes (100x100; lines 100 long; sections 400x300, the
  existing click sizes, now one `ToolId::click_size`) centred in the view, selected, as one undo
  step; the canvas then takes the keyboard. New `tool.place {tool?, x?, y?}`; MCP's headless `key
  Enter` does the same. The documented crosshair that the arrows move before placing is not built.
- **Keyboard box selection toast** (and its screen-reader text) now names both ways to size the box:
  "Shift+arrows or Cmd+arrows" (Ctrl on Windows and Linux). The changed key is renamed in every
  catalog with its translation updated.
- **Shortcuts panel labels:** a label too long for its column was measured in a smaller font than
  it is drawn in, so long ones (several Spanish rows) ran into their keycaps; it is now measured in
  its own font, ends with "..." before the keycaps and shows in full on hover. The new Tools row
  reads "Place a shape or frame (its tool chosen)" with an Enter keycap.
- **Parity table:** the two rows move from Missing to bound (Alt+R -> `transform.rotationOrigin`;
  Enter -> a Tools-tab gesture row, the keymap's Enter staying `select.children`); counts 129
  documented, 128 bound, 1 moved, 0 missing, 2 kept by a platform, 48 pinned.
- New command labels in Spanish (`tool.place`, `transform.rotationOrigin`,
  `transform.setRotationOrigin`), new UI strings in English and Spanish (es-419 overlays "Enter").
- Docs: ui-parity.md (selection and transform rows, shortcut counts, header, revision row),
  mcp.md (the new commands).
2026-10-12 07:53:24 +07:00
bflatastic c420721597 M6: experimental .fig export
An experimental, best-effort `.fig` export, so work done in FigCraft can be handed back as a `.fig` file. It is written from the same public format write-ups the importer uses and is the inverse of the importer's mapping. Only round trips through FigCraft's own importer are verified; other apps may not open every file.

- **Encoder** (`crates/format/src/fig/kiwi.rs`): the kiwi encoder that test fixtures used is now shipped code. It never panics and checks every value against the schema: unknown fields or enum names, missing struct fields, wrong types, out-of-range numbers and non-finite floats are errors, and NULs inside strings are dropped. Varints and floats are written exactly the way the decoder reads them. `Schema::parse` reads the compact text schema form, and the importer's tests use it too.
- **Writer** (`crates/format/src/fig/export.rs`): writes the zip (`canvas.fig`, `meta.json`, `thumbnail.png`, `images/<hash>`). `canvas.fig` holds the fig-kiwi header, then a raw-deflate binary schema and a raw-deflate message (flate2's pure-Rust backend, already in the tree; no new dependencies). The schema covers only the fields we write. The canvas, images, blobs, node count and nesting depth are capped, and floats are made finite before they are written.
- **Mapping:** pages; frames, groups, sections, rectangles, ellipses (arcs), polygons, stars, lines and vectors (vector network blobs with per-point radius, mirroring and caps, plus fill outlines); text with styled runs (UTF-16 indexed) and list types; slices; boolean groups; solid, gradient and image paints (image bytes included); strokes; effects; corners; constraints; masks; auto layout, including grids with tracks and cells; child layout; layout grids; export settings; components, component sets and their properties. Instances are written as their main plus the fields their layers change, by id path. This covers nested instances and swaps, and fields the main sets but the instance clears. Prototype interactions (triggers, navigation and overlay actions, transitions, easing), scrolling and flow starts are included. Everything skipped is reported in a warnings list: style links, variables, comments, Dev Mode data, plugin data, guides, slots, brush and width-profile strokes, pattern fills, image adjustments, variable and conditional actions, and more.
- **Import fixes found by the round trips** (`map.rs`): ellipse arcs, boolean operation types and component set descriptions are now read. Durations are read as whole milliseconds, because seconds stored as 32-bit floats used to truncate by 1 ms.
- **File writes:** a failed write no longer leaves its `.tmp-figcraft` file behind. The junk-parameter sweep found this.
- **Commands and UI:**
  - `file.export {format: "fig"}`, or any path ending in `.fig`, writes the whole file and returns `warnings`. Without a path it returns base64 `data`.
  - File > Export... has a new "The whole file" section with a ".fig (experimental)" row. A note under it says other apps may not open every exported file. After export, a toast reports how many kinds of content were left out.
  - `figcraft-cli export --format fig` (or `-o x.fig`) and `convert` to `.fig` print the warnings as `note:` lines.
  - MCP's `export` accepts `format: "fig"` and passes the warnings through.
  - New UI strings have English and Spanish entries.
- **Docs:** file-format-parity.md has a new export section covering what is written, what is skipped, and that only round trips through our own importer are verified. Also updated: mcp.md, cli.md, gaps.md (gap 1), target-app-parity.md and ROADMAP.md. Grading is kept low. Import/export goes to 86%/58% and file formats to 60%. The file-exchange discount stays at x0.85, because nothing shows that another app opens these files.
2026-10-12 07:19:57 +07:00
bflatastic 8d5f8a0978 M1: keyboard shortcut parity suite and bindings
- A shortcut parity table (`crates/engine/src/shortcut_parity_tests.rs`): one row per shortcut in
  the public documentation (help-centre feature articles: keyboard layouts, text properties,
  alignment, frames, components, prototypes, plugins, keyboard navigation), with the macOS and the
  Windows/Linux keys, what handles it here (keymap command, held key/gesture listed in the panel,
  vector edit tool key, or missing), and a note in our own words. Rows the documentation is silent
  on pin today's keys, so every default binding is in the table.
  - 129 documented rows: 126 bound (110 before), 1 moved off a documented conflict (Run last
    plugin on Windows: the docs also give Ctrl+Alt+P to pixel preview; the plugin keeps
    Ctrl+Alt+Shift+P), 2 missing (moving the rotation origin with Alt+R; placing an object with
    Enter after choosing a tool from the toolbar), 2 kept by a platform (Ctrl+Space with some CJK
    input methods; F6 in browsers). 48 undocumented bindings pinned.
  - Tests: every row fires its command on both platforms and sits in the right shortcuts-panel tab;
    every default binding has a row; no binding uses a chord the OS keeps (Cmd+H/M/Q/Tab/Space,
    Ctrl+Space, screenshot keys on macOS; Alt+Space/Tab/F4, Ctrl+Esc, Ctrl+Alt+Delete, Linux
    workspace and VT keys); macOS keycaps show Cmd/Option glyphs only and Windows/Linux keycaps
    Ctrl/Alt words only; vector edit tool keys; counts pinned for the docs.
- Newly bound: A (Frame tool), Shift+' (pixel grid; Cmd+' kept, the articles disagree),
  Alt+Cmd+\ / Ctrl+Alt+\ (others' cursors in a live session), Ctrl+F6 (toolbar focus on
  Windows/Linux), Shift+Cmd+U (link text; Cmd+K kept), Cmd/Ctrl+] and [ (list levels while
  editing), Cmd/Ctrl+Backspace (ungroup), Alt+Space / Ctrl+Space (keyboard box selection; F8 kept,
  Cmd/Ctrl+arrows draw the box out), Ctrl+Shift+A / Ctrl+Alt+Shift+A (suggest auto layout),
  Ctrl+Alt+Cmd+K / Ctrl+Alt+Shift+K (go to main component).
- New commands: `view.multiplayerCursors {on?}` (View menu item, menu breadth 207/207),
  `view.pan {dx, dy, big?}` (arrows pan the view by 20 screen px, 200 with Shift, when nothing is
  selected; the canvas and MCP `key` both use it), `text.listIndent` / `text.listOutdent`.
- Behaviour: Control held while dragging suspends snapping on macOS too (Cmd/Ctrl already did);
  the presenter takes Space and N for the next frame, C for comments (Shift+C kept) and F for full
  screen, after the prototype's own key triggers; the paint bucket's tooltip shows Shift+B (B still
  works).
- Keyboard layouts: shortcuts follow the character typed, named as on a US keyboard. A symbol
  another layout types with Shift (`/` is Shift+7 on German keyboards) now counts without the
  Shift (canvas, text editing and F6 navigation share one lookup). Characters egui has no key for
  already arrive as the key at that position.
- Keyboard shortcuts panel: new rows for panning with the arrows, suspending snapping, moving along
  the toolbar, and the presentation keys, in the tabs the documentation groups them in (en/es
  strings).
- Docs: ui-parity.md gets a Keyboard shortcuts section with the counts; target-app-parity.md,
  ROADMAP.md, gaps.md (gap 4 landed list), mcp.md, parity-checklist.md (regenerated).
2026-10-12 06:49:02 +07:00
bflatastic 01bc5a9dac M11: plugin installs saved at once; directory and library requests off the UI thread
- **Installed plugins survive a crash.** Importing, installing, updating or removing a plugin, and a
  plugin changing its settings, now sets `Runtime::unsaved`; at the end of each frame the app hands
  the preferences to a new `Services::save_prefs`, and the desktop app writes `state.json` at once
  (temporary file + rename, the same atomic write the quit path uses, now shared). Nothing waits for
  quit any more.
- **The UI never waits on the network.** New `Session::start_background(id, params)`: the command
  runs as usual until it makes a server request; the request goes out on the link's own thread
  (`LiveLink::background`, implemented by the desktop `NetLink` with one short-lived thread per
  request) and the command stops; when the answer arrives (`Event::Net`, taken by `live_poll`, which
  the network's waker already triggers) the command runs again from the start on the file it
  started on, answered from the answers so far, until it needs no new request. All server requests
  go through one funnel (`Session::net`), so `plugin.directory.*`, `library.publishToServer`,
  `library.add` (links), `library.usage` and library fetches work this way without being split.
  A re-run of `library.publishToServer` caches the library it actually sent (a fresh copy differs
  in its time and, on a first publish, its id). One job per command; `cancel_background` drops a
  late answer.
- **UI:** `FigApp::run_background(ctx, id, params, done)` with a boxed completion. Manage plugins >
  Browse shows "Fetching from the server..." with Cancel instead of Browse; the install confirmation
  shows "Installing..." with Cancel and stays open until the download is in (an error leaves it open
  to retry); row buttons wait during an install; closing the window cancels. Assets > Libraries:
  Publish to server shows "Publishing..." + Cancel, Show usage and pasting a team library link show
  "Fetching from the server..." + Cancel. No polling repaints: the window draws again when the
  answer's waker fires; pending state is a scan of a short Vec, no per-frame allocation.
- **Programmatic callers are unchanged:** MCP, CLI, control channel and scripts still run the same
  commands synchronously (and the browser link, which has no background requests, too).
- New string "Installing..." (en/es); docs: plugins.md (immediate saves, Browse/Install never wait),
  server.md (team library requests never wait).
2026-10-12 06:15:00 +07:00
bflatastic 9f13cb4ba2 M9: Simplified Chinese draft catalog
- `crates/ui-egui/locales/zh-Hans.toml`: Simplified Chinese, translated from our English catalog:
  every UI string (1,371 keys), command label (524) and menu wording (29), 1,924 entries. Concise UI
  style, full-width punctuation in Chinese text, a space between Chinese and Latin words or numbers;
  plural entries give only `other`. `[meta] status = "draft — needs native review"`; registered as
  a draft (not in `COMPLETE`), like fr/de/pt-BR/ja/ko.
- Preferences > Language shows the native name (Simplified Chinese in Chinese characters).
- Detection: zh, zh-CN, zh-SG, zh-Hans, zh-Hans-CN (and zh-Hans-HK) -> zh-Hans. Traditional Chinese
  (zh-Hant, zh-TW, zh-HK, zh-MO, zh_TW.UTF-8) has no catalog: it resolves to English, or to the next
  OS language in the list, never to Simplified (before this, the regional fallback would have picked
  zh-Hans for zh-TW).
- `plural_one` never picks "one" for zh-Hans.
- CJK UI fonts (`cjk.rs`, `theme.rs`, `lib.rs`):
  - `cjk::samples` takes the UI language's scripts: a Chinese UI puts Han first, looked up in the
    Chinese faces first (Noto Sans CJK SC, Microsoft YaHei UI / YaHei, PingFang SC, ...), even when
    the document also has kana.
  - `install_fonts`: in a Chinese UI the craft-fonts build's built-in Japanese BIZ UDPGothic goes
    after the system faces and doesn't count as drawing Han, so Han takes Simplified Chinese shapes
    instead of Japanese ones (and characters BIZ lacks no longer mix in from another face).
  - The installed CJK faces are keyed by UI language too, so switching languages lays them out
    again (before: switching from Japanese to Chinese kept the Japanese face in front).
- `pref.language` params doc and `docs/mcp.md` list zh-Hans.
- Docs: localization-parity, translating, target-app-parity, ROADMAP, gaps (gap 5), roadmap (focus
  item 4). String counts re-measured from source: 1,371 UI keys, 524 command labels, 29 menu
  wordings (1,924 entries; the docs said 1,899); fr/de/pt-BR/ja/ko drafts have 1,899 of them (25
  strings added since read English), graded 99%. 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 05:57:48 +07:00
bflatastic 08df188e1a M9: Latin American Spanish overlay
- `crates/ui-egui/locales/es-419.toml`: Latin American Spanish as an overlay on `es.toml`. It lists
  only the 87 entries (61 UI strings, 26 command labels) whose wording differs in Latin American
  usage: agregar for anadir, presionar for pulsar, configuracion for settings, Enter for the Intro
  key, control for a gamepad, resorte for a spring. Everything else reads `es.toml`, then English.
  Same tu register as `es.toml`. Marked as a draft awaiting native review (not in `COMPLETE`).
- Fallback chain: a regional catalog whose language has a catalog overlays it. `build.rs` bakes a
  `parent` index into each `Lang` (es-419 -> es; others -> English), and `i18n::chain` walks
  overlay -> base -> English for UI strings, command labels and menu wordings. Catalogs are now
  sorted by code, so the picker lists Espanol before Espanol (Latinoamerica).
- System detection: es-419, es-MX, es-AR, es-CO, es-US and the other Latin American tags (also
  `es_CO.UTF-8`, `es-Latn-MX`) resolve to es-419; es, es-ES and other regions stay es.
- Preferences > Language shows "Espanol (Latinoamerica)" (in Spanish spelling) with the draft note.
- `pref.language` params doc and `docs/mcp.md` list es-419.
- Docs: localization-parity (overlay section, language preference, Spanish row, total), translating
  (overlays, detection, wording), target-app-parity, ROADMAP, gaps (gap 5), roadmap (focus item 4).
  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 05:57:48 +07:00
bflatastic 119b8c7441 M5: prototyping tail: gamepad triggers, widgets in prototypes, mode lookups
The part of gap 15 that doesn't need video, from the public documentation of prototype triggers, plugins/widgets and prototype expressions.

- **Controller buttons on key/gamepad triggers.** A key trigger's key can be a controller button: `Gamepad A` ... `Gamepad Home`, the standard gamepad layout's 17 buttons (face buttons by position, shoulders, triggers, View/Menu, stick presses, d-pad, Home). The engine keeps its input-agnostic model (a key trigger with a key name; `figcraft_doc::proto::GAMEPAD_BUTTONS`, `gamepad_button`, `is_gamepad_button`, `uses_gamepad`). The interaction editor gets a Controller menu beside the key field; the trigger list shows the button's own name.
- **Reading controllers.** A new `Services::gamepad_held()` (bitmask of held buttons; default none). The player polls it only while the presented prototype has a controller trigger and the window has the focus (about 30 times a second), plays each press once (held buttons don't repeat), and fires the first matching key/gamepad trigger on the screen like a key.
  - Desktop, Windows: Windows.Gaming.Input (Xbox-compatible controllers) through the `windows` crate the app already depends on (one more feature flag, `Gaming_Input`; no new crate).
  - Browser: the Gamepad API through `web-sys` (features `Gamepad`, `GamepadButton`, `GamepadMappingType`; standard-mapped controllers only).
  - macOS, Linux, FreeBSD: no controllers yet (documented gap).
  - Control channel: `ui.input {events: [{gamepad: "A" | "Gamepad A" | 0}]}` presses a button in the prototype playing (presenter, else preview pane); an error when nothing plays.
- **REST import** maps controller triggers (`device` other than `KEYBOARD`) to the button at the code's standard-layout index instead of skipping them; two buttons at once or an unknown code is skipped with a warning (`.fig` goes through the same path).
- **Widgets react to clicks in a presented prototype.** A click on a widget's clickable part in the presenter or the preview pane (when no interaction on the layer takes it) calls the plugin back through the canvas's own callback with the same sandbox limits, on the player's copy of the file (`widgets::click_on`, a scratch session holding the copy and the installed plugins): the widget changes in the prototype only, the file and its undo history stay untouched, restarting starts the widget over. Failures (plugin missing or failing) toast; toasts now also show over the full-window presenter.
- **Variable mode lookups.** Lookups (`accent[Dark]`, `mode(Theme)`), set-variable-mode affecting later expressions in the same interaction, and every operator the documentation lists already held; they are now behaviour cases. New `proto.evaluate {expression | expr, id?}` (MCP and agents) computes an expression where a layer's interaction would run, without changing anything. No other cheap expression gap found: every documented operator (numbers, text join, booleans, comparisons, negation, mode lookup) already existed.
- Docs: prototyping-parity (new rule rows, features, revision), target-app-parity area 13 and totals, ROADMAP, gaps.md gap 15, roadmap.md M5, mcp.md, plugins.md, control-protocol.md.

Dependencies: none added. Windows uses the existing `windows` 0.62 dependency with the `Gaming_Input` feature; the browser uses the existing `web-sys` with three more features. The usual Rust controller library (gilrs) was not taken: its Linux backend links the system's libudev (a C library, needing headers on CI and the FreeBSD job), which breaks the pure-Rust rule.
2026-10-12 05:28:53 +07:00
bflatastic def6f84b22 M11: plugin directory on figcraft-server
A self-hosted plugin directory, with no third-party service: `figcraft-server` keeps plugin bundles,
the app browses, installs and updates from it, and `figcraft-cli` publishes to it.

- **Bundles** (`figcraft_plugin::bundle`): a plugin's manifest, module and up to 32 plain-named
  assets in one uncompressed, length-prefixed binary form (nothing can expand beyond the bytes
  sent). Its SHA-256 names it. `Bundle::decode` never allocates beyond its input and refuses
  trailing bytes; `Bundle::check` parses the manifest (refusing a plugin API this build doesn't
  run), requires a version of 1-4 numbers, compiles the module without running it (only the
  sandbox's imports allowed) and checks asset names (no paths, no dot files, no Windows device
  names, unique ignoring case). Manifests gain an optional `author`.
- **Protocol**: a new connection kind next to managing and team-library connections:
  `OpenPlugins {key}` then `Plugins {list | fetch | publish | unpublish}`. Browsing needs no
  credential; publishing and unpublishing need the server key (a wrong key is refused). Older
  servers refuse the connection and the app says the server has no plugin directory. The protocol
  version is unchanged; old clients are unaffected.
- **Server** (`apps/figcraft-server/src/directory.rs`): `<data>/plugins/index.json` plus
  `<data>/plugins/<sha256>.bin`, so no publisher-chosen name reaches the file system. Every publish
  is checked in full and must be a newer version; caps `max_plugins` (versions, 0 turns it off),
  `max_plugin_mb` (<= 32), `max_plugins_mb`. Fetches re-hash the file before sending (a damaged
  bundle is never sent) and share a download budget (~32 MiB/s) so a flood is refused, not queued.
  Damaged index entries are skipped and reported, never deleted; an unreadable index lists nothing
  and refuses writes until the operator fixes it. Operator moderation:
  `figcraft-server plugins [list]` and `plugins remove ID [--version V]` (offline or through the
  running server). Logs name ids and versions, never keys.
- **App**: Plugins > Manage plugins gets Installed / Browse tabs. Browse lists a server's plugins
  (version, author as typed, size, access) with a search; nothing is fetched until Browse is
  pressed. Install / Update open a confirmation listing the permissions asked for, the source and
  the SHA-256; only its button downloads. The download must match the listed SHA-256 (and the one
  shown), then is checked like an imported plugin before anything is written. Installed plugins
  live per user under `<settings>/FigCraft/Plugins/<hash of id>/` and run in the same sandbox;
  Remove deletes the folder.
- **Commands**: `plugin.directory.list {server?, query?}`, `plugin.directory.install {id, version?,
  confirm, server?, sha256?}` (refused without `confirm: true`; never opens a dialog),
  `plugin.directory.publish {path | plugin, server}`, `plugin.directory.unpublish {id, version?,
  server}`. `ui.set {pluginsTab, pluginConfirm}` on the control channel.
- **CLI**: `figcraft-cli plugin publish FOLDER --server ws://host:port/` with the key from
  `FIGCRAFT_SERVER_KEY` (or `?key=`), never printed.
- Docs: plugins.md (directories, publishing, commands, manifest), server.md (operating the
  directory: access, checks, caps, moderation, backups, security), collab-protocol.md, mcp.md,
  cli.md, control-protocol.md, target-app-parity area 18 and Ecosystem, ROADMAP, roadmap.md M11,
  gaps.md gap 6. Plugins & widgets 55%/32% -> 60%/35%, 10-30 h -> 8-26 h.
2026-10-12 05:28:53 +07:00
bflatastic 159de3ce43 M9: Japanese and Korean draft catalogs and CJK UI fonts
- **Japanese (`ja`) and Korean (`ko`) draft catalogs** (`crates/ui-egui/locales/ja.toml`, `ko.toml`): every UI string (1,351 keys), command label (519) and menu wording (29), machine-drafted from our English (Japanese: concise, no desu/masu endings, full-width punctuation where Japanese UI text uses it; Korean: no hamnida endings, instructions in -seyo). Placeholders identical to English, `|context` keys translated per context, plural entries give only `other`. Marked `status = "draft - needs native review"` and registered as drafts (not in `COMPLETE`), like fr/de/pt-BR.
- **Plurals:** `plural_one` returns false for ja and ko (always "other").
- **Language picker and detection:** Preferences > Language lists the two languages by their own names; System picks `ja*` / `ko*` from the OS language list; `pref.language` doc lists `ja|ko` (engine spec and docs/mcp.md).
- **CJK UI fonts** (`crates/ui-egui/src/cjk.rs`): egui's fonts and Inter have no CJK glyphs. After the existing system font scan, the desktop app works out which CJK scripts (kana, Hangul, Han) the UI shows: the UI language's, the language picker's while Preferences is open, typed/IME text, and the open file's names and comments (re-read when the tab's revision changes, not mid-drag, and never again once all three scripts have a face). For each needed script it appends one face to egui's fallback list, the UI language's script first: Windows Yu Gothic UI / Meiryo / Malgun Gothic / Microsoft YaHei, macOS Hiragino Sans / Apple SD Gothic Neo / PingFang SC, Linux/BSD Noto Sans CJK, Source Han Sans, Nanum, WenQuanYi; the craft-fonts build's embedded BIZ UDPGothic is tried first (free). Han without kana outside a Japanese/Korean UI reads as Chinese (Chinese faces first). English users load no CJK face. No font files are added to the repo.
- **Read once, share the bytes:** new `FontDb::find_one` reads only the one face a fallback needs instead of every file of the family (Yu Gothic UI is 4 files of ~14 MB); `theme::font_data` hands egui the font database's shared bytes instead of copying them (also used for the existing Hebrew/Arabic fallbacks). The scan's existing 64 MB per-file cap applies. Any failure degrades to the previous behaviour (tofu at worst), never a panic.
- **Web:** no system fonts there. `crates/text/build.rs` now embeds craft-fonts' Noto Sans CJK into the wasm build when `FIGCRAFT_EMBED_CJK` is set (it covers kana, Hangul and Han; +16 MB) and reruns when that variable changes. Without it, `cjk::can_show` keeps ja/ko out of the picker and out of System detection, so Japanese and Korean browser users keep the English UI instead of boxes. Nothing is fetched from the network.
- **Docs:** localization-parity (rows, CJK UI fonts section, totals, revision), translating (catalog list, CJK conventions, fonts, plural note), target-app-parity, ROADMAP, gaps (gap 5), roadmap (focus 4), mcp.md. Localization 30% -> 35% (graded low: unreviewed drafts), 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.
2026-10-12 04:41:28 +07:00
bflatastic 207373be15 M11: plugin widgets and the plugin panel tail
Widgets for FigCraft's own WebAssembly plugins (plugin API 3; API 1 and 2 plugins run unchanged), plus the panel tail.

- **Widgets.** A manifest with `"api": 3, "widget": true` makes a widget plugin. Plugins > Widgets (or `widget.insert {plugin, x?, y?}`) puts its widget on the page: the plugin is called with `widget: {id, state, event}` and hands back a description through the new `widget_render` import (`{state?, root}`: frames with auto layout, text, rectangles, ellipses and images it carries as base64). FigCraft validates the description (4 MB, 256 layers, 16 deep, finite numbers within ranges, hex colours, text length and control characters, a 16 KB state, images 1 MB each / 2 MB together / 4096 px a side) and builds it as a frame whose layers are locked, so the widget moves as one layer.
- **Clicks.** A plain click with the Move tool on a part the plugin named as a `target` (the cursor turns into a pointing hand there; quick repeated clicks each count) calls the plugin back in a fresh, metered sandbox (same fuel, memory and call limits as runs; nothing kept between calls, so the state and the click alone decide the result) as one undo step, and the widget is redrawn. Dragging moves it; Shift/Cmd/Alt clicks only select. Agents use `widget.click {id, target}` and `widget.list`.
- **State in the document.** The plugin id and the state live in the widget's own layer data (`figcraft.widget*`), so widgets survive save and reload, duplicate/copy and paste (the copy is a widget of its own), undo/redo, export (the built layers draw) and live sessions (synced like any layer data). No plugin can write `figcraft.widget*` data or call `widget.*`; a callback can only rebuild its own widget, and touches other layers only through the commands its manifest permits. A widget whose plugin is missing stays as drawn.
- **Example.** `plugins/examples/counter`: a counter between - and + buttons (read access, no assets). The SDK gained `Input::widget` and `render_widget`.
- **Panel tail.** Panels of several plugins are open at once, side by side (`plugin.ui` without an id returns the one opened last); a colour field's swatch opens FigCraft's colour picker beside the plugin's window (the plugin hears of the colour when a drag ends; also for parameters forms). Swatch grid's palette panel gained a label colour field.
- Docs: plugins.md (ABI v3, widget description, commands, limits), mcp.md, target-app-parity area 18, ROADMAP, gaps gap 6, roadmap M11; Spanish labels for the new commands.
2026-10-12 04:41:28 +07:00
bflatastic 1e0b51398a M9: French, German and Brazilian Portuguese draft catalogs
- New UI catalogs `crates/ui-egui/locales/fr.toml`, `de.toml` and `pt-BR.toml`: every UI string (1,351 keys, 36 with plural forms), every command label (519) and every main-menu wording (29) translated, 1,899 entries each. Machine-drafted from our English, marked `status = "draft - needs native review"` like Spanish, so Preferences shows the draft note. Placeholders are identical to English; `|context` keys are translated per context (sizing "Fill", stroke "Join", glass "Light", open comments).
- Language conventions: French uses no-break spaces (` `) before `: ; ? !`, inside guillemets and before `%`; German uses formal address and German quotes; Brazilian Portuguese uses Brazilian vocabulary (arquivo, salvar, tela, voce).
- Not added to `COMPLETE`: that list forces every later PR to translate new strings into each complete language, so the three drafts are registered as incomplete and fall back to English per key; Spanish stays the only enforced language. `docs/translating.md` says so and how to list what a draft lacks.
- Plurals: `plural_one` now takes 0 as singular for French and Brazilian Portuguese (CLDR integer rules); English, Spanish and German stay `n == 1`.
- Language choice: Preferences > Language lists Francais, Deutsch and Portugues (Brasil) by their own names (from the catalogs). System now walks the OS's preferred-language list and takes the first language with a catalog: Windows' full `GlobalizationPreferences.Languages`, all of macOS's `AppleLanguages`, gettext's `LANGUAGE` then the locale variables on Linux/BSD; the browser still uses `navigator.language`. A tag with only a regional catalog reads it (`pt-PT`, `pt` -> `pt-BR`). `pref.language` docs list `fr|de|pt-BR`.
- Overflow (German is the longest): `widgets::dropdown` now keeps its label on one line, ending it with an ellipsis and showing it in full on hover (it used to wrap mid-word, e.g. the auto layout sizing and stacking dropdowns); `elided_dropdown` merged into it. Two-column sublabels in the right panel and the home screen's cards elide the same way. English is unchanged.
- Docs: localization-parity (languages, string counts, status, hours), translating, target-app-parity, ROADMAP, gaps (gap 5), roadmap (current focus 4), mcp. Localization 20% -> 30%, 45-75 h -> 40-65 h; ready for real work stays ~52%; full parity ~289-564 h -> ~284-554 h; beta ~170-305 h -> ~162-292 h.
2026-10-12 04:19:58 +07:00
bflatastic 1f6e75e0b6 M8: cursor chat and spotlight in live sessions
Two presence features for live sessions, with no accounts needed:

- **Cursor chat.** In a live session, `/` on the canvas opens a bubble in your colour at your pointer. What you type goes to everyone as you type, and others see it next to your cursor in place of your name tag. Enter (or clicking away) sends it and closes the box. On every screen it fades a few seconds after its last change (5 s, then a 1 s fade; with reduced motion it vanishes without fading). Escape takes it back at once and doesn't deselect. A box left open without typing closes by itself. Limits: 100 characters, control characters become spaces. Agents use `collab.cursorChat {text}`.
- **Spotlight.** Your avatar (top of the right panel) now has a menu with Spotlight me / Stop spotlighting (`collab.spotlight {on?}`). Everyone else follows your view and page, using the existing follow. People who join later are told by the server and follow too. Anyone who pans, zooms or presses Escape stops following, and a canvas banner ("Hana is spotlighting", Follow) lets them follow again. A newer spotlight takes over the old one. The presenter leaving or stopping ends it. The presenter sees a banner with Stop. After a reconnect, your spotlight goes out again.
- **Protocol: signals.** New `ClientMsg::Signal {kind, text}` and `ServerMsg::Signal {client_id, signal}` carry ephemeral presence. Signals never go into the document, the log or a saved state; the server keeps only who is spotlighting, for newcomers. The server cleans each signal: unknown kinds, malformed spotlights, a client's `ready` and anything over 1 KiB are dropped, and chat is capped and has control characters replaced. The app checks what arrives again before showing it. The hub rate-limits signals per connection (20 a second, bursts of 40) and drops the excess without closing the connection.
- **Compatibility, both directions.** A client that reads signals announces it with the word `signals` in its hello's `client_version`, and the server sends signals only to those clients, so older clients never see a message they can't decode. A server that takes signals answers with a `ready` signal after the welcome, and the client sends signals only after that, so older servers never get one; the app says the host runs an older version. Signals are kind plus text, so clients skip kinds added later and servers drop them.
- Engine: `Live` keeps `signals`, `chats`, `chat`, `spotlight` and `spotlighting`, all reported by `collab.peers`. UI: chat bubbles, the chat box, the spotlight banner (the existing canvas banner widget), the avatar menu and `ui.inspect` fields. Keymap: `/` maps to `collab.cursorChat` (Essential). Spanish labels for both commands, plus en/es UI strings.
- Docs: collab-protocol (messages, compatibility, Presence, security limits, agents), mcp.md, collaboration-parity rows, target-app-parity, ROADMAP, gaps, ui-parity.
2026-10-12 03:47:34 +07:00
bflatastic 626a9eac08 M1: layers and assets panels behaviour suite and fixes
A behaviour suite for the layers panel and the Assets panel written from the public documentation
(table rows with one-line derivations; rows the documentation is silent on say so and pin today's
behaviour), and the fixes it found.

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

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

The engine gains the `regex` crate (already in the lock file through env_logger) for the
documented regular-expression matching.
2026-10-12 03:47:34 +07:00
bflatastic a5a706674c M4: vector multi-edit and edit tail
Vector edit mode can now edit several vector layers at once, and the rest of the vector edit tail
from the public documentation landed. A behaviour suite came first
(`crates/engine/src/vector_multi_behaviour_tests.rs`, 5 behaviour tests with about 30 table rows and
steps, plus a junk-parameter sweep of every `vector.*` command run in multi-edit mode).

- **Multi-edit.** Enter, a double-click or `vector.edit.begin {ids}` with several shapes selected
  puts them all in edit mode, converting them in one undo step. The clicked or first one is the
  active layer. Pressing another layer's point, handle or segment makes that layer active. Shift-click,
  marquee and lasso select points in any layer. Dragging a selected point, the arrows, Delete,
  delete and heal, mirroring, smoothing, corner radius and the new cap and join commands reach every
  layer's selected points in one undo step. The pen, bend, paint bucket, cut, eraser, join and
  position work in the active layer. A click away from every edited layer leaves edit mode.
  `vector.edit.state` now reports `layers`, `others` and `handles`.
- **Several handles.** Shift-click Bezier handles to select more than one. Dragging one moves the
  others by the same amount. Also available as `vector.edit.select {handles}` and
  `vector.edit.moveHandles`. Selected handles are drawn filled.
- **Rounding next to curves.** A sharp point with a corner radius rounds even when one of its
  segments is curved. The curve is cut where the arc meets it and keeps the rest of its shape, and
  the arc meets each side smoothly (`VectorNetwork::rounded`).
- **A cap and a join per point.** `Vertex` gets optional `cap` and `join` values. When unset, the
  layer's apply. The commands are `vector.edit.strokeCap` and `vector.edit.strokeJoin` (`null`
  clears). The Design panel's Point section gets End point and Join dropdowns, each with a "Same as
  layer" option. The stroke is drawn run by run and each point's own join is drawn over the break
  (`figcraft_pathops::stroke_outline_of`, new `points.rs`). This applies to the canvas, PNG and PDF,
  and to SVG as outlines. Outline stroke follows it, render bounds count arrowheads from points, and
  the stroke cache key includes the points' styles.
- **Pen outside edit mode.** Clicking one of the path's own points (other than the first or last)
  connects there and ends the path, as a vector network. With an open vector selected, clicking one
  of its end points continues that path in edit mode.
- MCP: headless `key` Enter on shapes now enters vector edit mode, as in the desktop app. The new
  commands have Spanish labels, and the new UI string has en and es entries.
- Docs: target-app-parity (area 4: 98%/72% to 99%/73%, 2-4 h to 1-3 h; area 20: 80%/55% to
  88%/60%, 2-5 h to 1-3 h), ROADMAP, gaps (gap 9 is down to tablet pressure on macOS and Linux; the
  gap 13 note is updated), ui-parity and mcp.md. Totals are recomputed by the doc's method, because
  rows added since the last recompute had left them stale. Feature breadth is now ~83% and ready for
  real work ~52%. Mainstream stays at ~49% (49.7%, rounded down). Full parity goes from
  ~306-595 h to ~294-572 h.
2026-10-12 02:52:18 +07:00
bflatastic 7e467c2b82 M7: dev resources links
- Dev resources: links for developers (a URL and a name) on any layer, stored on the layer (`Node::dev_resources`, a new synced property `devResources` at the end of the property list), saved in the file and undoable.
- Commands: `devResource.add {id?, url, name?}`, `devResource.edit {id?, index, url?, name?}`, `devResource.remove {id?, index?}`, `devResource.list {id?}` (Spanish labels added).
- An instance lists its own links, then its main component's and that component's set's, read-only with `from`; layers inside an instance show their main layer's and take none of their own (refused with a hint). Instances don't copy the main's links (created empty, own links kept across main edits).
- URLs are data: http or https with a host, no whitespace or control characters, at most 2,048 characters; names clipped to 200; at most 32 per layer. Nothing is fetched: a link added without a name is named after its URL (scheme, `www.` and trailing slash dropped). The app opens a link only when it is clicked.
- Inspect tab: a Dev resources section (after Annotations) lists the links with a link icon, name and URL (or "From <main>"), opens on click, edits name and link inline (pencil), removes (minus) and adds from a "Paste a link" field (plus). New strings in the en and es catalogs; links are announced as links to screen readers.
- Agents: `get_node` / `inspect_document` return `devResources` (own and inherited); docs/mcp.md and docs/control-protocol.md describe them.
- The editor extension stays out of scope: the MCP server already serves design data, code and links to agents and editors.
2026-10-12 02:30:25 +07:00
bflatastic 8a983f42ce M3: extended collections tail
- Variables a library adds to a collection this file extends are now a library update of their own. `library::pending` counts published variables of an extended collection's library collection that have no copy yet; `library.updates` lists them by name without an `id` (no copy yet); `library.acceptUpdates` brings them in, inheriting. Before, they only arrived with the library's next other update. The old special case in `accept_updates` is gone (the pending list now carries them).
- Extending an extended collection: a library collection that is itself an extended collection (a brand theme extending a core theme, published) can be extended in another file; values flow down the chain as each file accepts its library's updates, and every level's overrides stay. This already worked because a library's extended collection arrives as a plain copy; it is now checked end to end and documented, and the `sync_extensions` shortcut note says why local chains aren't followed (extending takes library collections only).
2026-10-12 02:30:25 +07:00
bflatastic c50bbd08bd M8: branch version history and finer list merges
- A branch keeps its own version history. On a branch tab, `file.saveVersion`, `version.autosave` and the `version.*` commands (list, preview, restore, rename, delete, duplicate) act on the branch's versions; the main file's history is untouched. The Version history panel on a branch tab lists the branch's versions (its banner gives way to the version banner while one is viewed).
- Branch versions are saved in the `.figcraft` file with the branch (`branches/<id>/versions/index.json` plus one snapshot each, the same layout as the file's own history; images shared; damaged entries skipped and reported; the snapshot byte budget is shared across branches).
- Merging adds a version to the main file's history (`Merged "<name>"`, after an autosave of main when it had changed); the merged or archived branch keeps its own history and opens with it.
- Fills, strokes and effects merge item by item in `figcraft_collab::merge`: different items changed on each side, or items added at the end on one side (or the same ones on both), merge without a conflict; the same item changed both ways, an item removed on one side, or different items added on both sides still conflict as the whole list. Both sides' writes become the merged list, so merging and Update from main write the same result.
2026-10-12 02:30:25 +07:00
bflatastic 970eb001f1 Docs: recompute headline percentages from the area table
Feature breadth ~84% (core ~94%), weighted ready ~63%, core ready ~72%, mainstream ~50%
(weekly areas 72.3%): earlier rows had moved area scores without carrying them into the
totals. Hours unchanged until the next full re-measure.
2026-10-12 02:09:10 +07:00
bflatastic bf82f7ebf9 M4: text lists, hanging punctuation and decoration styles
The tail of the text area, from the public documentation of the target app's text settings, with a table-driven behaviour suite (each case derived in one line; "silent" cases pin today's behaviour).

- **Lists per paragraph** (`TextData::paragraphs`, one `ParaList {list, indent}` per paragraph; empty when the layer has one list style at level 0, so old files load and save unchanged). The list buttons, Shift+Cmd+8 / Shift+Cmd+7, `text.listStyle` and Type settings act on the paragraphs the selection touches while editing (the whole layer otherwise). The per-paragraph list follows edits: a new paragraph copies the one it split from, joined paragraphs keep the first one's.
- **Nesting** up to 8 levels: Tab nests a list item, Shift+Tab moves it out; Enter on an empty item and Backspace at an item's start move it a level out, then out of the list. Typing `- `, `* ` or `1. ` at the start of a plain paragraph starts a list. Numbers count per level (1. a. i., then round again), bullets go • ◦ ▪; a plain paragraph, a shallower item or the other list kind starts the count again. Each level indents by one marker gutter; a nested bullet a font lacks comes from a fallback font.
- **List spacing** (space between two items of a list, in place of paragraph spacing), **hanging lists** (the outer gutter outside the box, item text lined up with plain paragraphs) and **hanging punctuation** (opening quotes and brackets outside the start edge of left-aligned and justified lines, closing punctuation outside the end edge of right-aligned and justified ones; hung punctuation takes no room when lines wrap).
- **Decoration look** per character (`TextStyle::decoration_look`, boxed, None by default): style (solid, dotted, wavy), thickness and underline offset (auto, px or % of the font size), skip ink (underlines break around descenders) and colour (its own style index in the layout, so canvas, PNG and PDF paint it without renderer changes). Decorations are drawn from `Decoration::path()` (bar, dots or wave) on the canvas, in PNG, PDF and outlined SVG.
- `node.set` keys: `listStyle` and `indentation` (range-aware with start/end), `listSpacing`, `hangingPunctuation`, `hangingList`, `textDecorationStyle`, `textDecorationThickness`, `textDecorationOffset`, `textDecorationSkipInk`, `textDecorationColor`; documented in `KEYS` and docs/mcp.md. `text.edit.key` documents Tab.
- Inspect: `listStyle`, or `lineTypes` and `lineIndentations` per paragraph; `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration keys. CSS: `text-decoration-style`, `-thickness`, `-color`, `text-underline-offset`, `text-decoration-skip-ink: none` (browsers skip ink by default). Dev Mode rows: decoration style, list spacing.
- REST import: `lineTypes` with `lineIndentations` per paragraph (they collapsed to one list style for the layer before), `listSpacing`, `hangingPunctuation`, `hangingList`, the decoration style, thickness, offset, skip ink and colour; instance override names for them.
- Type settings ▸ Basics: decoration style, thickness and offset fields (with Auto), Skip ink, decoration colour; List spacing; Hanging punctuation and Hanging list checkboxes; the list control acts on the edited paragraphs. New strings in en/es.
- Components carry the new text fields in the characters and text-style override groups; scaling scales list spacing and px decoration lengths.
- `TextData::path` is boxed (`Option<Box<TextPath>>`) to keep `NodeKind` under clippy's enum size limit after the new fields.
2026-10-12 02:09:10 +07:00
bflatastic 1caf771e82 M8: comments and version history behaviour suite and fixes
A behaviour suite for comments and version history written from the public documentation
(`crates/engine/src/history_behaviour_tests.rs`, 15 tests, about 80 table rows plus stepwise checks),
each row with a one-line derivation; where the documentation is silent the row says so and pins
today's behaviour. 4 of the 15 tests failed on the old code. Fixes, all in shared engine code:

- **Live sessions: comment threads made in the session couldn't be used.** Their ids come from the
  creator's id space (e.g. 4294967297) and every thread command (`comment.reply`, `edit`, `react`,
  `resolve`, `move`, `delete`, `markRead`, `open`, `link`) refused them as "out of range".
  Comment ids are now parsed as ids, not small indexes.
- **Restoring a version lost newer comments.** Comments are a conversation about the file, kept
  apart from its design history: restoring now keeps the current comments; a comment on a layer
  the version doesn't have stays where it was (on the nearest surviving parent, or the canvas).
- **Restoring now keeps the replaced design in the history** as an autosave first, unless a
  version of it was just saved or restored (so work isn't only in the session's undo history).
  The checkpoint marker is now "none yet" for a freshly opened file and compares contents when
  normalising copied the document.
- **A titled autosave becomes a named version** (`version.rename` clears `auto`), so it is no longer
  dropped when autosaves make room.
- **Merging a branch adds to the main file's history:** the main file as it was (an autosave,
  when it changed since its last version), then `Merged “<branch>”`.
- `version.restore` refuses up front for a view-only live session (nothing goes into the history).

Pinned where the documentation is silent: pins go to the innermost frame under them (not to a
leaf layer); copies, duplicates and duplicated pages come without comments; replying reopens a
resolved thread; only authors delete their messages (no accounts); stepping past the last thread
stays on it; comments show on the canvas by default; versions stay with whoever saved them in a
live session.

`crates/engine/src/collab_live_tests.rs`: the test `Room` is `pub(crate)` so the suite can run a
live session. `crates/engine/src/cmd/props.rs`: one line reformatted by `cargo fmt` (it was
unformatted on the base, which failed the fmt check). `crates/render/src/effects_behaviour_tests.rs`:
the paint suite expected an offset drop shadow to grow the export on every side, but the later export
suite made exports grow only where the shadow falls (40 x 50 for a shadow 10 down); the base failed
the gate on it, so the expectation now says "grows" (wider or taller). No new commands.

Docs: collaboration-parity, target-app-parity (area 16 85%/60% -> 85%/62%, 2-5 h -> 2-4 h; full
parity ~306-595 h -> ~306-594 h; percentages unchanged at this rounding), ROADMAP (also corrects the
stale area 16 row, 80%/58%/4-8 h), gaps (gap 4's landed list), mcp.md.
2026-10-12 02:09:10 +07:00
bflatastic f2c22213d6 M1: canvas navigation, guides and grids behaviour suite and fixes
A behaviour suite for canvas navigation, rulers, guides, layout grids, outline mode and measuring
(`crates/engine/src/view_behaviour_tests.rs`, 18 tests, ~120 table rows and checks), written from
the public documentation with a one-line derivation per case; where the documentation is silent the
case says so and pins today's behaviour. Plus the fixes it found:

- **Pixel grid** shows from 400% (it waited for 800%). The threshold, pixel preview's resolution
  and wheel panning (`ViewState::pixel_grid_shown`, `pixel_preview_scale`, `scroll`) now live in the
  engine; the canvas calls them.
- **Guides belong to the page or a top-level frame.** A guide dropped over a nested frame goes to
  the frame holding it (`guides::drop_owner`), `guide.add` refuses nested frames, and a frame moved
  into another frame keeps its guides but they stop drawing, snapping and measuring.
- **Selecting guides:** a click selects a guide (`guide.select`, new command, Spanish label added);
  Delete/Backspace removes it, the arrows across it nudge it (Shift: big nudge), Escape deselects;
  right-click opens the context menu with Remove guide (`guide.remove` without an index removes the
  selected guide). A click without a move no longer records an empty undo step.
- **Alt-drag** on a guide drops a copy; **holding Alt while dragging** a guide shows its distances
  to the nearest edges inside the frame it crosses (`distance::from_guide`).
- **Layout grids:** columns/rows with a fixed width and count 0 are **Auto** (as many as fit; the
  Count field shows Auto); grids don't show or snap on a **rotated frame**.
- **Outline mode:** shapes are hit on their outlines, so a click reaches layers drawn behind
  (`geometry::hit_outlines`).
2026-10-12 01:32:04 +07:00
bflatastic 8b2607de8e M7: export and inspect behaviour suite and fixes
A behaviour suite for export and inspect, written from public documentation of design tools'
export settings and developer handoff modes (`crates/engine/src/export_behaviour_tests.rs`,
`crates/engine/src/inspect_behaviour_tests.rs`: 19 tests, 139 table rows and asserts, each with a
one-line derivation; rows where the docs are silent say so and pin today's behaviour). 47 cases
failed on `main`; this PR fixes them in shared code.

**Fixes**
- Export bounds are what a layer paints (`Document::world_render_bounds`, used by every export):
  a drop shadow adds room only in the direction it falls (it was a symmetric margin of blur +
  largest offset on every side); strokes on rectangles, frames and ellipses reach their real width
  (the mitre factor made a centre stroke of 4 add 8 a side); hidden children take no room; a mask
  group exports the mask's area.
- Slices export everything visible in their area (they exported an empty image).
- Slashes in layer names export into folders (`icons/close` → `icons/close.png`, folders created;
  `.` and `..` segments dropped).
- Copy as PNG copies the whole selection (only the first layer was copied), now from shared code
  (`cmd::file::copy_png`).
- Inspect positions are measured from the frame around a layer; groups aren't frames.

**Added (documented options that were missing)**
- Export settings: contents only (off draws every layer showing in the area) and include bounding
  box (the layer's own box); SVG: outline text (off writes `<text>` with a `<tspan>` per line),
  id attributes (unique), simplified strokes (stroke attributes for centred strokes, and for inside
  and outside strokes on boxes and ellipses). Stored per export setting, taken by `file.export`,
  `export.add` / `export.update`, `file.exportLayers` and MCP `export`; a "…" popover on each row of
  the Export section.
- Inspect: colour formats hex / RGB / CSS / HSL / HSB (`inspect.properties {colorFormat}`, a Color
  format dropdown), a Blend row, CSS in rem (`inspect.code {unit, rootFontSize}`, px/rem dropdown),
  CSS for rotation, blend modes, gradient directions and kinds (linear angle, radial, conic), text
  alignment, case and decoration; icons and images in the asset list (`kind`), exported with
  `file.exportLayers {assets: true}`.
2026-10-12 01:32:04 +07:00
bflatastic df6bc0a319 M6: .fig import: editable vector networks and more layer kinds
The `.fig` import's tail (gap 1):

- **Editable vector networks.** Vector layers now open as vector networks instead of their outlines: points, curved segments with their tangents, filled areas (loops) with their winding rule, per-point corner radius and handle mirroring, and end caps taken from the end points' own styles (an arrow drawn on one end keeps its arrowhead on that end only). The network blob is decoded defensively (`crates/format/src/fig/network.rs`: bounds-checked reads, capped counts, indices checked, floats finite) and scaled from its normalized size to the layer's size. When the blob is missing or damaged, or an open path shows a fill that regions can't hold, the outline import is used as before (damage is reported in the warnings).
- **Layer kinds that used to be dropped** now import as the closest editable layers, each kind with one warning: sticky notes, shapes with text, code blocks, tables and table cells, stamps, media, widgets and slide containers become frames with their paints and children, their text added as a text layer (notes top-left, shapes centred); a non-rectangular shape with text gets its saved outline as a vector under the frame; connectors, highlights and washi tape become vectors (the network or outline when saved, else a straight line between the connector's ends: free points or a point of a sibling layer) with the connector's end caps; text on a path becomes plain text.
- **Variable prototype actions:** set-variable (literal, variable or expression values), set-variable-mode and conditional actions (if / else blocks with expression conditions) are mapped onto the REST importer's existing support. A condition that can't be read makes the whole conditional skip with a warning rather than turning into an "else".
- The REST importer gained two small extensions the `.fig` mapping writes: `vectorNetwork` (FigCraft's own network JSON, validated: sizes capped, coordinates finite and in range, references sanitized, else the outline with a warning) and per-end `strokeCapStart` / `strokeCapEnd`.
- Docs: gaps entry 1, file-format parity, target-app parity, ROADMAP and the MCP guide's `.fig` section.
2026-10-12 01:32:04 +07:00