Compare commits

..
Author SHA1 Message Date
skylar taylor-barrick 4b7e0afd19 Desktop: refuse unknown command-line options instead of ignoring them (#836)
A mistyped or unsupported flag (e.g. --automation-write-roots) used to be
dropped silently, so the app started unconfined. It now exits 2 with
'unknown option', like a bad automation root. A bare -- ends the options.

Adds a desktop control-channel test that file.save / file.saveAs outside the
write root are refused and create nothing, while saves inside succeed, and
documents that the root flags arrived in 0.7.0 (0.6.0 ignores them).
2026-10-18 14:02:25 -06:00
Dylan M. Taylor 7fcb193f2a ci: configure Dependabot for Cargo and GitHub Actions (#825)
* ci: configure Dependabot for Cargo and GitHub Actions

* ci: add Cargo update entry for /plugins workspace in Dependabot

* ci: consolidate Cargo Dependabot directories in figcraft
2026-10-18 15:10:42 -04:00
Dylan M. Taylorandbflatastic d8ff1441b1 docs: drop references to competing commercial software (#786)
* docs: drop references to competing commercial software

* docs: clarify parity checklist coverage in status callout

* #786: say interface design once in the tagline

---------

Co-authored-by: bflatastic <[email protected]>
2026-10-18 19:34:53 +07:00
BFlat 018b8166b2 M3: a bound colour variable's alpha applies once, not squared (#802) (#804)
Fixes #802.

Fixes #802.
2026-10-18 18:50:19 +07:00
BFlat 86e91d0b71 Merge pull request #801 from phonedu/fix/silent-document-errors
M1: vector.create honours width/height by scaling the path
2026-10-18 18:37:38 +07:00
bflatastic d1dad2e1c0 Merge #801's latest commit (negative sizes: covered by the size check above)
# Conflicts:
#	crates/engine/src/cmd/create.rs
#	crates/engine/src/vector_behaviour_tests.rs
#	docs/mcp.md
2026-10-18 18:18:51 +07:00
phonedu fec902b7d5 M1: refuse negative vector sizes
A negative width or height was clamped to zero by the scale helper, producing a layer smaller than asked for; vector.create now says so. Regression test covers width -10 with height 8.
2026-10-18 19:00:25 +08:00
bflatastic 057bf9a289 #801: create_node and mcp.md say width/height scale a vector's path 2026-10-18 17:54:25 +07:00
bflatastic f1d336249a #801: vector.create accepts 0 on an axis the geometry has none of and refuses a flattening size; points scaling tests 2026-10-18 17:54:24 +07:00
bflatastic 55aa0165fd #801: keep a component set's outline as its real stroke (reverts the export change) 2026-10-18 17:41:11 +07:00
bflatastic 836d6f2fa6 Merge main into #801 2026-10-18 17:40:54 +07:00
BFlat 1c3e175f66 Merge pull request #798 from ashsajal1/m9-branch-merge-lists-item-by-item
M9: the other item lists merge one by item when a branch merges (gap 17)
2026-10-18 17:35:09 +07:00
phonedu aff3c4f18b M1: gate the outline migration on a marker and refuse impossible vector sizes
Review follow-up. A file written by this version carries editorOutlines, so a stroke someone chose is kept even when it matches the old decoration; only files without the marker get the one-time legacy cleanup. vector.create now errors when a requested side cannot scale the geometry or when both axes would collapse, instead of creating an invisible layer.
2026-10-18 18:22:48 +08:00
bflatastic adb23f0ff1 #798: branch.review docs name the lists that merge item by item 2026-10-18 17:01:03 +07:00
bflatastic 53c8308b66 #798: test layout grids, code links and interactions (add, change, delete, conflicts) 2026-10-18 17:01:03 +07:00
bflatastic a4c7e259ab #798: layout grids merge item by item, code links merge whole (keyed by language); fix stale doc comments 2026-10-18 17:01:02 +07:00
bflatastic de2915908c #798: drop progress-doc edits (maintainer-owned) 2026-10-18 17:01:02 +07:00
bflatastic 34b08ac8bf Merge main into #798 2026-10-18 16:57:23 +07:00
BFlat e9565813ac Merge pull request #800 from storytold/community/batch-18
Community batch 18: recovery lock wait (#334), Finnish catalog (2 PRs)
2026-10-18 16:51:37 +07:00
phonedu c87baa37cc docs: record the component set outline and vector size behaviour 2026-10-18 17:48:51 +08:00
phonedu 911831fb89 M1: vector.create scales its path to width/height
A path's intrinsic box replaced the width and height asked for, so an icon imported at 24 stayed on its source grid. Scale the geometry to the requested size; with one side the other keeps its proportion. A path or point set that draws nothing is an error now, not an invisible 0x0 layer.
2026-10-18 17:24:50 +08:00
phonedu be47f99534 M1: component sets keep the dashed canvas outline out of the document
Node::new gave every component set a purple dashed stroke, so export and screenshot drew the editor frame into the image. Sets now start with no paint; the canvas draws the dashed frame itself, and files written by earlier versions lose exactly that stored decoration when opened. A stroke someone chose is kept.
2026-10-18 17:24:49 +08:00
Ashsajal 13fdae3d44 M9: the other item lists merge one by item when a branch merges (gap 17)
Fills, strokes and effects already merged item by item, so both sides
changing different items of one of those lists was no conflict. Every other
list conflicted as a whole: both sides editing different export settings, or
different guides, or different prototype interactions, or different
accessibility annotations or Dev Mode links, each had to be resolved by hand
for the whole list, though the items stand on their own.

Export settings, guides, prototype interactions, accessibility annotations and
the Dev Mode code links and resources now merge item by item, with the same
rule: the same item changed both ways, an item removed on one side, or
different items added on both sides still conflict.

Properties that are keyed maps rather than item lists (bound, varModes,
overrides, propRefs, pluginData) are deliberately left out: their items line up
by key, not by position. Text runs are left whole too: they live in the node's
text data, so merging them item by item means splitting a property the change
format keeps whole.

Replaces the test that documented the gap (other_lists_still_conflict_as_a_whole)
with one that checks each new list merges, and adds one for the same item
changed both ways still conflicting.
2026-10-18 14:55:12 +06:00
24 changed files with 602 additions and 57 deletions

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directories:
- "/"
- "/plugins"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+3 -5
View File
@@ -10,7 +10,7 @@
<h1 align="center">FigCraft</h1>
<p align="center">
<b>Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.</b><br>
<b>Interface design and prototyping: open source, collaborative and built in pure Rust.</b><br>
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,<br>
in a native app that opens instantly and works offline. Open source, and yours.
</p>
@@ -62,7 +62,7 @@
<tr>
<td width="25%" valign="top">
<h3>🎛️ Familiar by design</h3>
The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft.
The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.
</td>
<td width="25%" valign="top">
<h3>⚡ Native and fast</h3>
@@ -262,7 +262,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
## Privacy and online sharing
@@ -348,8 +348,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/brand/artcraft-mark.png" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+12 -2
View File
@@ -2,7 +2,9 @@
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). Any other
//! `--flag` is unknown and stops the app at startup (#836): a mistyped root flag must never leave
//! the app running unconfined. After a bare `--`, every argument is a file.
/// What the app was started with.
#[derive(Debug, Default, PartialEq, Eq)]
@@ -17,6 +19,8 @@ pub struct Args {
/// which is refused at startup.
pub read_roots: Vec<String>,
pub write_roots: Vec<String>,
/// `--flags` the app doesn't know, as given.
pub unknown: Vec<String>,
}
/// An invite link rather than a file (`figcraft:` links, in any case).
@@ -55,6 +59,10 @@ pub fn parse(args: &[String]) -> Args {
let mut out = Args::default();
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--" {
out.files.extend(it.by_ref().cloned());
break;
}
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
let roots = match flag {
"--automation-read-root" => Some(&mut out.read_roots),
@@ -69,7 +77,9 @@ pub fn parse(args: &[String]) -> Args {
out.join = Some(a.clone());
} else if let Some(path) = file_url_path(a) {
out.files.push(path);
} else if !a.starts_with("--") {
} else if a.starts_with("--") {
out.unknown.push(a.clone());
} else {
out.files.push(a.clone());
}
}
+14 -1
View File
@@ -9,7 +9,7 @@ fn args(a: &[&str]) -> Vec<String> {
#[test]
fn files_and_the_control_port() {
assert_eq!(parse(&args(&[])), Args::default());
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
let a = parse(&args(&["--control", "4242", "a.figcraft", "b.svg"]));
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
// A missing or bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control"])), Args::default());
@@ -26,6 +26,19 @@ fn automation_roots_repeat_and_are_never_files() {
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
}
#[test]
fn unknown_flags_are_refused_never_ignored() {
// #836: a mistyped root flag (or one an older release lacks) must stop the app, not start it
// unconfined. Both forms, with and without `=`.
let a = parse(&args(&["--automation-write-roots", "out", "--automation-write-dir=out", "--verbose", "a.figcraft"]));
assert_eq!(a.unknown, args(&["--automation-write-roots", "--automation-write-dir=out", "--verbose"]));
assert!(a.write_roots.is_empty());
// Single-dash arguments are left as they were (files); after `--` everything is a file.
let a = parse(&args(&["--control", "1", "--", "--odd.figcraft", "--automation-write-root=x"]));
assert_eq!(a.files, args(&["--odd.figcraft", "--automation-write-root=x"]));
assert!(a.unknown.is_empty() && a.write_roots.is_empty());
}
#[test]
fn invite_links_join_instead_of_opening() {
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
+8 -1
View File
@@ -10,7 +10,8 @@
//! `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable) confine the
//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
//! the command line are not. A root that isn't an existing folder stops the app at startup.
//! the command line are not. A root that isn't an existing folder stops the app at startup, and so
//! does any flag the app doesn't know (a mistyped root flag must not start the app unconfined).
#![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")]
@@ -129,6 +130,12 @@ fn main() -> eframe::Result {
return Ok(());
}
let args = args::parse(&args);
if let Some(flag) = args.unknown.first() {
eprintln!(
"FigCraft: unknown option {flag}\nUsage: figcraft [--control PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... [FILE | figcraft://join?...]..."
);
std::process::exit(2);
}
let roots = match (
args.read_roots.is_empty() && args.write_roots.is_empty(),
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
+3 -2
View File
@@ -87,8 +87,9 @@ macro_rules! node_props {
($($variant:ident => $field:ident),* $(,)?) => {
/// A node property: the tree position (`parent`, `position`) or one saved field of
/// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each,
/// replaced whole (three-way merges line fills, strokes and effects up item by item, see
/// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them).
/// replaced whole (three-way merges line the lists whose items stand on their own, such as
/// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the
/// binary form numbers them).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Prop {
+24 -8
View File
@@ -4,12 +4,13 @@
//!
//! A conflict is the same property (or document-level entry) changed on both sides to different
//! values, or a layer deleted on one side while the other side edited it or something inside it
//! (including adding layers to it or moving layers into or out of it). Fills, strokes and effects
//! merge item by item: both sides changing different items of the list (or one adding items at its
//! end) is no conflict, only the same item changed both ways or the list shortened on a side
//! while the other changed it. Nothing that conflicts is
//! merged until it is resolved: each conflict is resolved for one side, and the other side's
//! changes to it are left out. Everything else from both sides is kept.
//! (including adding layers to it or moving layers into or out of it). The layer lists whose items
//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype
//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides
//! changing different items of the list (or one adding items at its end) is no conflict, only the
//! same item changed both ways or the list shortened on a side while the other changed it. Nothing
//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the
//! other side's changes to it are left out. Everything else from both sides is kept.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
@@ -159,8 +160,23 @@ fn by_key(changes: &[Change]) -> BTreeMap<Key, Vec<usize>> {
out
}
/// List properties whose items merge one by one.
const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects];
/// List properties whose items merge one by one: the items stand on their own, so both sides
/// changing different ones (or one side adding at its end) is no conflict. Sets and keyed maps
/// (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) are not here: their items line up
/// by key, not by position.
const PER_ITEM: &[Prop] = &[
Prop::Fills,
Prop::Strokes,
Prop::Effects,
Prop::Grids,
Prop::Exports,
Prop::Guides,
Prop::Interactions,
Prop::Annotations,
// Not `CodeLinks`: there is one per language, and lining them up by position could leave two
// for one language (the second hidden), so they merge whole.
Prop::DevResources,
];
/// Both sides' writes `b` and `m` of list property `key` merged item by item against the base
/// (see [`merge_items`]); `None` when `key` isn't such a list or the writes conflict.
+107 -11
View File
@@ -1,8 +1,10 @@
//! Fills, strokes and effects merge item by item: different items changed on each side merge,
//! the same item changed both ways conflicts.
//! The layer lists whose items stand on their own (fills, strokes, effects, layout grids, export
//! settings, guides, prototype interactions, accessibility annotations and Dev Mode resources)
//! merge item by item: different items changed on each side merge, the same item changed both ways
//! conflicts.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, NodeId, NodeKind};
use figcraft_doc::{Document, Effect, LayoutGrid, NodeId, NodeKind, proto};
use serde_json::{Value, json};
use crate::merge::{Key, Side, merge_items, plan};
@@ -100,19 +102,113 @@ fn the_same_item_changed_both_ways_conflicts() {
}
#[test]
fn other_lists_still_conflict_as_a_whole() {
// Docs silent: lists other than fills, strokes and effects (export settings here) still
// conflict whole when both sides change them.
fn the_other_item_lists_merge_one_by_one_too() {
// Layout grids, export settings, guides, accessibility annotations and Dev Mode resources are
// item lists like fills: both sides changing different items merges.
let (mut base, _, _, r) = files();
let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() };
base.get_mut(r).unwrap().exports = vec![export("@1"), export("@2")];
let guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset };
let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None };
let grid = |size: f64| LayoutGrid { size, ..Default::default() };
let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() };
let n = base.get_mut(r).unwrap();
n.exports = vec![export("@1"), export("@2")];
n.guides = vec![guide(true, 10.0), guide(false, 20.0)];
n.annotations = vec![annot("a1"), annot("a2")];
n.grids = vec![grid(10.0), grid(20.0)];
n.dev_resources = vec![res("https://a.test"), res("https://b.test")];
let (mut branch, mut main) = (base.clone(), base.clone());
branch.set_client_id(base.highest_id_space() + 1);
branch.get_mut(r).unwrap().exports[0] = export("@b");
main.get_mut(r).unwrap().exports[1] = export("@m");
// The branch changes the first item of each list; main the second.
let b = branch.get_mut(r).unwrap();
(b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.grids[0].size, b.dev_resources[0].url) =
("@b".into(), 11.0, "b1".into(), 12.0, "https://branch.test".into());
let m = main.get_mut(r).unwrap();
(m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.grids[1].size, m.dev_resources[1].url) =
("@m".into(), 22.0, "m2".into(), 24.0, "https://main.test".into());
both_ways(&base, &branch, &main, |doc| {
let n = doc.node(r).unwrap();
let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect();
assert_eq!(suffixes, ["@b", "@m"], "export settings");
let offsets: Vec<f64> = n.guides.iter().map(|g| g.offset).collect();
assert_eq!(offsets, [11.0, 22.0], "guides");
let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect();
assert_eq!(labels, ["b1", "m2"], "annotations");
let sizes: Vec<f64> = n.grids.iter().map(|g| g.size).collect();
assert_eq!(sizes, [12.0, 24.0], "layout grids");
let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect();
assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources");
});
}
#[test]
fn the_same_item_of_another_list_changed_both_ways_conflicts() {
let (mut base, _, _, r) = files();
let link = |language: &str| figcraft_doc::CodeLink { language: language.into(), ..Default::default() };
let n = base.get_mut(r).unwrap();
n.exports = vec![
figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() },
figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() },
];
n.code_links = vec![link("react"), link("vue")];
let (mut branch, mut main) = (base.clone(), base.clone());
branch.set_client_id(base.highest_id_space() + 1);
branch.get_mut(r).unwrap().exports[0].suffix = "@b".into();
main.get_mut(r).unwrap().exports[0].suffix = "@m".into();
// Code links are one per language, not an item list: different links changed still conflict.
branch.get_mut(r).unwrap().code_links[0].name = "B".into();
main.get_mut(r).unwrap().code_links[1].name = "M".into();
let p = plan(&base, &branch, &main).unwrap();
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
assert_eq!(keys, [format!("{r}/exports")]);
let mut keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
keys.sort();
let mut want = [format!("{r}/codeLinks"), format!("{r}/exports")];
want.sort();
assert_eq!(keys, want);
}
#[test]
fn interactions_merge_item_by_item() {
let ia = |duration_ms: u32| proto::Interaction {
trigger: Default::default(),
actions: vec![proto::Action::Back],
transition: Default::default(),
duration_ms,
easing: Default::default(),
delay_ms: 0,
};
let durations = |doc: &Document, r: NodeId| doc.node(r).unwrap().interactions.iter().map(|i| i.duration_ms).collect::<Vec<_>>();
let (mut base, _, _, r) = files();
base.get_mut(r).unwrap().interactions = vec![ia(100), ia(200)];
let fork = || {
let (mut branch, main) = (base.clone(), base.clone());
branch.set_client_id(base.highest_id_space() + 1);
(branch, main)
};
// The branch changes the first interaction and adds one; main changes the second.
let (mut branch, mut main) = fork();
branch.get_mut(r).unwrap().interactions[0].duration_ms = 150;
branch.get_mut(r).unwrap().interactions.push(ia(300));
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [150, 250, 300]));
// The branch deletes one while main leaves them alone.
let (mut branch, mut main) = fork();
branch.get_mut(r).unwrap().interactions.remove(0);
main.get_mut(r).unwrap().fills[0] = red();
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [200]));
// The same interaction changed both ways, or one deleted while the other side changed the
// list, conflicts.
for delete in [false, true] {
let (mut branch, mut main) = fork();
if delete {
branch.get_mut(r).unwrap().interactions.remove(0);
} else {
branch.get_mut(r).unwrap().interactions[1].duration_ms = 220;
}
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
let p = plan(&base, &branch, &main).unwrap();
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
assert_eq!(keys, [format!("{r}/interactions")], "delete: {delete}");
}
}
#[test]
+1 -1
View File
@@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() {
let fills = s.doc().unwrap().node(r).unwrap().fills.clone();
assert_eq!(fills.len(), 2, "the second fill stays");
assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) });
assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity");
assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included");
assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) });
// Removing the bound (first) fill takes the binding with it.
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
+131
View File
@@ -0,0 +1,131 @@
//! A colour variable's alpha applies once to the paint it is bound to (#802): a `#RRGGBBAA` fill keeps
//! its alpha in the paint opacity, and binding a variable with alpha hands the variable the whole
//! colour, so the two alphas don't multiply (canvas, SVG, `.fig` round trips, mode switches).
use figcraft_doc::NodeId;
use serde_json::json;
use crate::Session;
/// 0x80 / 255 and 0x40 / 255.
const HALF: f32 = 128.0 / 255.0;
const QUARTER: f32 = 64.0 / 255.0;
fn ex(s: &mut Session, cmd: &str, p: serde_json::Value) {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
}
/// A rectangle filled `#0D6EFD80` and a colour variable `tint` (Light `light`, Dark `dark`).
fn setup(light: &str, dark: &str) -> (Session, NodeId) {
let mut s = Session::new();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#0D6EFD80"}})).unwrap();
let r = NodeId(r["id"].as_u64().unwrap());
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "tint", "type": "color", "value": light}));
ex(&mut s, "variable.set", json!({"variable": "tint", "mode": "Dark", "value": dark}));
(s, r)
}
/// The fill's effective alpha (colour alpha times paint opacity).
fn alpha(s: &Session, r: NodeId) -> f32 {
s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().a
}
/// The alpha byte of the rectangle rendered on its own (transparent backdrop).
fn pixel_alpha(s: &Session, r: NodeId) -> u8 {
let pix = figcraft_render::render_node(s.doc().unwrap(), r, 1.0).unwrap();
let i = ((5 * pix.width() + 5) * 4 + 3) as usize;
pix.data()[i]
}
fn near(a: f32, b: f32) -> bool {
(a - b).abs() < 2e-3
}
#[test]
fn a_hex_with_alpha_keeps_its_alpha_in_the_opacity() {
let (s, r) = setup("#0D6EFD80", "#0D6EFD40");
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
assert!(near(p.opacity, HALF));
assert_eq!(pixel_alpha(&s, r), 128);
}
#[test]
fn a_bound_variable_alpha_applies_once() {
// The fill and the variable are both #0D6EFD80: the result is 50%, not 25%.
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
assert_eq!(pixel_alpha(&s, r), 128);
let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[r], &figcraft_svg::SvgOptions::default()).unwrap();
assert!(svg.contains("fill=\"#0D6EFD\" fill-opacity=\"0.502\""), "{svg}");
// Binding the same variable again changes nothing.
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert!(near(alpha(&s, r), HALF));
}
#[test]
fn unbinding_keeps_the_colour_with_its_alpha_in_the_opacity() {
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert_eq!(pixel_alpha(&s, r), 64);
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
assert!(near(p.opacity, QUARTER), "{}", p.opacity);
assert_eq!(pixel_alpha(&s, r), 64);
// Undo restores the binding and its paint.
ex(&mut s, "edit.undo", json!({}));
assert!(!s.doc().unwrap().node(r).unwrap().bound.is_empty());
assert_eq!(pixel_alpha(&s, r), 64);
}
#[test]
fn modes_switch_the_alpha_without_accumulating() {
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD80");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
for (mode, want) in [("Dark", HALF), ("Light", QUARTER), ("Dark", HALF), ("Light", QUARTER)] {
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": mode}));
assert!(near(alpha(&s, r), want), "{mode}: {}", alpha(&s, r));
}
// An opacity set while bound multiplies on top and survives mode switches.
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": "Dark"}));
assert!(near(alpha(&s, r), HALF * 0.5));
}
#[test]
fn node_set_and_style_bindings_apply_the_alpha_once() {
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
let var = s.doc().unwrap().variables.get("tint").unwrap().id.clone();
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": var}}}));
assert!(near(alpha(&s, r), HALF));
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": null}}}));
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, HALF));
// A paint style made from a `#RRGGBBAA` colour, bound to the variable.
let st = s.execute("style.create", &json!({"type": "paint", "name": "Tint", "value": "#0D6EFD80"})).unwrap()["id"].clone();
ex(&mut s, "style.bind", json!({"style": st, "field": "fills/0/color", "variable": "tint"}));
ex(&mut s, "style.apply", json!({"ids": [r.0], "style": st}));
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
}
#[test]
fn a_fig_round_trip_keeps_the_alpha_once() {
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
let back = crate::cmd::file::load_fig(&bytes, "x").unwrap().unwrap().doc;
let n = back.nodes().find(|n| !n.bound.is_empty()).unwrap();
let a = n.fills[0].solid_color().unwrap().a;
assert!(near(a, HALF * 0.5), "{a}");
}
#[test]
fn code_gives_a_translucent_stroke_its_alpha_once() {
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
ex(&mut s, "stroke.add", json!({"ids": [r.0], "color": "#FF000080"}));
let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string();
assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}");
}
+60 -9
View File
@@ -73,9 +73,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| {
create_kind(s, p, "text")
}),
spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind(
s, p, "vector"
)),
spec!(
"vector.create",
"Vector",
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
true,
|s, p| create_kind(s, p, "vector")
),
spec!(
"image.place",
"Place image",
@@ -162,6 +166,37 @@ pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
}
/// How much a vector's path is scaled to take the size asked for: with both sides, each scales
/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on
/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0
/// or less on an axis it has is refused rather than flattening it.
fn vector_scale(width: Option<f64>, height: Option<f64>, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> {
let fit = |asked: Option<f64>, have: f64, axis: &str| match asked {
None => Ok(1.0),
Some(a) if have <= 1e-9 => {
if a == 0.0 {
Ok(1.0)
} else {
Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" }))
}
}
Some(a) if a > 0.0 && (a / have).is_finite() => Ok(a / have),
Some(_) => Err(format!("`{axis}` must be a positive, finite size: 0 or less would flatten {what}")),
};
Ok(match (width, height) {
(Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?),
(Some(_), None) => {
let s = fit(width, bw, "width")?;
(s, s)
}
(None, Some(_)) => {
let s = fit(height, bh, "height")?;
(s, s)
}
(None, None) => (1.0, 1.0),
})
}
pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "node.create";
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
@@ -187,20 +222,32 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
"Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)),
_ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
};
// Vectors: path data or points define the shape and its box.
// Vectors: path data or points define the shape and its box. `width`/`height`, when asked
// for, scale the geometry: both sides independently, one side proportionally.
let mut offset = (0.0, 0.0);
let asked = (num(p, "width"), num(p, "height"));
if let NodeKind::Vector { network } = &mut kind {
if let Some(d) = string(p, "path") {
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
use figcraft_geom::Shape;
let bb = path.bounding_box();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
*network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
if boolean(p, "closed") == Some(false) {
network.regions.clear();
}
w = bb.width();
h = bb.height();
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
} else if let Some(Value::Array(pts)) = get(p, "points") {
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
@@ -208,10 +255,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
}
let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default();
let local: Vec<Point> = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area"));
}
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a point set").map_err(|e| bad(cmd, e))?;
let local: Vec<Point> = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect();
*network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false));
w = bb.width();
h = bb.height();
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
}
}
+1
View File
@@ -548,6 +548,7 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
/// innermost first so outer instances settle last.
fn link_instances(doc: &mut Document) {
super::styles::unfold_imported_bound_alpha(doc);
crate::style_vars::after_load(doc);
super::styles::apply_paint_bindings_on_load(doc);
let mut insts: Vec<(usize, NodeId)> =
+6 -1
View File
@@ -775,10 +775,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
for (field, var) in o {
match var.as_str() {
Some(vid) => {
if n.bound.get(field).map(String::as_str) != Some(vid) {
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true);
}
n.bound.insert(field.clone(), vid.to_string());
}
None => {
n.bound.remove(field);
if n.bound.remove(field).is_some() {
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
}
}
}
}
+79 -3
View File
@@ -458,10 +458,19 @@ fn bind_style(s: &mut Session, p: &Value) -> Result<Value> {
match var {
Some((_, kind)) if kind != want => return Err(bad(cmd, format!("`{field}` takes a {} variable, not a {} one", want.id(), kind.id()))),
Some((id, _)) => {
if style.bound.get(&key) != Some(&id)
&& let StyleKind::Paint { paints } = &mut style.kind
{
rebind_paint_alpha(paints, &mut [], &key, true);
}
style.bound.insert(key, id);
}
None => {
style.bound.remove(&key);
if style.bound.remove(&key).is_some()
&& let StyleKind::Paint { paints } = &mut style.kind
{
rebind_paint_alpha(paints, &mut [], &key, false);
}
}
}
// The style takes its default-mode value, and each layer using it its own.
@@ -770,16 +779,41 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
let n = doc.get_mut(i)?;
match &vid {
Some(v) => {
if n.bound.get(&field) != Some(v) {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true);
}
n.bound.insert(field.clone(), v.clone());
}
None => {
n.bound.remove(&field);
if n.bound.remove(&field).is_some() {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false);
}
}
}
}
ok()
}
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the
/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a
/// typed colour keeps it. An opacity set while bound multiplies on top and stays.
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
use super::lists::{BoundList, list_binding};
let Some((list, i, "color")) = list_binding(field) else { return };
let paints = match list {
BoundList::Fills => fills,
BoundList::Strokes => strokes,
_ => return,
};
if let Some(p) = paints.get_mut(i)
&& let PaintKind::Solid { color } = &mut p.kind
{
p.opacity = if bind { 1.0 } else { p.opacity * color.a };
color.a = 1.0;
}
}
/// `variable.setMode`: choose a collection's mode on frames (the page when none are given).
pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.setMode";
@@ -916,6 +950,49 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
apply_bindings_matching(doc, only, |_| true);
}
/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too,
/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity
/// (styles' paints and layers') before the bindings are first resolved, so it applies once.
pub(crate) fn unfold_imported_bound_alpha(doc: &mut Document) {
use super::lists::{BoundList, list_binding};
fn alphas(vars: &Variables, bound: &BTreeMap<String, VariableId>, modes: &BTreeMap<CollectionId, ModeId>) -> Vec<(BoundList, usize, f32)> {
let found = bound.iter().filter_map(|(field, var)| match (list_binding(field)?, vars.resolve(var, modes)?) {
((list @ (BoundList::Fills | BoundList::Strokes), i, "color"), VariableValue::Color(c)) if c.a > 0.0 && c.a < 1.0 => Some((list, i, c.a)),
_ => None,
});
found.collect()
}
fn unfold(p: Option<&mut Paint>, a: f32) {
if let Some(p) = p
&& matches!(p.kind, PaintKind::Solid { .. })
{
p.opacity = (p.opacity / a).min(1.0);
}
}
let pending = doc.take_dirty();
for i in 0..doc.styles.len() {
let found = doc.styles.get(i).map(|s| alphas(&doc.variables, &s.bound, &BTreeMap::new())).unwrap_or_default();
if let Some(StyleKind::Paint { paints }) = doc.styles.get_mut(i).filter(|_| !found.is_empty()).map(|s| &mut s.kind) {
for (_, j, a) in found {
unfold(paints.get_mut(j), a);
}
}
}
let ids: Vec<NodeId> = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect();
for id in ids {
let found = doc.node(id).map(|n| alphas(&doc.variables, &n.bound, &doc.modes_for(id))).unwrap_or_default();
if found.is_empty() {
continue;
}
let Ok(n) = doc.get_mut(id) else { continue };
for (list, j, a) in found {
unfold(if list == BoundList::Fills { n.fills.get_mut(j) } else { n.strokes.get_mut(j) }, a);
}
}
doc.take_dirty();
doc.mark_dirty(pending);
}
/// Settle imported paint colours before the document is first shown. Other bindings can affect
/// layout or component structure, which must be resolved through the normal edit pipeline.
pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
@@ -1104,7 +1181,6 @@ pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> {
stack.extend(n.children.iter().copied());
for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) {
if let Some(c) = paint.solid_color() {
let c = c.with_alpha(c.a * paint.opacity);
counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1;
}
}
+5 -1
View File
@@ -288,7 +288,11 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>)
doc.variables.variables.retain(|v| !gone.contains(&v.id));
let users: Vec<NodeId> = doc.nodes().filter(|n| n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect();
for id in users {
doc.get_mut(id)?.bound.retain(|_, v| !gone.contains(v));
let n = doc.get_mut(id)?;
for (field, _) in n.bound.iter().filter(|(_, v)| gone.contains(*v)) {
super::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
}
n.bound.retain(|_, v| !gone.contains(v));
}
Ok(())
}
+1 -1
View File
@@ -256,7 +256,7 @@ fn stroke_color(n: &Node) -> Option<Color> {
if !n.has_visible_stroke() {
return None;
}
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color().map(|c| c.with_alpha(c.a * p.opacity)))
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color())
}
fn drop_shadow(n: &Node) -> Option<&Shadow> {
+2
View File
@@ -1554,6 +1554,8 @@ mod binding_lists_tests;
#[cfg(test)]
mod booleans_tests;
#[cfg(test)]
mod bound_alpha_tests;
#[cfg(test)]
mod branch_tests;
#[cfg(test)]
mod branch_versions_tests;
@@ -439,3 +439,61 @@ fn bend_paint_bucket_cut_join_and_eraser_tools() {
assert_eq!(net(&s, id).segments.len(), 2, "the eraser removes what it passes over");
assert_eq!(s.doc().unwrap().node(page).unwrap().children, vec![id], "and the pieces stay one layer");
}
#[test]
fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() {
let mut s = Session::new();
let path = "M0 0 L24 0 L24 24 L0 24 Z";
let intrinsic = id_of(&ex(&mut s, "vector.create", json!({"path": path}))["id"]).unwrap();
let n = s.doc().unwrap().node(intrinsic).unwrap();
assert_eq!((n.width, n.height), (24.0, 24.0), "a path without a size keeps its intrinsic box");
// Both sides: each scales independently, like resizing a vector.
let both = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16, "height": 8}))["id"]).unwrap();
let n = s.doc().unwrap().node(both).unwrap();
assert_eq!((n.width, n.height), (16.0, 8.0));
let n = net(&s, both);
let max = n.vertices.iter().fold((0.0f64, 0.0f64), |(x, y), v| (x.max(v.x), y.max(v.y)));
assert!((max.0 - 16.0).abs() < 1e-9 && (max.1 - 8.0).abs() < 1e-9, "{max:?}: the geometry itself is scaled");
// One side: the other keeps its proportion.
let width = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16}))["id"]).unwrap();
let n = s.doc().unwrap().node(width).unwrap();
assert_eq!((n.width, n.height), (16.0, 16.0));
// A path or point set that draws nothing is an error, not an invisible zero-size layer.
let e = s.execute("vector.create", &json!({"path": "M5 5"})).unwrap_err().to_string();
assert!(e.contains("draws nothing"), "{e}");
let e = s.execute("vector.create", &json!({"points": [[5, 5], [5, 5]]})).unwrap_err().to_string();
assert!(e.contains("draws nothing"), "{e}");
// A size on an axis the geometry has none of is refused, not silently ignored.
let e = s.execute("vector.create", &json!({"path": "M0 0 L0 24", "width": 16})).unwrap_err().to_string();
assert!(e.contains("horizontal extent"), "{e}");
let e = s.execute("vector.create", &json!({"path": "M0 0 L24 0", "height": 16})).unwrap_err().to_string();
assert!(e.contains("vertical extent"), "{e}");
// But 0 on an axis it has none of is what it already is: a horizontal line with height 0.
let line = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L24 0", "width": 16, "height": 0}))["id"]).unwrap();
let n = s.doc().unwrap().node(line).unwrap();
assert_eq!((n.width, n.height), (16.0, 0.0));
// A size of 0 or less on an axis it has would flatten it, so it is refused, alone or with the other.
for p in [json!({"path": path, "width": 0}), json!({"path": path, "width": 16, "height": 0}), json!({"path": path, "width": -4, "height": 8})] {
let e = s.execute("vector.create", &p).unwrap_err().to_string();
assert!(e.contains("positive"), "{p}: {e}");
}
}
#[test]
fn vector_points_take_the_requested_size() {
let mut s = Session::new();
let points = json!([[10, 20], [34, 20], [34, 32]]);
let corners = |s: &Session, id: NodeId| net(s, id).vertices.iter().map(|v| (v.x, v.y)).collect::<Vec<_>>();
// Both sides: each axis scales on its own; the vertices move with the box.
let both = id_of(&ex(&mut s, "vector.create", json!({"points": points, "x": 10, "y": 20, "width": 48, "height": 6}))["id"]).unwrap();
let n = s.doc().unwrap().node(both).unwrap();
assert_eq!((n.width, n.height), (48.0, 6.0));
assert_eq!(corners(&s, both), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 6.0)]);
// One side: the other keeps its proportion.
let one = id_of(&ex(&mut s, "vector.create", json!({"points": points, "height": 24}))["id"]).unwrap();
let n = s.doc().unwrap().node(one).unwrap();
assert_eq!((n.width, n.height), (48.0, 24.0));
assert_eq!(corners(&s, one), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 24.0)]);
let e = s.execute("vector.create", &json!({"points": points, "width": 16, "height": -1})).unwrap_err().to_string();
assert!(e.contains("positive"), "{e}");
}
+1 -1
View File
@@ -147,7 +147,7 @@ pub fn tool_definitions() -> Vec<Value> {
"type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"},
"x": num("Left, relative to the parent frame (default 0)"),
"y": num("Top, relative to the parent frame (default 0)"),
"width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap)"),
"width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap; for a vector with path, width/height scale the path, one alone keeping its proportion, and without them the path's own box is used)"),
"height": num("Height (default 100; ignored for lines/arrows and auto-height text)"),
"parent": id_schema("Parent frame/group/component id (default: the current page)"),
"index": int("Z-index among the parent's children (default: on top)"),
+18 -3
View File
@@ -120,8 +120,7 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
/// Write `paint` to the target. `live` edits are part of an ongoing drag.
fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
if let PickerTarget::PluginField { plugin, control, .. } = target {
let c = paint.solid_color().unwrap_or(Color::BLACK);
let hex = c.with_alpha(c.a * paint.opacity).to_css_hex();
let hex = paint.solid_color().unwrap_or(Color::BLACK).to_css_hex();
let (plugin, control) = (plugin.clone(), control.clone());
if !live {
crate::plugins_ui::set_field(app, &plugin, &control, &hex);
@@ -153,7 +152,7 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
}
PickerTarget::Variable { id, mode } => {
let c = paint.solid_color().unwrap_or(Color::BLACK);
app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.with_alpha(c.a * paint.opacity).to_css_hex()}))
app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.to_css_hex()}))
}
PickerTarget::PluginField { .. } => return,
PickerTarget::Style { id, index } => {
@@ -1181,6 +1180,22 @@ mod selection_color_tests {
assert_eq!(app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000");
}
#[test]
fn writing_a_variable_keeps_its_alpha() {
// The picker holds a colour variable's alpha as the paint opacity; writing it back applies it once.
let (mut app, _, _) = setup();
app.session.execute("variable.createCollection", &json!({"name":"T"})).unwrap();
let v = app.session.execute("variable.create", &json!({"collection":"T","name":"tint","type":"color","value":"#0D6EFD80"})).unwrap();
let var = app.session.doc().unwrap().variables.get(v["id"].as_str().unwrap()).unwrap().clone();
let target = PickerTarget::Variable { id: var.id.clone(), mode: var.values.keys().next().unwrap().clone() };
for _ in 0..2 {
let paint = current_paint(&app, &target).unwrap();
write(&mut app, &target, &paint, false);
}
let c = current_paint(&app, &target).unwrap().solid_color().unwrap();
assert_eq!(c.to_css_hex().to_uppercase(), "#0D6EFD80");
}
#[test]
fn picker_does_not_follow_selection_or_document_switches() {
let (mut app, _, _) = setup();
+18
View File
@@ -57,6 +57,24 @@ fn a_confined_app_writes_only_inside_its_write_root() {
assert!(app.session.execute("file.export", &json!({"format": "svg", "path": p("outside/own.svg")})).is_ok());
}
#[test]
fn a_confined_app_saves_only_inside_its_write_root() {
// #836: saving over the control channel, both as a new file and to the current one.
let (mut app, d) = confined_app("save");
let p = |rel: &str| d.0.join(rel).to_string_lossy().into_owned();
for cmd in ["file.saveAs", "file.save"] {
let e = handle(&mut app, "engine.execute", json!({"command": cmd, "params": {"path": p("outside/a.figcraft")}})).unwrap_err();
assert!(e.contains("automation path rejected"), "{cmd}: {e}");
let e = handle(&mut app, "engine.execute", json!({"command": cmd, "params": {"path": p("out/../outside/b.figcraft")}})).unwrap_err();
assert!(e.contains("automation path rejected"), "{cmd}: {e}");
}
assert_eq!(std::fs::read_dir(d.0.join("outside")).unwrap().count(), 0, "nothing written outside the root");
handle(&mut app, "engine.execute", json!({"command": "file.saveAs", "params": {"path": p("out/in.figcraft")}})).unwrap();
assert!(d.0.join("out/in.figcraft").is_file());
// Saving again goes to the same file, inside the root.
handle(&mut app, "engine.execute", json!({"command": "file.save", "params": {}})).unwrap();
}
#[test]
fn window_captures_are_written_through_the_roots_or_returned() {
let (app, d) = confined_app("capture");
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -65,7 +65,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay |
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values |
| Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose code doesn't name variables yet |
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content |
| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) |
| Explicit modes per frame, library collections' modes | done | |
| Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed |
| Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) |
@@ -76,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged |
| 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently |
| 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged |
| 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. |
+4 -1
View File
@@ -71,7 +71,10 @@ and what a person does in the window are held to the same folders. Files are rea
read or write root and written only inside a write root; anything else is an ordinary error reply
(`automation path rejected: …`), and recent files outside the roots aren't listed. Paths are
resolved as `docs/mcp.md` › Confining file access describes. A root that isn't an existing folder
stops the app before its window opens. Without the flags nothing changes.
stops the app before its window opens, and so does any option the app doesn't know (a mistyped
flag such as `--automation-write-roots` exits with `unknown option`, never starts unconfined).
Without the flags nothing changes. The flags arrived in FigCraft 0.7.0: 0.6.0 and earlier ignore
them and confine nothing.
Not confined: opening the files given on the command line when the app starts and the documents
the system hands over (Finder, Open With; saving them later is confined like any write), and the
+11 -4
View File
@@ -426,6 +426,12 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
### Editing vectors
`vector.create {path | points}` makes the layer's box from the geometry; `width`/`height` scale
the geometry to the size asked for (with both, each side independently; with one, the other keeps
its proportion). A path or point set that draws nothing is refused instead of making an invisible
0×0 layer, as is a size on an axis the geometry has none of (except 0, such as a horizontal line's
height) and a size of 0 or less on an axis it has, which would flatten it.
Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures):
`vector.edit.begin {id? | ids?}` (default the selection; converts shapes to vectors; several layers
are edited at once, the first active; `key` Enter with shapes selected does the same),
@@ -972,10 +978,11 @@ branches). Driven with `run_command`:
name, property, branch, main, deletedBy?}]}`. `layers` are the changed layers with their property
changes formatted like Inspect (`fill #2563EB → #DC2626`); `document` lists styles, variables,
comments and file settings. A conflict is the same property changed on both sides to different
values (key `<layer id>/<property>`, e.g. `4/fills`; fills, strokes and effects compare item by
item, so changing different items of one list on each side, or adding items at its end on one
side, merges without a conflict; a layer's parent and position are one
property, `parent`), a layer deleted on one side while the other edited it or what's inside it
values (key `<layer id>/<property>`, e.g. `4/fills`; fills, strokes, effects, layout grids,
export settings, guides, prototype interactions, annotations and Dev Mode resources compare item
by item, so changing different items of one list on each side, or adding items at its end on one
side, merges without a conflict; other lists, such as code links, compare whole; a layer's parent
and position are one property, `parent`), a layer deleted on one side while the other edited it or what's inside it
(`<layer id>/layer`, with `deletedBy`), or a document-level entry or setting changed on both sides
(`style/<id>`, `variable/<id>`, `comment/<id>`, `doc/<property>`…).
- `branch.merge {id?, resolutions?}` merges the branch into the main file as one undo step,