From 57e455dfa78944e2aae9821366cd9fc507293c1e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=8F=B6=E6=9E=97=E6=A9=A6?=
<108113065+yelintong@users.noreply.github.com>
Date: Sat, 17 Oct 2026 19:14:54 +0800
Subject: [PATCH 001/123] engine: fill rules as vector.fillRule and
vector.reverseLoop
Two vector commands behind the fill-rule panel, split out so they can land
on their own (the panel is held for a maintainer design decision):
- vector.fillRule {ids? | id?, region?, rule: nonZero|evenOdd} sets the
winding rule a filled area fills by (the whole layer when region is left out).
- vector.reverseLoop {ids? | id?, region, loop} turns one loop round, which is
how a hole is made and unmade.
reverseLoop no longer swaps the stored ends of a segment a loop shares with a
neighbour: reversing the loop's list already flips this loop's traversal of the
shared segment, so flipping its segment would turn the neighbour's area round
too (bug called out in review). A new test builds two areas that share a segment
and checks the shared segment and the neighbour's fill are left alone.
Adds the commands to the Vector menu (catalog/parity), their locale labels, and
vector_fill_rule_tests (rule per area or whole layer, junk refused, shared segment).
---
crates/engine/src/catalog.rs | 2 +
crates/engine/src/cmd/structure.rs | 132 ++++++++++++
crates/engine/src/lib.rs | 2 +
crates/engine/src/vector_fill_rule_tests.rs | 228 ++++++++++++++++++++
crates/ui-egui/locales/cs.toml | 2 +
crates/ui-egui/locales/de.toml | 2 +
crates/ui-egui/locales/es.toml | 2 +
crates/ui-egui/locales/fr.toml | 2 +
crates/ui-egui/locales/id.toml | 2 +
crates/ui-egui/locales/ja.toml | 2 +
crates/ui-egui/locales/ko.toml | 2 +
crates/ui-egui/locales/pt-BR.toml | 2 +
crates/ui-egui/locales/ru.toml | 2 +
crates/ui-egui/locales/sv.toml | 2 +
crates/ui-egui/locales/tr.toml | 2 +
crates/ui-egui/locales/zh-Hans.toml | 2 +
16 files changed, 388 insertions(+)
create mode 100644 crates/engine/src/vector_fill_rule_tests.rs
diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs
index c8f34fc1..6bee5048 100644
--- a/crates/engine/src/catalog.rs
+++ b/crates/engine/src/catalog.rs
@@ -235,6 +235,8 @@ pub static MENU: &[MenuItem] = &[
m!("Vector", "Delete and heal selection", "vector.deleteHeal"),
m!("Vector", "Simplify vector", "vector.simplify"),
m!("Vector", "Reverse path direction", "vector.reverse"),
+ m!("Vector", "Set fill rule", "vector.fillRule"),
+ m!("Vector", "Reverse loop", "vector.reverseLoop"),
m!("Vector", "Offset path", "vector.offset"),
// Plugins
m!("Plugins", "Run last plugin", "plugins.runLast"),
diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs
index 500fa021..c38052ec 100644
--- a/crates/engine/src/cmd/structure.rs
+++ b/crates/engine/src/cmd/structure.rs
@@ -103,6 +103,20 @@ pub fn specs() -> Vec {
}
ok()
}),
+ spec!(
+ "vector.fillRule",
+ "Set fill rule",
+ "{ids? | id?, region? (a filled area of a vector; every area when left out), rule: nonZero|evenOdd}",
+ true,
+ fill_rule
+ ),
+ spec!(
+ "vector.reverseLoop",
+ "Reverse loop",
+ "{ids? | id?, region, loop} (a loop of a filled area; its direction flips, which changes how the area fills)",
+ true,
+ reverse_loop
+ ),
spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify),
spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path),
spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| {
@@ -719,3 +733,121 @@ fn resize_to_fit(s: &mut Session, p: &Value) -> Result {
}
Ok(json!({"ids": fitted}))
}
+
+/// The vector network of `id`, when it is a vector layer.
+fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> {
+ match &mut doc.get_mut(id).ok()?.kind {
+ NodeKind::Vector { network } => Some(network),
+ _ => None,
+ }
+}
+
+/// The id a fill rule is written as.
+fn rule_id(rule: WindingRule) -> &'static str {
+ match rule {
+ WindingRule::NonZero => "nonZero",
+ WindingRule::EvenOdd => "evenOdd",
+ }
+}
+
+/// `region` as an index: absent (or null) means every area.
+fn area_index(cmd: &str, p: &Value) -> Result
+
+
+
+
FigCraft
From 0e3876e6d6ce0f81ad71047d681fb62f69ce36f4 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 07:52:34 -0400
Subject: [PATCH 037/123] Use chrono for UTC/calendar conversion instead of
hand-rolled copies
Replaces the two hand-rolled Howard Hinnant days-from-civil copies with chrono
as a workspace dependency:
- crates/collab/src/message.rs: utc_date (YYYY-MM-DD HH:MM UTC)
- crates/ui-egui/src/home_recents.rs: date (Mon D, YYYY, UTC; keeps the
translated month names and trf formatting)
Output is unchanged. Neither function had a test, so each gains one pinning the
exact strings the old code produced (leap days included). chrono uses only std,
so wasm stays safe. AGENTS.md/CLAUDE.md now say time goes through chrono.
Follow-up to storytold/cadcraft#1785
---
AGENTS.md | 1 +
CLAUDE.md | 1 +
Cargo.lock | 11 +++++++++++
Cargo.toml | 2 ++
crates/collab/Cargo.toml | 1 +
crates/collab/src/message.rs | 31 +++++++++++++++++-------------
crates/ui-egui/Cargo.toml | 1 +
crates/ui-egui/src/home_recents.rs | 28 +++++++++++++++++----------
8 files changed, 53 insertions(+), 23 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 708da805..51157976 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -81,6 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
diff --git a/CLAUDE.md b/CLAUDE.md
index 708da805..51157976 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -81,6 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
diff --git a/Cargo.lock b/Cargo.lock
index 459c8320..d2052d22 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -742,6 +742,15 @@ dependencies = [
"rand_core 0.10.1",
]
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "clipboard-win"
version = "5.4.1"
@@ -1499,6 +1508,7 @@ name = "figcraft-collab"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
+ "chrono",
"figcraft-color",
"figcraft-doc",
"figcraft-geom",
@@ -1744,6 +1754,7 @@ name = "figcraft-ui-egui"
version = "0.9.0"
dependencies = [
"base64 0.22.1",
+ "chrono",
"egui",
"egui_extras",
"figcraft-color",
diff --git a/Cargo.toml b/Cargo.toml
index 6cc17546..7bc0b082 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -45,6 +45,8 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
log = "0.4"
+# All date/time conversion goes through chrono (no hand-rolled calendar code; AGENTS.md).
+chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
imbl = { version = "7", features = ["serde"] }
postcard = { version = "1", default-features = false, features = ["alloc"] }
diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml
index 89a79687..ed85201d 100644
--- a/crates/collab/Cargo.toml
+++ b/crates/collab/Cargo.toml
@@ -13,6 +13,7 @@ figcraft-doc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+chrono = { workspace = true }
imbl = { workspace = true }
base64 = { workspace = true }
postcard = { workspace = true }
diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs
index d035042b..71e69461 100644
--- a/crates/collab/src/message.rs
+++ b/crates/collab/src/message.rs
@@ -699,19 +699,8 @@ pub enum ServerMsg {
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
/// messages).
pub fn utc_date(secs: u64) -> String {
- let days = i64::try_from(secs / 86_400).unwrap_or(0);
- let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
- // Days to a civil date (proleptic Gregorian).
- let z = days + 719_468;
- let era = z.div_euclid(146_097);
- let doe = z.rem_euclid(146_097);
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let d = doy - (153 * mp + 2) / 5 + 1;
- let mo = if mp < 10 { mp + 3 } else { mp - 9 };
- let y = yoe + era * 400 + i64::from(mo <= 2);
- format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
+ let t = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default();
+ t.format("%Y-%m-%d %H:%M UTC").to_string()
}
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
@@ -752,3 +741,19 @@ pub fn decode_json(text: &str, max_bytes: usize) -> Result<
}
serde_json::from_str(text).map_err(|e| Error::Decode(e.to_string()))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// `utc_date` keeps the exact `YYYY-MM-DD HH:MM UTC` output the hand-rolled civil-from-days
+ /// code produced (leap days and all).
+ #[test]
+ fn utc_date_formats_seconds_since_1970() {
+ assert_eq!(utc_date(0), "1970-01-01 00:00 UTC");
+ assert_eq!(utc_date(1_000_000), "1970-01-12 13:46 UTC");
+ assert_eq!(utc_date(951_782_400), "2000-02-29 00:00 UTC");
+ assert_eq!(utc_date(1_709_164_800), "2024-02-29 00:00 UTC");
+ assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC");
+ }
+}
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
index 65a3592d..09cd2226 100644
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -22,6 +22,7 @@ egui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
+chrono = { workspace = true }
base64 = { workspace = true }
image = { workspace = true }
egui_extras = { workspace = true }
diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs
index 81d68240..dcfd1aa2 100644
--- a/crates/ui-egui/src/home_recents.rs
+++ b/crates/ui-egui/src/home_recents.rs
@@ -10,6 +10,7 @@
use std::collections::HashMap;
use std::path::Path;
+use chrono::Datelike;
use egui::{Align2, Color32, CornerRadius, Id, Key, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_engine::Prefs;
use figcraft_format::thumbnail::{FileKind, Preview};
@@ -202,16 +203,8 @@ const MONTHS: [&str; 12] = [
/// `secs` since 1970 as a date in UTC: "Mar 4, 2026".
fn date(secs: u64) -> String {
- // Days to a civil date (Howard Hinnant's days_from_civil, inverted).
- let z = i64::try_from(secs / 86_400).unwrap_or(0) + 719_468;
- let era = z.div_euclid(146_097);
- let doe = z.rem_euclid(146_097);
- let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
- let mp = (5 * doy + 2) / 153;
- let day = doy - (153 * mp + 2) / 5 + 1;
- let month = if mp < 10 { mp + 3 } else { mp - 9 };
- let year = yoe + era * 400 + i64::from(month <= 2);
+ let dt = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default().naive_utc();
+ let (year, month, day) = (i64::from(dt.year()), i64::from(dt.month()), i64::from(dt.day()));
let name = usize::try_from(month - 1).ok().and_then(|m| MONTHS.get(m)).map_or("", |m| tr(m));
trf("{month} {day}, {year}", &[("month", &name), ("day", &day), ("year", &year)])
}
@@ -965,3 +958,18 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6
arrows(ui, recents, files, 0, 1);
action
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// `date` keeps the exact "Mon D, YYYY" (UTC) output the hand-rolled days-from-civil code
+ /// produced. With no catalog loaded the month and format strings pass through in English.
+ #[test]
+ fn date_formats_seconds_since_1970_in_utc() {
+ assert_eq!(date(0), "Jan 1, 1970");
+ assert_eq!(date(951_782_400), "Feb 29, 2000");
+ assert_eq!(date(1_709_164_800), "Feb 29, 2024");
+ assert_eq!(date(1_741_046_400), "Mar 4, 2025");
+ }
+}
From 630e0dd4034e41058863696a3579ecfe8abf37ae Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 09:11:43 -0400
Subject: [PATCH 038/123] Keep 5-digit years unprefixed in utc_date
chrono's %Y writes a '+' before years past 9999, whereas the old hand-rolled
{y:04} did not. utc_date now writes the year with {:04} to keep the exact old
output. from_timestamp already reads a clock past chrono's range as the epoch
rather than panicking. Tests cover the 5-digit-year and out-of-range cases.
---
crates/collab/src/message.rs | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs
index 71e69461..fb14924a 100644
--- a/crates/collab/src/message.rs
+++ b/crates/collab/src/message.rs
@@ -697,10 +697,12 @@ pub enum ServerMsg {
}
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
-/// messages).
+/// messages). A time past the last date chrono can represent (year 262143) reads as the epoch.
pub fn utc_date(secs: u64) -> String {
+ use chrono::Datelike;
let t = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default();
- t.format("%Y-%m-%d %H:%M UTC").to_string()
+ // `{:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old `{y:04}` did not).
+ format!("{:04}-{}", t.year(), t.format("%m-%d %H:%M UTC"))
}
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
@@ -755,5 +757,9 @@ mod tests {
assert_eq!(utc_date(951_782_400), "2000-02-29 00:00 UTC");
assert_eq!(utc_date(1_709_164_800), "2024-02-29 00:00 UTC");
assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC");
+ // A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did.
+ assert_eq!(utc_date(253_402_300_800), "10000-01-01 00:00 UTC");
+ // Past chrono's range reads as the epoch instead of panicking.
+ assert_eq!(utc_date(u64::MAX), "1970-01-01 00:00 UTC");
}
}
From e3169e5a61401d4981ddbd43c10a5cc9a32c2ace Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 17:18:14 -0400
Subject: [PATCH 039/123] collab/ui-egui: keep old UTC output past chrono's
year range
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`utc_date` (collab) and `date` (ui-egui) took `u64` seconds, which the old
hand-rolled civil-from-days code converted for the whole `u64` range. The
chrono rewrite used `from_timestamp(..).unwrap_or_default()`, so any instant
past chrono's own year limit (262143) silently became the epoch — changing
existing output. `u64::MAX` seconds, for example, used to read
`584554051223-11-09 07:00 UTC` / `Nov 9, 584554051223`, not 1970.
Fix: add a `utc_parts(days, sod, nanos)` helper per crate that moves the
instant into chrono's range by whole 400-year Gregorian cycles (146_097 days),
lets chrono do the month/day/time, and restores the real year afterwards.
chrono stays the only calendar logic; the cycle count just shifts the year, and
every `u64` day count fits in `i64` so the conversion is always exact (no epoch
fallback). Split seconds with `u64` arithmetic first to avoid the `i64` seconds
overflow near `u64::MAX`, matching the old code.
Tests now pin the old output instead of the epoch:
- collab utc_date(u64::MAX) == "584554051223-11-09 07:00 UTC"
(and utc_date(253_402_300_800) == "10000-01-01 00:00 UTC", already pinned)
- ui-egui date(253_402_300_800) == "Jan 1, 10000"
date(u64::MAX) == "Nov 9, 584554051223"
---
crates/collab/src/message.rs | 43 ++++++++++++++++++++++++------
crates/ui-egui/src/home_recents.rs | 37 ++++++++++++++++++++++---
2 files changed, 69 insertions(+), 11 deletions(-)
diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs
index fb14924a..4fda776a 100644
--- a/crates/collab/src/message.rs
+++ b/crates/collab/src/message.rs
@@ -696,13 +696,38 @@ pub enum ServerMsg {
},
}
-/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
-/// messages). A time past the last date chrono can represent (year 262143) reads as the epoch.
-pub fn utc_date(secs: u64) -> String {
+/// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock
+/// instant moved into chrono's range by whole 400-year cycles). The calendar repeats every 400
+/// years (146_097 days), so month, day and time of day come from chrono and only the year is
+/// carried, which keeps the result exact for any `i64` day count. This is not hand-rolled calendar
+/// math: chrono still does the date, the cycle count just shifts the year.
+fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
- let t = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default();
- // `{:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old `{y:04}` did not).
- format!("{:04}-{}", t.year(), t.format("%m-%d %H:%M UTC"))
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), nanos.min(999_999_999));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
+ }
+ _ => (1970, chrono::NaiveDateTime::default()), // unreachable
+ }
+}
+
+/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
+/// messages). Years past chrono's own range (262143) keep converting: the instant is moved into
+/// range by whole 400-year cycles and the real year restored, so every `u64` reads as the date the
+/// old hand-rolled conversion produced (e.g. `u64::MAX` → `584554051223-11-09 07:00 UTC`).
+pub fn utc_date(secs: u64) -> String {
+ // The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), and the
+ // seconds-of-day is < 86_400, so the conversion is always exact (no epoch fallback). Splitting
+ // with `u64` arithmetic (as the old code did) avoids the `i64` seconds overflow near `u64::MAX`.
+ let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32);
+ let (y, t) = utc_parts(days, sod, 0);
+ // `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old code did not).
+ format!("{y:04}-{}", t.format("%m-%d %H:%M UTC"))
}
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
@@ -759,7 +784,9 @@ mod tests {
assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC");
// A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did.
assert_eq!(utc_date(253_402_300_800), "10000-01-01 00:00 UTC");
- // Past chrono's range reads as the epoch instead of panicking.
- assert_eq!(utc_date(u64::MAX), "1970-01-01 00:00 UTC");
+ // Past chrono's own year range (262143) the instant is moved into range by whole 400-year
+ // cycles and the real year restored, so it keeps the old hand-rolled output instead of
+ // falling back to the epoch.
+ assert_eq!(utc_date(u64::MAX), "584554051223-11-09 07:00 UTC");
}
}
diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs
index dcfd1aa2..ca50d78f 100644
--- a/crates/ui-egui/src/home_recents.rs
+++ b/crates/ui-egui/src/home_recents.rs
@@ -201,10 +201,35 @@ const MONTHS: [&str; 12] = [
tr_noop("Dec"),
];
-/// `secs` since 1970 as a date in UTC: "Mar 4, 2026".
+/// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock
+/// instant moved into chrono's range by whole 400-year cycles). The calendar repeats every 400
+/// years (146_097 days), so month, day and time of day come from chrono and only the year is
+/// carried, which keeps the result exact for any `i64` day count. This is not hand-rolled calendar
+/// math: chrono still does the date, the cycle count just shifts the year.
+fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) {
+ let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
+ // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
+ let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
+ let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), nanos.min(999_999_999));
+ match (date, time) {
+ (Some(d), Some(t)) => {
+ let y = i128::from(d.year()) + i128::from(cycles) * 400;
+ (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
+ }
+ _ => (1970, chrono::NaiveDateTime::default()), // unreachable
+ }
+}
+
+/// `secs` since 1970 as a date in UTC: "Mar 4, 2026". Years past chrono's own range (262143) keep
+/// converting: the instant is moved into range by whole 400-year cycles and the real year restored,
+/// so every `u64` reads as the date the old hand-rolled conversion produced.
fn date(secs: u64) -> String {
- let dt = i64::try_from(secs).ok().and_then(|s| chrono::DateTime::from_timestamp(s, 0)).unwrap_or_default().naive_utc();
- let (year, month, day) = (i64::from(dt.year()), i64::from(dt.month()), i64::from(dt.day()));
+ // The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), so the
+ // conversion is always exact (no epoch fallback). Splitting with `u64` arithmetic (as the old
+ // code did) avoids the `i64` seconds overflow near `u64::MAX`.
+ let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32);
+ let (year, dt) = utc_parts(days, sod, 0);
+ let (month, day) = (i64::from(dt.month()), i64::from(dt.day()));
let name = usize::try_from(month - 1).ok().and_then(|m| MONTHS.get(m)).map_or("", |m| tr(m));
trf("{month} {day}, {year}", &[("month", &name), ("day", &day), ("year", &year)])
}
@@ -971,5 +996,11 @@ mod tests {
assert_eq!(date(951_782_400), "Feb 29, 2000");
assert_eq!(date(1_709_164_800), "Feb 29, 2024");
assert_eq!(date(1_741_046_400), "Mar 4, 2025");
+ // A year past chrono's own `NaiveDate` range (262143): the instant is moved into range by
+ // whole 400-year cycles and the real year restored.
+ assert_eq!(date(253_402_300_800), "Jan 1, 10000");
+ // Past chrono's year range, `u64::MAX` keeps the old hand-rolled output instead of falling
+ // back to the epoch.
+ assert_eq!(date(u64::MAX), "Nov 9, 584554051223");
}
}
From a0e937918c0f3923570a68933924ba44f0af6fef Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 18:29:17 -0400
Subject: [PATCH 040/123] Name the 400-year range extension as the one
exception to the time rule
The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.
---
AGENTS.md | 2 +-
CLAUDE.md | 2 +-
Cargo.toml | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 51157976..80c55db5 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -81,7 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
-- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month, day and time, and add the cycles back to the year.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
diff --git a/CLAUDE.md b/CLAUDE.md
index 51157976..80c55db5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -81,7 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
-- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month, day and time, and add the cycles back to the year.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
diff --git a/Cargo.toml b/Cargo.toml
index 7bc0b082..b6fb2704 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -45,7 +45,7 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
log = "0.4"
-# All date/time conversion goes through chrono (no hand-rolled calendar code; AGENTS.md).
+# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
imbl = { version = "7", features = ["serde"] }
From 9ec80bf2c2b6ad8eb34c2fec374ccab4a8219e0d Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Wed, 21 Oct 2026 19:07:03 -0400
Subject: [PATCH 041/123] Say the library computes month and day; time-of-day
is plain arithmetic
Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
---
AGENTS.md | 2 +-
CLAUDE.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 80c55db5..b2384311 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -81,7 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
-- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month, day and time, and add the cycles back to the year.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
diff --git a/CLAUDE.md b/CLAUDE.md
index 80c55db5..b2384311 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -81,7 +81,7 @@ code.
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
-- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month, day and time, and add the cycles back to the year.
+- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
### Quality gates (before every commit)
From 6394e96005ff4dbc8649045a979c32fe1fa4da93 Mon Sep 17 00:00:00 2001
From: MortenHusted
Date: Thu, 22 Oct 2026 10:41:53 +0200
Subject: [PATCH 042/123] M3: a variable mode set on a layer inside an instance
is an override
A variable mode chosen on a layer inside an instance (the icon inside a
Close button, recoloured through an icon colour collection) wasn't an
override group. A full rebuild of the instance starts each layer over
from its main and carries only override groups, so the mode fell back
to the main's. Library import always rebuilds, as do main edits such as
a new auto layout direction, so a modal's recoloured close icon came
into other files in its default colour.
"modes" is now an override group: setting a mode inside an instance
records it, rebuilds carry it, rebasing a nested instance finds it, the
component panel lists it, and resetting it brings back the main's mode.
A top-level instance's own modes stay its own and record no override,
like its resizing.
---
crates/engine/src/cmd/component.rs | 2 +-
crates/engine/src/components.rs | 27 ++++++----
.../src/components_rebase_source_tests.rs | 14 ++++++
crates/engine/src/instance_modes_tests.rs | 49 +++++++++++++++++++
crates/engine/src/library_variables_tests.rs | 36 ++++++++++++++
crates/ui-egui/src/component_panel.rs | 1 +
6 files changed, 119 insertions(+), 10 deletions(-)
diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs
index 6a5b1012..01914640 100644
--- a/crates/engine/src/cmd/component.rs
+++ b/crates/engine/src/cmd/component.rs
@@ -165,7 +165,7 @@ pub fn specs() -> Vec {
spec!(
"component.resetOverride",
"Reset override",
- "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}",
+ "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|modes|component|props|slot; all when omitted)}",
true,
reset_override
),
diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs
index 67259d03..f011625d 100644
--- a/crates/engine/src/components.rs
+++ b/crates/engine/src/components.rs
@@ -12,9 +12,10 @@
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers
-//! inside an instance, `component` (a swapped nested instance), `props` (property values of a
-//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own
-//! export settings, interactions and resizing are its own, like its place. `position`
+//! inside an instance, `modes` (the variable modes a layer inside an instance chooses),
+//! `component` (a swapped nested instance), `props` (property values of a nested instance) and
+//! `slot` (a slot holding the instance's own content). An instance's own export settings,
+//! interactions, resizing and variable modes are its own, like its place. `position`
//! overrides of layers inside an instance are still read and kept, but new ones are refused
//! ([`check_override_edit`]): those layers take their place and constraints from the main.
//!
@@ -60,6 +61,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[
"size",
"resizing",
"position",
+ "modes",
"component",
"props",
SLOT,
@@ -415,6 +417,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
(b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h);
}
"position" => to.transform = from.transform,
+ "modes" => to.var_modes = from.var_modes.clone(),
"component" => {
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
*b = *a;
@@ -1049,6 +1052,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
if a.interactions != b.interactions {
out.push("interactions");
}
+ if a.var_modes != b.var_modes {
+ out.push("modes");
+ }
if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) {
if x != y {
out.push("component");
@@ -1146,10 +1152,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
}
let raw = diff_groups(b, n);
let mut groups = raw.clone();
- // An instance's own place, resizing, export settings and interactions are its own, not
- // overrides.
+ // An instance's own place, resizing, export settings, interactions and variable modes are
+ // its own, not overrides.
if top_root {
- groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions"));
+ groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes"));
}
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
@@ -1969,7 +1975,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
let mut groups: Vec = value_groups(m, n)
.into_iter()
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
- .filter(|g| placed_source.is_some() || g != "resizing")
+ .filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
.collect();
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
if named {
@@ -1993,8 +1999,11 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
}
if let Some(n) = doc.node_mut(inst) {
n.source = root_base;
- // A top-level instance's resizing is its own (a nested one's overrides its main layer's).
- n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing"));
+ // A top-level instance's resizing and modes are its own (a nested one's override its main
+ // layer's).
+ n.overrides.retain(|g| {
+ !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
+ });
n.overrides.extend(root_groups);
}
}
diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/components_rebase_source_tests.rs
index b272b82f..94eae71f 100644
--- a/crates/engine/src/components_rebase_source_tests.rs
+++ b/crates/engine/src/components_rebase_source_tests.rs
@@ -201,6 +201,20 @@ fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() {
assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0);
}
+#[test]
+fn rebase_marks_a_nested_roots_own_mode_but_not_a_top_level_instances() {
+ let mut s = scene();
+ let modes = BTreeMap::from([("Tone".to_string(), "B".to_string())]);
+ s.doc.node_mut(s.nested).unwrap().var_modes = modes.clone();
+ both(&mut s);
+ assert!(s.doc.node(s.nested).unwrap().overrides.contains("modes"), "its slot in Outer sets no mode");
+ let page = s.doc.first_page().unwrap();
+ let detached = mirror(&mut s.doc, s.nested, page);
+ rebase_instance(&mut s.doc, detached);
+ let n = s.doc.node(detached).unwrap();
+ assert_eq!((&n.var_modes, n.overrides.contains("modes")), (&modes, false), "a top-level instance's modes are its own");
+}
+
#[test]
fn rebase_does_not_preserve_existing_foreign_or_stale_sources() {
let mut s = scene();
diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs
index 79007622..0836048f 100644
--- a/crates/engine/src/instance_modes_tests.rs
+++ b/crates/engine/src/instance_modes_tests.rs
@@ -126,3 +126,52 @@ fn detaching_keeps_the_mode_it_took_from_its_main() {
assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B");
assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1);
}
+
+/// A "Button" holding an instance of a swatch "Icon" on mode A, and a "Modal" holding a Button
+/// instance whose icon copy was set to mode B: (the Button main, its icon layer, the copy in Modal).
+fn modal(s: &mut Session) -> (NodeId, NodeId, NodeId) {
+ tone(s);
+ let icon = swatch(s, "Icon", None);
+ let frame = id(run(s, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"})));
+ let button_icon = id(run(s, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": frame})));
+ set_mode(s, button_icon, json!("A"));
+ let button = id(run(s, "component.create", json!({"ids": [frame]})));
+ let outer = id(run(s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"})));
+ let close = id(run(s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": outer})));
+ let nested = s.doc().unwrap().node(close).unwrap().children[0];
+ set_mode(s, nested, json!("B"));
+ run(s, "component.create", json!({"ids": [outer]}));
+ (button, button_icon, nested)
+}
+
+#[test]
+fn a_mode_set_on_a_layer_inside_an_instance_is_an_override() {
+ let mut s = Session::new();
+ let (button, button_icon, nested) = modal(&mut s);
+ assert_eq!(fill(&s, nested), "0000FF");
+ // An edit the copies can't take as they are rebuilds the Button instance.
+ run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"}));
+ assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps the mode set on the copy");
+ // The main's layer choosing another mode doesn't reach it either.
+ set_mode(&mut s, button_icon, json!("B"));
+ set_mode(&mut s, button_icon, Value::Null);
+ assert_eq!(fill(&s, nested), "0000FF");
+ run(&mut s, "component.resetOverride", json!({"id": nested, "group": "modes"}));
+ assert_eq!(fill(&s, nested), "FF0000", "reset: the main's layer is on Auto (mode A)");
+ assert!(s.doc().unwrap().node(nested).unwrap().var_modes.is_empty());
+}
+
+#[test]
+fn a_mode_set_on_an_icon_inside_a_placed_instance_survives_main_edits() {
+ let mut s = Session::new();
+ let (button, _, _) = modal(&mut s);
+ // A Button placed on the page, its icon recoloured through the icon's mode.
+ let placed = instance(&mut s, button);
+ let icon = s.doc().unwrap().node(placed).unwrap().children[0];
+ set_mode(&mut s, icon, json!("B"));
+ assert!(s.doc().unwrap().node(icon).unwrap().overrides.contains("modes"), "it shows as an override");
+ run(&mut s, "node.set", json!({"ids": [button], "layout": "vertical"}));
+ assert_eq!(fill(&s, icon), "0000FF", "a rebuild of the Button keeps it");
+ run(&mut s, "component.resetInstance", json!({"ids": [placed]}));
+ assert_eq!(fill(&s, icon), "FF0000", "reset all brings back the main's mode");
+}
diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs
index 4fc778a7..7104164b 100644
--- a/crates/engine/src/library_variables_tests.rs
+++ b/crates/engine/src/library_variables_tests.rs
@@ -344,3 +344,39 @@ fn library_style_bindings_ignore_unrelated_local_variables() {
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
+
+#[test]
+fn modes_set_inside_nested_instances_come_along() {
+ let d = dir("nested-modes");
+ let lib_path = path_str(&d.join("kit.figcraft"));
+ let mut lib = Session::new();
+ theme(&mut lib);
+ let f = idv(&ex(&mut lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"})));
+ ex(&mut lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"}));
+ let icon = idv(&ex(&mut lib, "component.create", json!({"ids": [f]})));
+ let b = idv(&ex(&mut lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"})));
+ ex(&mut lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b}));
+ let button = idv(&ex(&mut lib, "component.create", json!({"ids": [b]})));
+ let m = idv(&ex(&mut lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"})));
+ let close = idv(&ex(&mut lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m})));
+ let nested = lib.doc().unwrap().node(close).unwrap().children[0];
+ ex(&mut lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"}));
+ ex(&mut lib, "component.create", json!({"ids": [m]}));
+ let lid = publish_to(&mut lib, &lib_path);
+
+ let mut s = Session::new();
+ ex(&mut s, "library.add", json!({"path": lib_path}));
+ let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0})));
+ let copy = |s: &Session| {
+ let doc = s.doc().unwrap();
+ doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0]
+ };
+ assert_eq!(fill(&s, copy(&s)), "FFFFFF", "the Dark mode set on the nested icon comes along");
+ // An update that rebuilds the Button copies keeps it too.
+ ex(&mut lib, "node.set", json!({"ids": [button], "layout": "horizontal"}));
+ republish(&mut lib);
+ ex(&mut s, "library.acceptUpdates", json!({}));
+ assert_eq!(fill(&s, copy(&s)), "FFFFFF");
+ healthy(&s);
+ let _ = std::fs::remove_dir_all(&d);
+}
diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs
index 2b2573c7..4db613ab 100644
--- a/crates/ui-egui/src/component_panel.rs
+++ b/crates/ui-egui/src/component_panel.rs
@@ -521,6 +521,7 @@ fn group_label(g: &str) -> String {
"grids" => tr("Layout grids"),
"exports" => tr("Export settings"),
"interactions" => tr("Interactions"),
+ "modes" => tr("Variable modes"),
"size" => tr("Size"),
"resizing" => tr("Resizing"),
"position" => tr("Position"),
From f1c206509f89c5a4d26c98f2b8219fcc0640d6fd Mon Sep 17 00:00:00 2001
From: MortenHusted
Date: Thu, 22 Oct 2026 13:41:51 +0200
Subject: [PATCH 043/123] M3: files from before the modes override keep the
modes set inside their instances
A file or library saved before "modes" was an override group has a mode
chosen on a layer inside an instance without its marker, so the next
rebuild or library import still put the main's mode back.
Copies follow their main layer's modes when the main changes them, so a
layer inside an instance on other modes chose them itself.
mark_layer_modes marks those when a file opens and when a published
library is read from a file or a server. Layers on their main's modes
and top-level instances stay unmarked.
---
crates/engine/src/components.rs | 17 ++++++
crates/engine/src/instance_modes_tests.rs | 19 +++++++
crates/engine/src/lib.rs | 1 +
crates/engine/src/library.rs | 9 ++-
crates/engine/src/library_server.rs | 2 +-
crates/engine/src/library_variables_tests.rs | 60 ++++++++++++++------
6 files changed, 90 insertions(+), 18 deletions(-)
diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs
index f011625d..c0612bcd 100644
--- a/crates/engine/src/components.rs
+++ b/crates/engine/src/components.rs
@@ -116,6 +116,23 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
enclosing_instance(doc, id).is_some()
}
+/// Mark the `modes` override on layers inside instances whose variable modes differ from their
+/// main layer's, for files from before `modes` was an override group. Copies follow their main
+/// layer's modes, so a layer on other modes chose them itself.
+pub fn mark_layer_modes(doc: &mut Document) {
+ let chose: Vec = doc
+ .nodes()
+ .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
+ .map(|n| n.id)
+ .filter(|id| inside_instance(doc, *id))
+ .collect();
+ for id in chose {
+ if let Some(n) = doc.node_mut(id) {
+ n.overrides.insert("modes".into());
+ }
+ }
+}
+
/// The slot property frame `id` stands for (its name and preferred components): a frame bound to
/// a slot property of its component, or an instance's copy of one (followed through its sources).
pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> {
diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs
index 0836048f..43de3ca4 100644
--- a/crates/engine/src/instance_modes_tests.rs
+++ b/crates/engine/src/instance_modes_tests.rs
@@ -175,3 +175,22 @@ fn a_mode_set_on_an_icon_inside_a_placed_instance_survives_main_edits() {
run(&mut s, "component.resetInstance", json!({"ids": [placed]}));
assert_eq!(fill(&s, icon), "FF0000", "reset all brings back the main's mode");
}
+
+#[test]
+fn a_file_from_before_modes_were_overrides_keeps_the_modes_chosen_inside_its_instances() {
+ let mut s = Session::new();
+ let (button, _, nested) = modal(&mut s);
+ // Saved before `modes` was an override group: the copy has its mode but no marker.
+ s.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes");
+ let path = std::env::temp_dir().join(format!("figcraft-instance-modes-{}.figcraft", std::process::id()));
+ let p = path.to_string_lossy().into_owned();
+ run(&mut s, "file.saveAs", json!({"path": p}));
+ let mut s = Session::new();
+ run(&mut s, "file.open", json!({"path": p}));
+ let _ = std::fs::remove_file(&path);
+ let doc = s.doc().unwrap();
+ assert!(doc.node(nested).unwrap().overrides.contains("modes"), "opening it marks the mode the copy chose");
+ assert!(doc.nodes().filter(|n| n.id != nested).all(|n| !n.overrides.contains("modes")), "layers on their main's modes stay unmarked");
+ run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"}));
+ assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps it");
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 79e7be0d..d878d6ab 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -413,6 +413,7 @@ impl DocState {
}
doc.link_layer_text_styles();
cmd::styles::apply_paint_bindings_on_load(&mut doc);
+ components::mark_layer_modes(&mut doc);
// Initial import settlement belongs to the saved baseline, not the user's first edit.
let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0));
let doc = Arc::new(doc);
diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs
index 26863561..9740182c 100644
--- a/crates/engine/src/library.rs
+++ b/crates/engine/src/library.rs
@@ -553,11 +553,18 @@ fn stat(_path: &str) -> std::result::Result {
Err(NO_FILES.into())
}
+/// A library read from a file or a server, with the modes chosen inside its instances marked when
+/// it was published before they were overrides ([`crate::components::mark_layer_modes`]).
+pub(crate) fn settled(mut lib: PublishedLibrary) -> PublishedLibrary {
+ crate::components::mark_layer_modes(Arc::make_mut(&mut lib.doc));
+ lib
+}
+
#[cfg(not(target_arch = "wasm32"))]
fn read_file(path: &str) -> std::result::Result {
let f = crate::file_access::open(path).map_err(|e| e.to_string())?;
match figcraft_format::read_library(std::io::BufReader::new(f)) {
- Ok(Some(lib)) => Ok(lib),
+ Ok(Some(lib)) => Ok(settled(lib)),
Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")),
Err(e) => Err(format!("{path}: {e}")),
}
diff --git a/crates/engine/src/library_server.rs b/crates/engine/src/library_server.rs
index 99d5f29a..10852b5f 100644
--- a/crates/engine/src/library_server.rs
+++ b/crates/engine/src/library_server.rs
@@ -53,7 +53,7 @@ pub fn archive(lib: &PublishedLibrary) -> Result> {
/// The library in an archive from a server (untrusted: read with the library file's limits).
pub fn read_archive(bytes: &[u8]) -> std::result::Result {
match figcraft_format::read_library(Cursor::new(bytes)) {
- Ok(Some(lib)) => Ok(lib),
+ Ok(Some(lib)) => Ok(crate::library::settled(lib)),
Ok(None) => Err("the server sent a file without a library".into()),
Err(e) => Err(format!("the server sent a library that can't be read ({e})")),
}
diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs
index 7104164b..b4a4171a 100644
--- a/crates/engine/src/library_variables_tests.rs
+++ b/crates/engine/src/library_variables_tests.rs
@@ -345,32 +345,42 @@ fn library_style_bindings_ignore_unrelated_local_variables() {
let _ = std::fs::remove_dir_all(&d);
}
+/// A library "Modal" holding a Button instance whose icon copy is on mode Dark: (the Button main,
+/// the icon copy in Modal).
+fn nested_mode_kit(lib: &mut Session) -> (NodeId, NodeId) {
+ theme(lib);
+ let f = idv(&ex(lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"})));
+ ex(lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"}));
+ let icon = idv(&ex(lib, "component.create", json!({"ids": [f]})));
+ let b = idv(&ex(lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"})));
+ ex(lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b}));
+ let button = idv(&ex(lib, "component.create", json!({"ids": [b]})));
+ let m = idv(&ex(lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"})));
+ let close = idv(&ex(lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m})));
+ let nested = lib.doc().unwrap().node(close).unwrap().children[0];
+ ex(lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"}));
+ ex(lib, "component.create", json!({"ids": [m]}));
+ (button, nested)
+}
+
+/// The icon inside the Button inside library Modal instance `inst`.
+fn modal_icon(s: &Session, inst: NodeId) -> NodeId {
+ let doc = s.doc().unwrap();
+ doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0]
+}
+
#[test]
fn modes_set_inside_nested_instances_come_along() {
let d = dir("nested-modes");
let lib_path = path_str(&d.join("kit.figcraft"));
let mut lib = Session::new();
- theme(&mut lib);
- let f = idv(&ex(&mut lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"})));
- ex(&mut lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"}));
- let icon = idv(&ex(&mut lib, "component.create", json!({"ids": [f]})));
- let b = idv(&ex(&mut lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"})));
- ex(&mut lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b}));
- let button = idv(&ex(&mut lib, "component.create", json!({"ids": [b]})));
- let m = idv(&ex(&mut lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"})));
- let close = idv(&ex(&mut lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m})));
- let nested = lib.doc().unwrap().node(close).unwrap().children[0];
- ex(&mut lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"}));
- ex(&mut lib, "component.create", json!({"ids": [m]}));
+ let (button, _) = nested_mode_kit(&mut lib);
let lid = publish_to(&mut lib, &lib_path);
let mut s = Session::new();
ex(&mut s, "library.add", json!({"path": lib_path}));
let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0})));
- let copy = |s: &Session| {
- let doc = s.doc().unwrap();
- doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0]
- };
+ let copy = |s: &Session| modal_icon(s, inst);
assert_eq!(fill(&s, copy(&s)), "FFFFFF", "the Dark mode set on the nested icon comes along");
// An update that rebuilds the Button copies keeps it too.
ex(&mut lib, "node.set", json!({"ids": [button], "layout": "horizontal"}));
@@ -380,3 +390,21 @@ fn modes_set_inside_nested_instances_come_along() {
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
+
+#[test]
+fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_nested_instances() {
+ let d = dir("nested-modes-old");
+ let lib_path = path_str(&d.join("kit.figcraft"));
+ let mut lib = Session::new();
+ let (_, nested) = nested_mode_kit(&mut lib);
+ // Published before `modes` was an override group: the copy has its mode but no marker.
+ lib.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes");
+ let lid = publish_to(&mut lib, &lib_path);
+
+ let mut s = Session::new();
+ ex(&mut s, "library.add", json!({"path": lib_path}));
+ let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0})));
+ assert_eq!(fill(&s, modal_icon(&s, inst)), "FFFFFF", "the Dark mode comes along");
+ healthy(&s);
+ let _ = std::fs::remove_dir_all(&d);
+}
From 6d748536b6f9460beb5168a13de1e157fe524931 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Thu, 22 Oct 2026 13:01:37 +0000
Subject: [PATCH 044/123] Drop XML-forbidden control characters from live-text
SVG export
Found while testing crates/svg/src/lib.rs:153 (esc, used for tspan text at :598, :608 and :613)
Co-Authored-By: Claude Opus 5.5
---
crates/svg/src/lib.rs | 10 +++++++++-
crates/svg/src/tests.rs | 28 ++++++++++++++++++++++++++++
2 files changed, 37 insertions(+), 1 deletion(-)
diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs
index 55176aaf..f5a23b1b 100644
--- a/crates/svg/src/lib.rs
+++ b/crates/svg/src/lib.rs
@@ -150,8 +150,16 @@ fn blend_style(b: figcraft_color::BlendMode) -> String {
}
}
+/// `s` escaped for XML text and attribute values; characters XML 1.0 forbids (control characters
+/// other than tab, newline and carriage return, U+FFFE and U+FFFF) are left out.
fn esc(s: &str) -> String {
- s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
+ s.chars()
+ .filter(|&c| !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}'))
+ .collect::()
+ .replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
}
fn hex(c: Color) -> String {
diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs
index 8dece77f..1aafc987 100644
--- a/crates/svg/src/tests.rs
+++ b/crates/svg/src/tests.rs
@@ -135,6 +135,34 @@ fn title_case_does_not_restart_words_across_fill_runs_in_live_text() {
assert!(svg.contains(">He") && svg.contains(">l") && svg.contains(">lo"), "{svg}");
}
+/// Control characters in text (a pasted vertical-tab soft break, U+0001) are not allowed in XML:
+/// live-text export must leave them out so the SVG still parses, for plain and per-run spans.
+#[test]
+fn live_text_export_drops_xml_forbidden_control_characters() {
+ use figcraft_doc::{StyleRun, TextData};
+ figcraft_text::scan_system_fonts();
+ for styled in [false, true] {
+ let mut d = Document::new("t");
+ let page = d.first_page().unwrap();
+ let mut text = TextData::new("line one\u{b}line two\u{1}");
+ text.style.font_size = 24.0;
+ if styled {
+ let mut style = text.style.clone();
+ style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]);
+ text.runs.push(StyleRun { start: 5, end: 12, style });
+ text.normalize();
+ }
+ let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 600.0, 40.0).unwrap();
+ d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)];
+ let svg = export_svg(&d, &[id], &SvgOptions { outline_text: false, ..SvgOptions::default() }).unwrap();
+ assert!(!svg.contains('\u{b}') && !svg.contains('\u{1}'), "{svg:?}");
+ assert!(svg.contains("line") && svg.contains("two"), "{svg}");
+ let mut d2 = Document::new("t2");
+ let p2 = d2.first_page().unwrap();
+ assert!(import_svg(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "x").is_ok(), "{svg:?}");
+ }
+}
+
#[test]
fn point_joins_equal_to_the_layer_join_keep_a_plain_stroke() {
use figcraft_doc::{StrokeJoin, VectorNetwork};
From 3d5f3e79464f728aa4490349cb2cded5814ecdc6 Mon Sep 17 00:00:00 2001
From: MortenHusted
Date: Thu, 22 Oct 2026 16:41:30 +0200
Subject: [PATCH 045/123] M5: prototype actions lay out what they change like
the canvas does
Change to rebuilt the instance from the new variant, and set variable
and set variable mode re-applied bindings, but the player never laid the
result out: no text sizing, no auto layout. A hugging button with a
longer label than its main showed the other variant at the main's width,
its label clipped, and a text bound to a variable kept its old size.
These actions now run the engine's normalize on the player's document
afterwards, the pass that follows every edit on the canvas: text resizes
to what it shows, instances follow and auto layout reflows.
---
crates/ui-egui/src/present.rs | 19 +++-
crates/ui-egui/src/present_behaviour_tests.rs | 93 +++++++++++++++++++
docs/prototyping-parity.md | 1 +
3 files changed, 110 insertions(+), 3 deletions(-)
diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs
index 9c0f1608..a538db4b 100644
--- a/crates/ui-egui/src/present.rs
+++ b/crates/ui-egui/src/present.rs
@@ -511,6 +511,16 @@ impl Presenter {
self.rev += 1;
}
+ /// After an action changed layers of `design` (since `before`): text resizes to what it shows,
+ /// instances follow and auto layout reflows, as after an edit on the canvas; then
+ /// [`relayout`](Self::relayout).
+ fn settle(&mut self, before: &Document) {
+ if let Some(d) = self.design.as_mut() {
+ figcraft_engine::normalize(d, before);
+ }
+ self.relayout();
+ }
+
/// Responsive prototypes: resize the presented frame to a screen of `size` (None: frames
/// keep their size). Lays out again only when it changes.
fn respond_to(&mut self, size: Option<(f64, f64)>) {
@@ -879,28 +889,31 @@ impl Presenter {
}
self.begin(tr, dur, easing, now);
let Some(d) = self.design.as_mut() else { return false };
+ let before = d.clone();
if let Some(n) = d.node_mut(inst)
&& let NodeKind::Instance { component, .. } = &mut n.kind
{
*component = *dest;
}
figcraft_engine::components::sync_instance(d, inst);
- self.relayout();
+ self.settle(&before);
true
}
Action::SetVariable { variable, value, expr } => {
let Some(d) = self.design.as_mut() else { return false };
+ let before = d.clone();
let set = figcraft_engine::proto_run::set_variable(d, node, variable, value, expr.as_ref());
if set {
- self.relayout();
+ self.settle(&before);
}
set
}
Action::SetVariableMode { collection, mode } => {
let Some(d) = self.design.as_mut() else { return false };
+ let before = d.clone();
let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode);
if set {
- self.relayout();
+ self.settle(&before);
}
set
}
diff --git a/crates/ui-egui/src/present_behaviour_tests.rs b/crates/ui-egui/src/present_behaviour_tests.rs
index 791e0d3f..e9a036f2 100644
--- a/crates/ui-egui/src/present_behaviour_tests.rs
+++ b/crates/ui-egui/src/present_behaviour_tests.rs
@@ -454,6 +454,99 @@ fn interactive_components_change_in_place() {
}
}
+/// A hugging auto layout frame (in `parent`, else on the page) holding text `chars`: (frame, text).
+fn hugging_label(s: &mut Session, parent: Option, name: &str, x: f64, chars: &str) -> (NodeId, NodeId) {
+ let f = id(&ex(s, "frame.create", json!({"x": x, "y": 600, "width": 60, "height": 30, "name": name, "parent": parent})));
+ let t = id(&ex(s, "text.create", json!({"text": chars, "x": 0, "y": 0, "parent": f})));
+ ex(s, "node.set", json!({"ids": [f], "props": {"layout": "horizontal", "padding": 8, "sizingH": "hug", "sizingV": "hug"}}));
+ (f, t)
+}
+
+fn size(d: &Document, n: NodeId) -> (f64, f64) {
+ d.node(n).map(|n| (n.width, n.height)).unwrap()
+}
+
+#[test]
+fn change_to_lays_out_the_instance_around_its_own_content() {
+ // Change to shows the other variant in place: the instance keeps its overrides (a longer
+ // label), so it is laid out around them, as switching the variant on the canvas does.
+ figcraft_text::scan_system_fonts();
+ let mut s = Session::new();
+ let variants: Vec = ["Default", "Hover"]
+ .iter()
+ .enumerate()
+ .map(|(k, state)| {
+ let (f, _) = hugging_label(&mut s, None, &format!("State={state}"), k as f64 * 150.0, "Button");
+ id(&ex(&mut s, "component.create", json!({"ids": [f]})))
+ })
+ .collect();
+ let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()})));
+ on(&mut s, variants[0], json!({"type": "onClick"}), json!({"type": "changeTo", "dest": variants[1]}), json!({"type": "instant"}), 0);
+ let screen = frame(&mut s, "Screen", 0.0, 400.0, 300.0);
+ let button = id(&ex(&mut s, "instance.create", json!({"component": set, "x": 20, "y": 20, "parent": screen})));
+ let label = s.doc().unwrap().node(button).unwrap().children[0];
+ ex(&mut s, "text.set", json!({"id": label, "characters": "A much longer label"}));
+ let (main, canvas, canvas_label) = {
+ let d = s.doc().unwrap();
+ (size(d, variants[1]), size(d, button), size(d, label))
+ };
+ // Without any font nothing is measured and both sizes are the main's.
+ if figcraft_text::layout_node(s.doc().unwrap().node(label).unwrap()).is_some_and(|(l, _)| !l.glyphs.is_empty()) {
+ assert!(canvas.0 > main.0 + 20.0, "on the canvas the instance hugs its longer label");
+ }
+ let mut p = player(s.doc().unwrap().clone(), screen);
+ assert!(p.fire(button, 0, None, 1.0));
+ let d = p.doc.as_ref().unwrap();
+ assert!(matches!(d.node(button).unwrap().kind, NodeKind::Instance { component, .. } if component == variants[1]));
+ assert_eq!((size(d, button), size(d, label)), (canvas, canvas_label), "the Hover variant, laid out around the instance's label");
+}
+
+/// A screen holding a hugging chip whose text is bound to string variable `label` of collection
+/// "Copy" (modes A "Short" and B "A much longer label"), the chip's click running `action`:
+/// (session, screen, chip, text).
+fn bound_chip(action: Value) -> (Session, NodeId, NodeId, NodeId) {
+ figcraft_text::scan_system_fonts();
+ let mut s = Session::new();
+ ex(&mut s, "variable.createCollection", json!({"name": "Copy", "modes": ["A", "B"]}));
+ ex(
+ &mut s,
+ "variable.create",
+ json!({"collection": "Copy", "name": "label", "type": "string", "values": {"A": "Short", "B": "A much longer label"}}),
+ );
+ let screen = frame(&mut s, "Screen", 0.0, 400.0, 300.0);
+ let (chip, text) = hugging_label(&mut s, Some(screen), "Chip", 20.0, "Short");
+ ex(&mut s, "variable.bind", json!({"ids": [text], "field": "characters", "variable": "label"}));
+ on(&mut s, chip, json!({"type": "onClick"}), action, json!({"type": "instant"}), 0);
+ (s, screen, chip, text)
+}
+
+#[test]
+fn set_variable_and_set_mode_lay_out_the_text_they_change() {
+ // A text bound to a string variable shows what an interaction sets, measured: the hugging
+ // chip around it grows with it, to the size the canvas gives the same text.
+ let cases = [
+ (
+ json!({"type": "setVariable", "variable": "label", "value": "A much longer label"}),
+ "variable.set",
+ json!({"variable": "label", "value": "A much longer label"}),
+ ),
+ (json!({"type": "setVariableMode", "collection": "Copy", "mode": "B"}), "variable.setMode", json!({"collection": "Copy", "mode": "B"})),
+ ];
+ for (action, canvas_cmd, canvas_params) in cases {
+ let (mut s, screen, chip, text) = bound_chip(action.clone());
+ let mut p = player(s.doc().unwrap().clone(), screen);
+ assert!(p.fire(chip, 0, None, 1.0), "{action}");
+ let page = s.doc().unwrap().page_of(screen).unwrap();
+ let mut params = canvas_params;
+ if canvas_cmd == "variable.setMode" {
+ params["ids"] = json!([page]);
+ }
+ ex(&mut s, canvas_cmd, params);
+ let (d, canvas) = (p.doc.as_ref().unwrap(), s.doc().unwrap());
+ assert_eq!((size(d, chip), size(d, text)), (size(canvas, chip), size(canvas, text)), "{action}: the chip grows with its text");
+ }
+}
+
#[test]
fn a_drag_started_transition_follows_the_pointer() {
let (mut u, hot) = one_interaction(json!({"type": "onDrag"}), json!({"type": "push", "direction": "left"}), 400);
diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md
index e4cf8c7f..3adb30c0 100644
--- a/docs/prototyping-parity.md
+++ b/docs/prototyping-parity.md
@@ -56,6 +56,7 @@ case pins the current behaviour).
| Interactive components: every instance plays the interactions of the variant it shows; an interaction on the instance with the same trigger wins | **fixed** | instances played only interactions copied onto them when they were placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown; `instances_play_their_variants_interactions` |
| Change to on a layer inside a nested instance changes the instance whose variant set holds the destination | **fixed** | the innermost instance (an icon inside a button) was changed instead |
| Set variable, set variable mode, conditional | pass | `proto_run_tests`, `proto_conditional_tests`, `present_variables_tests` |
+| Change to, set variable and set variable mode lay out what they change as the canvas does: text sizes to what it shows, hugging frames around it | **fixed** | the player didn't lay out after them: an instance with a longer label changed to the main's width, and a bound text kept its old size; `change_to_lays_out_the_instance_around_its_own_content`, `set_variable_and_set_mode_lay_out_the_text_they_change` |
| Expressions: a variable reads the mode where the interaction runs (a frame that chose a mode, else the page's); `name[Mode]` reads a given mode whatever is chosen; operators on numbers, text (`+` joins), booleans and comparisons | pass | `mode_lookups_read_a_given_mode_or_the_layers_current_one`, `proto_expr_mode_tests` |
| Set variable mode, then a conditional or set variable later in the same interaction: they see the new mode | pass | `set_variable_mode_changes_what_later_expressions_read` |
| A set variable writes every mode of the variable, so a lookup of any mode reads the new value; a frame inside the screen that chose a mode keeps it after set variable mode | silent | pinned as stated |
From ad9116ff5c8b59b10c4a1b95f29a4ae613f80c6f Mon Sep 17 00:00:00 2001
From: MortenHusted
Date: Thu, 22 Oct 2026 17:01:21 +0200
Subject: [PATCH 046/123] M5: the prototyping parity status line and history
name the layout fix
---
docs/prototyping-parity.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md
index 3adb30c0..fed6a639 100644
--- a/docs/prototyping-parity.md
+++ b/docs/prototyping-parity.md
@@ -1,6 +1,6 @@
# Prototyping parity
-> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (Change to, set variable and set variable mode lay out what they change in the player, as the canvas does) · **Target:** Figma Design (desktop app 126.10.7)
**Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except
media control, every transition and easing exist; key/gamepad triggers take controller buttons,
@@ -101,6 +101,7 @@ case pins the current behaviour).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-22 | minor | Change to, set variable and set variable mode lay out what they change in the player as the canvas does: text sizes to what it shows and hugging frames follow (an instance with a longer label changed to the main's width; a bound text kept its old size). Two player tests. Breadth and ready unchanged. |
| 2026-10-17 | minor | Community batch #753: a Present tab (#537): playback in its own tab that keeps its screen and history, a current-frame thumbnail, previous / next, device presets and scale; the frame reflows to the device's screen on the prototype's copy; phone and tablet content drags inside a fixed device as its overflow allows ("No scrolling" frames clip; iPad mini, MacBook Air and Desktop keep their bodies); the unsaved-changes question shows above the presentation. Checked in the app on Windows. Percentages and hours unchanged |
| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. |
| 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h |
From 2437a65d1bbd2ad8b2645028a095da08a5f56f99 Mon Sep 17 00:00:00 2001
From: lileeei
Date: Thu, 22 Oct 2026 23:04:57 +0800
Subject: [PATCH 047/123] M1: fill/stroke props accept "none" and null (#1036)
node.create, node.set and the other prop appliers map fill/stroke
"none" (either case) and null to an empty fills/strokes list, as
fills: [] already did (undocumented), so an outline-only shape is one
call - agents write "none" for no paint. KEYS and docs/mcp.md
document it; the error names the accepted forms. fill_none_tests
covers create, set, undo, text and the error.
---
ROADMAP.md | 4 +-
crates/engine/src/cmd/props.rs | 23 ++++++--
crates/engine/src/fill_none_tests.rs | 88 ++++++++++++++++++++++++++++
crates/engine/src/lib.rs | 2 +
docs/mcp.md | 2 +
5 files changed, 113 insertions(+), 6 deletions(-)
create mode 100644 crates/engine/src/fill_none_tests.rs
diff --git a/ROADMAP.md b/ROADMAP.md
index f43cd015..d92ae3ec 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (`fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
+- **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged.
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
@@ -316,6 +317,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged |
| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs
index f180b53c..83e2830a 100644
--- a/crates/engine/src/cmd/props.rs
+++ b/crates/engine/src/cmd/props.rs
@@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
/// Keys `node.set` understands (documented for agents).
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
-fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
+fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
@@ -372,6 +372,11 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec) {
}
}
+/// `"none"` (either case) and null ask for no paint, as an empty `fills`/`strokes` list does.
+fn paint_none(v: &Value) -> bool {
+ v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))
+}
+
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
/// bindings last so paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
@@ -462,13 +467,21 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
"fill" => {
- let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
- set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
+ if paint_none(v) {
+ set_paints(doc.get_mut(id)?, true, vec![]);
+ } else {
+ let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?;
+ set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
+ }
}
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
"stroke" => {
- let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
- set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
+ if paint_none(v) {
+ set_paints(doc.get_mut(id)?, false, vec![]);
+ } else {
+ let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?;
+ set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
+ }
}
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
diff --git a/crates/engine/src/fill_none_tests.rs b/crates/engine/src/fill_none_tests.rs
new file mode 100644
index 00000000..74fe6231
--- /dev/null
+++ b/crates/engine/src/fill_none_tests.rs
@@ -0,0 +1,88 @@
+//! `props.fill` and `props.stroke` accept `"none"` (either case) and `null` as no paint, the
+//! same as an empty `fills`/`strokes` list (#1036): an outline-only shape in one call.
+
+use figcraft_doc::NodeId;
+use serde_json::{Value, json};
+
+use crate::Session;
+
+fn id(v: Value) -> NodeId {
+ NodeId(v["id"].as_u64().unwrap())
+}
+
+fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
+ s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
+}
+
+fn create_rect(s: &mut Session, fill: Value) -> NodeId {
+ id(ex(
+ s,
+ "node.create",
+ json!({
+ "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10,
+ "props": {"fill": fill, "stroke": "#000000", "strokeWeight": 1}
+ }),
+ ))
+}
+
+#[test]
+fn fill_none_or_null_makes_an_outline_only_shape_in_one_call() {
+ for none in [json!("none"), json!("None"), json!("NONE"), json!(null)] {
+ let mut s = Session::new();
+ let r = create_rect(&mut s, none.clone());
+ let n = s.doc().unwrap().node(r).unwrap().clone();
+ assert!(n.fills.is_empty(), "{none:?}: fills not empty");
+ assert_eq!(n.strokes.len(), 1, "{none:?}: stroke kept");
+ }
+}
+
+#[test]
+fn fill_none_clears_an_existing_fill_as_one_undo_step() {
+ let mut s = Session::new();
+ let r = create_rect(&mut s, json!("#FF0000"));
+ assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1);
+ ex(&mut s, "node.set", json!({"id": r, "props": {"fill": "none"}}));
+ assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
+ ex(&mut s, "edit.undo", json!({}));
+ assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1);
+}
+
+#[test]
+fn stroke_none_or_null_clears_the_strokes() {
+ for none in [json!("none"), json!(null)] {
+ let mut s = Session::new();
+ let r = create_rect(&mut s, json!("#FF0000"));
+ ex(&mut s, "node.set", json!({"id": r, "props": {"stroke": none}}));
+ assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty(), "{none:?}");
+ assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1, "{none:?}");
+ }
+}
+
+#[test]
+fn text_with_fill_none_gets_no_automatic_colour() {
+ let mut s = Session::new();
+ let t = id(ex(
+ &mut s,
+ "node.create",
+ json!({
+ "type": "text", "x": 0, "y": 0, "text": "Hi", "props": {"fill": "none"}
+ }),
+ ));
+ assert!(s.doc().unwrap().node(t).unwrap().fills.is_empty());
+}
+
+#[test]
+fn an_unknown_string_still_names_the_key() {
+ let mut s = Session::new();
+ let e = s
+ .execute(
+ "node.create",
+ &json!({
+ "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10,
+ "props": {"fill": "nope"}
+ }),
+ )
+ .unwrap_err();
+ let msg = e.to_string();
+ assert!(msg.contains("`fill`") && msg.contains("none"), "{msg}");
+}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 79e7be0d..8e369adf 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -1675,6 +1675,8 @@ mod fig_export_tests;
#[cfg(test)]
mod fig_import_tests;
#[cfg(test)]
+mod fill_none_tests;
+#[cfg(test)]
mod finite_geometry_tests;
#[cfg(test)]
mod frame_guides_tests;
diff --git a/docs/mcp.md b/docs/mcp.md
index dfea95cc..484255c2 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -144,6 +144,8 @@ Or in a project's `.mcp.json`:
there is none, so they are page coordinates then). Groups and boolean groups have no coordinate
space of their own: a layer in a group is placed in the frame (or on the page) the group is in.
`inspect_document` reports `x`/`y` the same way, plus `absoluteBounds` in page coordinates.
+ In `props`, `fill` and `stroke` also accept `"none"` (either case) or `null` as no paint, the
+ same as an empty `fills`/`strokes` list, so an outline-only shape can be made in one call.
- Frames, groups, sections and masks: drawing a frame or section with the `pointer` tool around
layers puts those fully inside it in it; sections go only on the page or in sections (moves,
drags, pastes and new sections elsewhere are refused or land on the page); resizing a section
From be6a547ff424113a599415223f989f58d54e8716 Mon Sep 17 00:00:00 2001
From: AganzeFelicite
Date: Thu, 22 Oct 2026 18:01:15 +0200
Subject: [PATCH 048/123] M7: the Type settings test checks it stays open at
264 px every frame (#1029)
---
crates/ui-egui/tests/type_settings_long_labels.rs | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/crates/ui-egui/tests/type_settings_long_labels.rs b/crates/ui-egui/tests/type_settings_long_labels.rs
index 8410e3cf..113b4daf 100644
--- a/crates/ui-egui/tests/type_settings_long_labels.rs
+++ b/crates/ui-egui/tests/type_settings_long_labels.rs
@@ -27,7 +27,11 @@ fn widths(language: &str) -> Vec {
let raw =
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(i as f64 / 60.0), ..Default::default() };
ctx.run_ui(raw, |ui| app.ui(ui)).textures_delta.clear();
- widths.extend(ctx.memory(|m| m.area_rect(open)).map(|r| r.width()));
+ // From the frame after the one it opened on (egui's sizing pass).
+ if i > 3 {
+ assert!(Popup::is_id_open(&ctx, open), "{language}: Type settings closed on frame {i}");
+ widths.push(ctx.memory(|m| m.area_rect(open)).unwrap().width());
+ }
}
widths
}
@@ -36,9 +40,8 @@ fn widths(language: &str) -> Vec {
fn type_settings_keeps_its_width_with_labels_longer_than_their_column() {
for language in ["ka", "fr"] {
let w = widths(language);
- assert!(w.len() > 30, "{language}: Type settings is open ({} frames)", w.len());
assert_ne!(figcraft_ui_egui::i18n::tr("Paragraph indent"), "Paragraph indent", "{language} is shown");
- let (first, last) = (w[1], w[w.len() - 1]);
- assert!((last - first).abs() < 0.01, "{language}: Type settings widens every frame: {first} → {last}");
+ // The width it asks for (`Popup::width`), every frame.
+ assert!(w.iter().all(|x| (x - 264.0).abs() < 0.01), "{language}: Type settings widens: {} → {}", w[0], w[w.len() - 1]);
}
}
From 83b9f74665f33ec0c25bbf66406981101d3fca1b Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Thu, 22 Oct 2026 19:04:10 +0200
Subject: [PATCH 049/123] Write a final Greek sigma in lower-case text layout
Found while testing crates/text/src/layout.rs:1070 (shape_segment calls TextCase::apply_after one char at a time; crates/doc/src/text.rs:124 apply_after)
Co-Authored-By: Claude Opus 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/text/src/layout.rs | 14 ++++++++++++++
crates/text/src/tests.rs | 22 ++++++++++++++++++++++
2 files changed, 36 insertions(+)
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index 26ae3d33..f844b571 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -1035,6 +1035,18 @@ fn is_emoji(c: char) -> bool {
matches!(c, '\u{1F1E6}'..='\u{1F1FF}' | '\u{1F300}'..='\u{1FAFF}')
}
+/// The lower case of the capital sigma at `i`: the final form ς or σ, chosen from the letters
+/// around it in the whole text (as lower-casing the whole string would), not from the lone char.
+fn lower_sigma(chars: &[char], i: usize) -> char {
+ // Enough context to step over any realistic run of combining marks next to the sigma.
+ const CONTEXT: usize = 32;
+ let before: String = chars.get(i.saturating_sub(CONTEXT)..i).unwrap_or(&[]).iter().collect();
+ let after: String = chars.iter().skip(i + 1).take(CONTEXT).collect();
+ let lower = format!("{before}\u{3A3}{after}").to_lowercase();
+ // Only the sigma's lower case depends on context, so the text before it keeps its length.
+ lower.get(before.to_lowercase().len()..).and_then(|s| s.chars().next()).unwrap_or('σ')
+}
+
/// One run of chars shaped with a single face and style.
struct Segment<'a> {
start: usize,
@@ -1065,6 +1077,8 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe
for u in c.to_uppercase() {
mapped.push((u, src, true));
}
+ } else if style.case == TextCase::Lower && *c == 'Σ' {
+ mapped.push((lower_sigma(chars, src), src, false));
} else {
let mut buf = [0u8; 4];
for u in style.case.apply_after(c.encode_utf8(&mut buf), prev).chars() {
diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs
index ebf5889c..4475fbbc 100644
--- a/crates/text/src/tests.rs
+++ b/crates/text/src/tests.rs
@@ -497,3 +497,25 @@ fn the_caret_after_a_line_break_sits_on_the_next_line() {
let l = layout(&TextData::new("ab\n"), None, None);
assert_eq!(l.caret(3), (0.0, 1));
}
+
+#[test]
+fn lower_case_writes_final_sigma() {
+ if !have_fonts() {
+ return;
+ }
+ let gids = |t: &TextData| layout(t, None, None).glyphs.iter().map(|g| (g.gid, g.char_start)).collect::>();
+ let want = gids(&TextData::new("οδος ας"));
+ let Some(face) = layout(&TextData::new("ς"), None, None).glyphs.first().map(|g| g.face.clone()) else { return };
+ if face.glyph_for('σ') == face.glyph_for('ς') {
+ eprintln!("face has no distinct final sigma; skipping");
+ return;
+ }
+ let mut t = TextData::new("ΟΔΟΣ ΑΣ");
+ t.style.case = figcraft_doc::TextCase::Lower;
+ assert_eq!(gids(&t), want, "one run");
+ // The word split across two styled runs: the final form is still chosen from the whole text.
+ let mut other = t.style.clone();
+ other.font_size += 4.0;
+ t.runs.push(figcraft_doc::StyleRun { start: 3, end: 4, style: other });
+ assert_eq!(gids(&t).iter().map(|g| g.0).collect::>(), want.iter().map(|g| g.0).collect::>(), "split runs");
+}
From 21d666465fda16e0c9cc54bbbd8b6f8121c3bac1 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Thu, 22 Oct 2026 20:34:42 +0200
Subject: [PATCH 050/123] Remove all leftover temp files at server startup
Fixes #1129
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
apps/figcraft-server/src/store.rs | 13 ++++++++++++-
apps/figcraft-server/src/store_tests.rs | 26 +++++++++++++++++++++++++
2 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs
index ea9bc626..8c6226a5 100644
--- a/apps/figcraft-server/src/store.rs
+++ b/apps/figcraft-server/src/store.rs
@@ -344,6 +344,9 @@ impl Store {
/// [`open`](Self::open), or `None` when someone else holds the directory.
pub fn try_open(root: &Path, cfg: StoreConfig) -> Result
, String> {
let Some(lock) = lock(root)? else { return Ok(None) };
+ for name in [KEY, BLOCKED, REPORTS] {
+ let _ = fs::remove_file(disk::temp_of(&root.join(name)));
+ }
let key = load_key(root)?;
let dir = root.join(FILES);
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
@@ -425,12 +428,20 @@ impl Store {
/// A file folder's description and links, after checking its snapshot and log. Leftover
/// temporary files (writes cut short) are removed.
fn check(&self, path: &Path) -> Result {
- for name in [SNAPSHOT, LOG, META, LINKS] {
+ for name in [SNAPSHOT, LOG, META, LINKS, USAGE] {
let tmp = disk::temp_of(&path.join(name));
if tmp.exists() {
let _ = fs::remove_file(&tmp);
}
}
+ if let Ok(entries) = fs::read_dir(path) {
+ for e in entries.flatten() {
+ let name = e.file_name().to_string_lossy().into_owned();
+ if name.starts_with("library-") && name.ends_with(".bin.tmp") {
+ let _ = fs::remove_file(e.path());
+ }
+ }
+ }
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
let links: Vec =
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs
index f380568c..6625a0dd 100644
--- a/apps/figcraft-server/src/store_tests.rs
+++ b/apps/figcraft-server/src/store_tests.rs
@@ -313,3 +313,29 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
let name = page_name(&restored);
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
}
+
+#[test]
+fn every_leftover_temporary_file_is_removed_at_startup() {
+ let dir = TempDir::new("leftovers");
+ let id = {
+ let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
+ let id = publish(&mut store, "A").0;
+ store.flush();
+ id
+ };
+ let folder = dir.path().join(FILES).join(&id);
+ let leftovers = [
+ dir.path().join("blocked.json.tmp"),
+ dir.path().join("reports.json.tmp"),
+ dir.path().join("server.key.tmp"),
+ folder.join("usage.json.tmp"),
+ folder.join("library-1.bin.tmp"),
+ ];
+ for path in &leftovers {
+ fs::write(path, b"half").unwrap();
+ }
+ let _store = Store::open(dir.path(), cfg(dir.path())).unwrap();
+ for path in &leftovers {
+ assert!(!path.exists(), "leftover not cleaned at startup: {}", path.display());
+ }
+}
From 82779ec0f29976b535389c1ee14324c1b7cbd8c8 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Thu, 22 Oct 2026 20:39:39 +0200
Subject: [PATCH 051/123] Guard file generation increments against u64 overflow
Fixes #1128
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
apps/figcraft-server/src/store.rs | 11 ++++++++--
apps/figcraft-server/src/store_tests.rs | 27 +++++++++++++++++++++++++
2 files changed, 36 insertions(+), 2 deletions(-)
diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs
index ea9bc626..cbd0e977 100644
--- a/apps/figcraft-server/src/store.rs
+++ b/apps/figcraft-server/src/store.rs
@@ -1167,7 +1167,10 @@ impl Store {
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
}
// A new snapshot and log of the next generation, so appends start clean.
- _ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?,
+ _ => {
+ let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?;
+ self.rewrite(file, next, &server).map_err(|e| e.why)?
+ }
};
self.entry(file)?.log = Some(log);
Ok(server)
@@ -1243,7 +1246,11 @@ impl Store {
fn compact(&mut self, file: &str, server: &Server) {
self.compactions += 1;
let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
- match self.rewrite(file, generation + 1, server) {
+ let Some(next) = generation.checked_add(1) else {
+ log::error!("file {file}: compaction failed: the file's generation can't go any higher");
+ return;
+ };
+ match self.rewrite(file, next, server) {
Ok(log) => {
if let Ok(e) = self.entry(file) {
e.log = Some(log);
diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs
index f380568c..17623f2f 100644
--- a/apps/figcraft-server/src/store_tests.rs
+++ b/apps/figcraft-server/src/store_tests.rs
@@ -313,3 +313,30 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
let name = page_name(&restored);
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
}
+
+#[test]
+fn a_maxed_out_generation_does_not_wrap() {
+ let dir = TempDir::new("maxgen");
+ let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
+ let mut store = Store::open(dir.path(), c.clone()).unwrap();
+ let (id, _, _) = publish(&mut store, "Max");
+ let path = dir.path().join(FILES).join(&id);
+ let (_, state) = disk::decode_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap();
+ // Loading: no log goes with the snapshot, so the next generation would overflow.
+ fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX, &state).unwrap()).unwrap();
+ fs::remove_file(path.join(LOG)).unwrap();
+ assert!(store.open(&id).is_err());
+ // Compacting: the open log is at the largest generation.
+ fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX - 1, &state).unwrap()).unwrap();
+ let _ = fs::remove_file(path.join(LOG));
+ let mut server = store.open(&id).unwrap();
+ let mut a = join(&mut server, Access::Edit);
+ store.changed(&id, &mut server);
+ for i in 0..4 {
+ rename(&mut server, &mut a, &format!("P{i}"));
+ store.changed(&id, &mut server);
+ store.flush();
+ }
+ let snap = disk::check_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap();
+ assert_eq!(snap, u64::MAX);
+}
From 8c46b733e6c087b2631c1e57c88de0f8bdab415b Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Thu, 22 Oct 2026 20:43:04 +0200
Subject: [PATCH 052/123] Restrict a pre-existing data directory to 0700
Fixes #1127
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
apps/figcraft-server/src/store.rs | 4 ++++
apps/figcraft-server/src/store_tests.rs | 11 +++++++++++
2 files changed, 15 insertions(+)
diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs
index ea9bc626..0cf62685 100644
--- a/apps/figcraft-server/src/store.rs
+++ b/apps/figcraft-server/src/store.rs
@@ -302,6 +302,10 @@ pub fn lock(root: &Path) -> Result
, String> {
+/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end.
+fn next_message(ws: &mut WebSocket, max: usize, deadline: Option) -> Result
, String> {
let Some(lock) = lock(root)? else { return Ok(None) };
let key = load_key(root)?;
@@ -374,7 +377,6 @@ impl Store {
store.scan(&dir);
store.blocked = store.read_list::(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
store.reports = store.read_list(REPORTS);
- store.sweep();
Ok(Some(store))
}
From 3b440d01e647449f94fcf5ec5dfd119b1a6462b2 Mon Sep 17 00:00:00 2001
From: skylar taylor-barrick
Date: Thu, 22 Oct 2026 18:39:53 -0600
Subject: [PATCH 088/123] #237: test that wrapped colour picker swatches stay
inset inside the picker
The "On this page" swatches were indented by a single add_space, so once they wrapped the later rows started at the picker's left edge and ran past its right edge. The test fills a page with 40 distinct colours, opens the picker and checks every painted swatch lies at least 16 pt inside both sides of the picker window. Without the fix the rightmost swatch ends 3 pt inside the edge instead of 16.
---
.../src/colorpicker_selection_tests.rs | 51 +++++++++++++++++++
1 file changed, 51 insertions(+)
diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs
index 57aae197..fff5536a 100644
--- a/crates/ui-egui/src/colorpicker_selection_tests.rs
+++ b/crates/ui-egui/src/colorpicker_selection_tests.rs
@@ -52,3 +52,54 @@ fn picker_closes_when_another_layer_is_selected() {
u.frame(vec![]);
assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker);
}
+
+// Regression test for #237: the "On this page" swatches wrapped onto rows that started at the picker's
+// left edge (only the first row was indented) and ran past its right edge.
+#[test]
+fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() {
+ use egui::Shape;
+ let mut s = Session::new();
+ let mut first = None;
+ for i in 0..40 {
+ let v = s
+ .execute(
+ "shape.rectangle",
+ &json!({"x": i * 30, "y": 0, "width": 20, "height": 20, "props": {"fill": format!("#{:02X}{:02X}80", i * 6, 255 - i * 6)}}),
+ )
+ .unwrap();
+ first.get_or_insert(NodeId(v["id"].as_u64().unwrap()));
+ }
+ let a = first.unwrap();
+ s.execute("select.set", &json!({"ids": [a]})).unwrap();
+ let mut u = Ui::new(s);
+ u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });
+ for _ in 0..3 {
+ u.frame(vec![]);
+ }
+ let out = u.frame_output(vec![]);
+ // The picker is the only floating window open.
+ let picker = u
+ .ctx
+ .memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Middle).find_map(|l| m.area_rect(l.id)))
+ .expect("the picker is open");
+ // A swatch is a 14 pt square with a 1 pt outline.
+ let swatches: Vec = out
+ .shapes
+ .iter()
+ .filter_map(|c| match &c.shape {
+ Shape::Rect(r) if r.stroke.width == 1.0 && (r.rect.width() - 14.0).abs() < 0.01 && (r.rect.height() - 14.0).abs() < 0.01 => Some(r.rect),
+ _ => None,
+ })
+ .filter(|r| picker.contains_rect(*r))
+ .collect();
+ let mut rows: Vec = swatches.iter().map(|r| r.min.y).collect();
+ rows.sort_by(f32::total_cmp);
+ rows.dedup_by(|a, b| (*a - *b).abs() < 1.0);
+ assert!(rows.len() >= 2, "expected the page colours to wrap onto several rows, got {} swatches in {} row(s)", swatches.len(), rows.len());
+ for r in &swatches {
+ assert!(
+ r.min.x >= picker.min.x + 16.0 - 0.5 && r.max.x <= picker.max.x - 16.0 + 0.5,
+ "swatch {r:?} is not inset 16 pt from both sides of the picker {picker:?}"
+ );
+ }
+}
From af0b150736ccbcf52c9f46aae9a108f63477a3a3 Mon Sep 17 00:00:00 2001
From: Marco Arnulfo
Date: Fri, 23 Oct 2026 02:12:13 +0200
Subject: [PATCH 089/123] M3: instance-swap properties in the Dev Mode
component playground
---
crates/ui-egui/src/component_panel.rs | 82 +++++++++++++++++----------
crates/ui-egui/src/dev_mode.rs | 18 ++++--
crates/ui-egui/src/dev_mode_tests.rs | 79 +++++++++++++++++++++++++-
docs/components-parity.md | 5 +-
docs/target-app-parity.md | 2 +-
5 files changed, 147 insertions(+), 39 deletions(-)
diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs
index 2b2573c7..b869e1c0 100644
--- a/crates/ui-egui/src/component_panel.rs
+++ b/crates/ui-egui/src/component_panel.rs
@@ -88,7 +88,7 @@ fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str)
/// Local components (and component sets, not their variants), for swap menus: (id, label), page
/// by page. Listed once per document revision (a file with thousands of layers isn't walked every
/// frame).
-fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc> {
+pub(crate) fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc> {
type Cached = ((u64, u64), std::sync::Arc>);
let key = app.session.ds().map(|d| (d.uid, d.revision)).unwrap_or_default();
let id = Id::new("component_panel_local_components");
@@ -708,35 +708,9 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps
});
}
PropDef::InstanceSwap { preferred, .. } => {
- let cur = figcraft_engine::params::id_of(&v);
- let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into());
- let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance));
- menu_popup(&resp).width(200.0).show(|ui| {
- let t = Tokens::get(ui.ctx());
- let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary));
- let mut pick = |ui: &mut Ui, c: NodeId, name: &str| {
- let current = cur == Some(c);
- if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") {
- if !current {
- app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}}));
- }
- ui.close();
- }
- };
- let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect();
- if !preferred.is_empty() {
- heading(ui, tr("Preferred"));
- for (c, name) in &preferred {
- pick(ui, *c, name);
- }
- heading(ui, tr("All components"));
- }
- egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
- for (c, name) in comps {
- pick(ui, *c, name);
- }
- });
- });
+ if let Some(c) = swap_dropdown(ui, doc, figcraft_engine::params::id_of(&v), &preferred, comps, w, "").1 {
+ app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}}));
+ }
}
PropDef::Variant { .. } => {}
PropDef::Slot { preferred } => {
@@ -749,6 +723,54 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps
}
}
+/// The dropdown of an instance-swap property showing component `cur`: its menu lists the
+/// property's preferred components first (when it has any), then every component in the file. The
+/// dropdown, and the component picked (picking the current one picks nothing). `name` labels it for
+/// assistive technology.
+pub(crate) fn swap_dropdown(
+ ui: &mut Ui,
+ doc: &Document,
+ cur: Option,
+ preferred: &[NodeId],
+ comps: &[(NodeId, String)],
+ w: f32,
+ name: &str,
+) -> (egui::Response, Option) {
+ let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into());
+ let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance));
+ if !name.is_empty() {
+ crate::a11y::combo(&resp, name, &label, crate::a11y::menu_open(&resp));
+ }
+ let mut picked = None;
+ menu_popup(&resp).width(200.0).show(|ui| {
+ let t = Tokens::get(ui.ctx());
+ let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary));
+ let mut pick = |ui: &mut Ui, c: NodeId, name: &str| {
+ let current = cur == Some(c);
+ if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") {
+ if !current {
+ picked = Some(c);
+ }
+ ui.close();
+ }
+ };
+ let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect();
+ if !preferred.is_empty() {
+ heading(ui, tr("Preferred"));
+ for (c, name) in &preferred {
+ pick(ui, *c, name);
+ }
+ heading(ui, tr("All components"));
+ }
+ egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
+ for (c, name) in comps {
+ pick(ui, *c, name);
+ }
+ });
+ });
+ (resp, picked)
+}
+
fn prop_label(ui: &mut Ui, name: &str, icon: Icon) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(84.0, 20.0), Sense::hover());
diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs
index 0b429820..735da4bb 100644
--- a/crates/ui-egui/src/dev_mode.rs
+++ b/crates/ui-egui/src/dev_mode.rs
@@ -281,13 +281,14 @@ pub fn code(app: &mut FigApp, ui: &Ui, id: NodeId, lang: CodeLang) -> String {
}
/// The component playground, for a selected instance, component or component set: a preview of the
-/// component and a control for each variant, boolean and text property. The values tried here
-/// change the preview and the code only (returned when any is set: the Code section shows it),
-/// never the document.
+/// component and a control for each variant, boolean, text and instance-swap property. The values
+/// tried here change the preview and the code only (returned when any is set: the Code section
+/// shows it), never the document.
pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, lang: CodeLang) -> Option {
figcraft_engine::playground::component_of(doc, id)?;
let t = Tokens::get(ui.ctx());
let Ok((uid, revision)) = app.session.ds().map(|d| (d.uid, d.revision)) else { return None };
+ let comps = crate::component_panel::local_components(app, ui, doc);
let play = &mut app.ui.dev.playground;
if play.target != Some(id) {
*play = Playground { target: Some(id), ..Playground::default() };
@@ -357,7 +358,16 @@ pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: Nod
set = Some((name.to_string(), json!(new.chars().take(10_000).collect::())));
}
}),
- // Instance swaps and slots show as the component has them.
+ "instanceSwap" => row(ui, 32.0, |ui| {
+ widgets::label(ui, name, t.text_secondary);
+ let preferred: Vec = p["preferred"].as_array().into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect();
+ let w = ui.available_width().max(60.0);
+ if let Some(c) = crate::component_panel::swap_dropdown(ui, doc, figcraft_engine::params::id_of(value), &preferred, &comps, w, name).1
+ {
+ set = Some((name.to_string(), json!(c.0)));
+ }
+ }),
+ // Slots show as the component has them.
_ => {}
}
}
diff --git a/crates/ui-egui/src/dev_mode_tests.rs b/crates/ui-egui/src/dev_mode_tests.rs
index 941f1b2c..3e2ea9ba 100644
--- a/crates/ui-egui/src/dev_mode_tests.rs
+++ b/crates/ui-egui/src/dev_mode_tests.rs
@@ -1,8 +1,10 @@
//! Dev Mode in the UI, driven headless (the harness is `present_tests`'): the compare view opens
//! from `dev.compare {show}`, switches to the overlay and closes with Escape before the focus view
-//! does; the focus view's Escape; the annotation filter.
+//! does; the focus view's Escape; the annotation filter; the playground's instance-swap menu.
-use egui::{Key, pos2};
+use egui::accesskit::{Node, Role};
+use egui::{Event, Key, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2};
+use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
@@ -80,3 +82,76 @@ fn annotation_filter_and_categories_panel() {
assert_eq!(CategoryFilter::parse(&doc, "none"), Some(CategoryFilter::Uncategorized));
assert_eq!(CategoryFilter::parse(&doc, "nope"), None);
}
+
+/// A "Button" component whose nested "Heart" instance is its instance-swap property "Icon", a
+/// second icon "Star", and an instance of the button selected in the Inspect tab. Returns (ui,
+/// instance, heart, star).
+fn swap_setup() -> (crate::layers_drag_tests::Ui, NodeId, NodeId, NodeId) {
+ let mut s = Session::new();
+ let icon = |s: &mut Session, name: &str, x: i32| {
+ let f = id(&s.execute("frame.create", &json!({"x": x, "y": 0, "width": 24, "height": 24, "name": name})).unwrap());
+ id(&s.execute("component.create", &json!({"ids": [f]})).unwrap())
+ };
+ let (heart, star) = (icon(&mut s, "Heart", 0), icon(&mut s, "Star", 40));
+ let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 100, "width": 120, "height": 40, "name": "Button"})).unwrap());
+ let nested = id(&s.execute("instance.create", &json!({"component": heart, "x": 8, "y": 108, "parent": f})).unwrap());
+ let button = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap());
+ s.execute("component.addProperty", &json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested})).unwrap();
+ let inst = id(&s.execute("instance.create", &json!({"component": button, "x": 0, "y": 300})).unwrap());
+ s.execute("select.set", &json!({"ids": [inst]})).unwrap();
+ let mut u = crate::layers_drag_tests::Ui::new(s);
+ u.app.ui.right_tab = crate::RightTab::Inspect;
+ u.ctx.enable_accesskit();
+ (u, inst, heart, star)
+}
+
+fn tree(u: &mut crate::layers_drag_tests::Ui) -> Vec {
+ u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect()
+}
+
+fn centre(n: &Node) -> Pos2 {
+ let b = n.bounds().unwrap();
+ pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
+}
+
+fn click(u: &mut crate::layers_drag_tests::Ui, at: Pos2) {
+ u.button(at, true);
+ u.button(at, false);
+}
+
+/// The playground's dropdown for property `name`, scrolled into view.
+fn playground_combo(u: &mut crate::layers_drag_tests::Ui, name: &str) -> Option {
+ for _ in 0..20 {
+ let combo = tree(u).into_iter().find(|n| n.role() == Role::ComboBox && n.label() == Some(name))?;
+ if centre(&combo).y < 860.0 {
+ return Some(combo);
+ }
+ let wheel =
+ Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -100.0), phase: TouchPhase::Move, modifiers: Default::default() };
+ u.frame(vec![Event::PointerMoved(pos2(1300.0, 500.0)), wheel]);
+ for _ in 0..30 {
+ u.frame(vec![]);
+ }
+ }
+ None
+}
+
+#[test]
+fn the_playground_swaps_an_instance_in_the_preview_only() {
+ let (mut u, inst, heart, star) = swap_setup();
+ let (doc, undo) = (u.app.session.ds().unwrap().doc.clone(), u.app.session.undo_depth());
+ let combo = playground_combo(&mut u, "Icon").expect("the playground has a dropdown for the instance-swap property");
+ assert_eq!(combo.value(), Some("Heart"));
+ // Open it and pick Star: the item that wasn't there before the menu opened.
+ let before: Vec = tree(&mut u).iter().filter(|n| n.label() == Some("Star")).map(centre).collect();
+ click(&mut u, centre(&combo));
+ let item = tree(&mut u).into_iter().find(|n| n.label() == Some("Star") && !before.contains(¢re(n))).expect("Star in the menu");
+ click(&mut u, centre(&item));
+ let combo = playground_combo(&mut u, "Icon").unwrap();
+ assert_eq!(combo.value(), Some("Star"), "the playground shows the swapped icon");
+ // The file isn't touched: the instance still has the heart, nothing to undo.
+ assert!(std::sync::Arc::ptr_eq(&doc, &u.app.session.ds().unwrap().doc));
+ assert_eq!(u.app.session.undo_depth(), undo);
+ let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap();
+ assert_eq!(props["properties"][0]["value"], json!(heart.0), "{star:?}");
+}
diff --git a/docs/components-parity.md b/docs/components-parity.md
index fad4c6fe..7f7c162f 100644
--- a/docs/components-parity.md
+++ b/docs/components-parity.md
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (duplicating or Alt-dragging a main component makes an instance of it (#892); 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)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); 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
@@ -29,7 +29,7 @@ accounts).
| Assets panel: folders by page, frame and slash names (fold away), thumbnails, search, drag into frames and auto layout slots | done | virtualized; `component.list` gives each one's `folder` |
| Hidden from publishing: a leading `.` or `_` in the name | done | the name decides both ways (adding the prefix takes a published component out, removing it brings it back); Hide when publishing in the Assets panel's context menu and Libraries ▸ Hide when publishing (see [ui-parity](ui-parity.md)) |
| Instances pasted into other files follow their mains | done | missing main restorable |
-| Component playground (try properties without placing) | partial | variant, boolean and text properties in the Inspect tab and `component.playground`; instance swaps and slots have no control there yet (0.5–1 h) |
+| Component playground (try properties without placing) | partial | variant, boolean, text and instance-swap properties in the Inspect tab (the swap menu lists the preferred components first, then every component in the file, as in the Design panel) and `component.playground`; slots have no control there yet |
| Real-world libraries QA | **not checked** | [gaps](gaps.md) #3 |
The behaviour of instances, overrides, variants and swaps is checked against the public
@@ -77,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-23 | minor | Instance-swap properties in the component playground: the Inspect tab's Playground shows a dropdown for each, with the same menu as the Design panel's (the property's preferred components first, then every component in the file). The component picked changes the preview and the code only, never the file. Left: slots in the playground. Numbers unchanged. |
| 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. |
| 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 |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index a1a5bf12..5f88498b 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -104,7 +104,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
| 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 |
| 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 |
| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 |
-| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 |
+| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean, text and instance-swap properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 |
| 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 |
| 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 |
| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 |
From 410c1f10e263f586981d101fbe93758a5ab932b8 Mon Sep 17 00:00:00 2001
From: Marco Arnulfo
Date: Fri, 23 Oct 2026 03:38:33 +0200
Subject: [PATCH 090/123] The colour picker's eyedropper applies the colour to
the paint the picker edits (Reported in storytold/vectorcraft#3342)
---
crates/engine/src/cmd/view.rs | 9 +-
crates/engine/src/fx_behaviour_tests.rs | 23 +++++
crates/engine/src/tools.rs | 10 ++-
crates/ui-egui/src/colorpicker.rs | 16 +++-
.../src/colorpicker_eyedropper_tests.rs | 90 +++++++++++++++++++
crates/ui-egui/src/lib.rs | 2 +
6 files changed, 145 insertions(+), 5 deletions(-)
create mode 100644 crates/ui-egui/src/colorpicker_eyedropper_tests.rs
diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs
index 0592046e..c9715e77 100644
--- a/crates/engine/src/cmd/view.rs
+++ b/crates/engine/src/cmd/view.rs
@@ -534,7 +534,14 @@ pub fn specs() -> Vec {
spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)),
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
- spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)),
+ // `apply: false` is for the UI colour picker: the eyedropper only samples (into
+ // `ToolState::picked`) and the picker writes the colour to the paint it edits.
+ spec!("edit.pickColor", "Pick color", "{apply?}", false, |s, p| {
+ let r = tool(s, ToolId::Eyedropper)?;
+ s.tool.pick_only = !boolean(p, "apply").unwrap_or(true);
+ s.tool.picked = None;
+ Ok(r)
+ }),
]
}
diff --git a/crates/engine/src/fx_behaviour_tests.rs b/crates/engine/src/fx_behaviour_tests.rs
index eb8da9b2..d9d93592 100644
--- a/crates/engine/src/fx_behaviour_tests.rs
+++ b/crates/engine/src/fx_behaviour_tests.rs
@@ -53,6 +53,29 @@ fn the_eyedropper_picks_the_colour_seen_and_fills_the_selection() {
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into()));
}
+#[test]
+fn the_eyedropper_of_a_colour_picker_samples_without_filling() {
+ // Opened from a colour picker (`apply: false`), the eyedropper hands the colour to the picker,
+ // which writes it to its own paint (a stroke, an effect...): the fill stays as it was.
+ let mut s = Session::new();
+ s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap();
+ rect(&mut s, None, (0.0, 0.0, 100.0, 100.0), "#FF0000");
+ let b = rect(&mut s, None, (200.0, 0.0, 50.0, 50.0), "#00FF00");
+ s.execute("select.set", &json!({"ids": [b]})).unwrap();
+ let undo = s.undo_depth().0;
+ s.execute("edit.pickColor", &json!({"apply": false})).unwrap();
+ s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap();
+ assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into()));
+ assert_eq!(s.undo_depth().0, undo, "nothing to undo");
+ assert_eq!(s.tool.picked.take().map(|c| c.to_hex()), Some("FF0000".into()));
+ assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper");
+ // The I key's eyedropper fills the selection again.
+ s.execute("edit.pickColor", &json!({})).unwrap();
+ s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap();
+ assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into()));
+ assert_eq!(s.tool.picked, None);
+}
+
#[test]
fn blend_modes_and_effects_through_commands() {
let mut s = Session::new();
diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs
index f0115e1f..b5e6538e 100644
--- a/crates/engine/src/tools.rs
+++ b/crates/engine/src/tools.rs
@@ -462,6 +462,11 @@ pub struct ToolState {
pub labels: Vec<(NodeId, [Point; 4])>,
/// The brush the Pencil draws with (`tool.pencilBrush`; `None`: a plain stroke).
pub pencil_brush: Option,
+ /// The eyedropper samples for a colour picker (`edit.pickColor` with `apply: false`): the
+ /// colour goes to `picked`, for the picker to write to the paint it edits, not to the fill.
+ pub pick_only: bool,
+ /// The colour the eyedropper sampled for a colour picker, until the picker takes it.
+ pub picked: Option,
}
/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most
@@ -923,6 +928,7 @@ impl Session {
}
self.tool.tool = t;
self.tool.gesture = Gesture::None;
+ self.tool.pick_only = false;
// The tool's cursor shows before the pointer moves.
self.tool.overlay =
Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() };
@@ -2032,7 +2038,9 @@ impl Session {
255,
);
let sel = ds.selection.clone();
- if !sel.is_empty() {
+ if self.tool.pick_only {
+ self.tool.picked = Some(c);
+ } else if !sel.is_empty() {
self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?;
}
self.recent_colors.retain(|x| *x != c);
diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs
index d6aad31d..96188631 100644
--- a/crates/ui-egui/src/colorpicker.rs
+++ b/crates/ui-egui/src/colorpicker.rs
@@ -436,7 +436,14 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> {
}
pub fn show(app: &mut FigApp, ctx: &egui::Context) {
- let Some(target) = app.ui.picker.clone() else { return };
+ let Some(target) = app.ui.picker.clone() else {
+ // A colour sampled for a picker that has closed has no paint to go to.
+ app.session.tool.picked = None;
+ if app.session.tool.pick_only {
+ app.run("tool.move", json!({}));
+ }
+ return;
+ };
// A layer's picker belongs to the selection it was opened for: once that changes, close it
// rather than keep editing layers that are no longer selected.
let stale = match &target {
@@ -470,6 +477,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
.open(&mut open)
.show(ctx, |ui| {
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
+ // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...).
+ if let Some(c) = app.session.tool.picked.take() {
+ set_color(app, &target, &mut paint, c, stop_id(), ui);
+ }
// Header.
let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover());
ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text);
@@ -772,8 +783,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
)
.clicked()
{
- app.ui.picker = None;
- app.run("edit.pickColor", json!({}));
+ app.run("edit.pickColor", json!({"apply": false}));
return;
}
let (c2, d2) = hue_slider(ui, hr, &mut hsb);
diff --git a/crates/ui-egui/src/colorpicker_eyedropper_tests.rs b/crates/ui-egui/src/colorpicker_eyedropper_tests.rs
new file mode 100644
index 00000000..51856b48
--- /dev/null
+++ b/crates/ui-egui/src/colorpicker_eyedropper_tests.rs
@@ -0,0 +1,90 @@
+//! The eyedropper of a colour picker, driven through egui headless: the colour it samples goes to
+//! the paint the picker edits (a stroke, an effect), not to the layer's fill (vectorcraft#3342).
+
+use egui::accesskit::Role;
+use egui::{Event, Pos2, pos2};
+use figcraft_doc::NodeId;
+use figcraft_engine::{Session, ToolId};
+use serde_json::json;
+
+use crate::colorpicker::PickerTarget;
+use crate::layers_drag_tests::Ui;
+
+fn rect(s: &mut Session, x: f64, props: serde_json::Value) -> NodeId {
+ let v = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 60, "height": 60, "props": props})).unwrap();
+ NodeId(v["id"].as_u64().unwrap())
+}
+
+/// The middle of the picker's eyedropper button.
+fn eyedropper_button(u: &mut Ui) -> Pos2 {
+ u.ctx.enable_accesskit();
+ u.frame(vec![]);
+ let out = u.frame_output(vec![]);
+ let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
+ let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some("Pick color (I)")).expect("the eyedropper button");
+ let b = n.bounds().expect("bounds");
+ pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
+}
+
+fn click(u: &mut Ui, p: Pos2) {
+ u.button(p, true);
+ u.button(p, false);
+ u.frame(vec![Event::PointerMoved(p)]);
+ u.frame(vec![]);
+}
+
+#[test]
+fn the_pickers_eyedropper_applies_the_colour_to_the_paint_it_edits() {
+ for effect in [false, true] {
+ // A red layer to sample, and a green one with a blue stroke (and a shadow) to edit.
+ let mut s = Session::new();
+ s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap();
+ let red = rect(&mut s, 0.0, json!({"fill": "#FF0000"}));
+ let b = rect(&mut s, 200.0, json!({"fill": "#00FF00", "stroke": "#0000FF", "strokeWeight": 2}));
+ s.execute("select.set", &json!({"ids": [b]})).unwrap();
+ let target = if effect {
+ s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap();
+ PickerTarget::Effect { ids: vec![b], index: 0, secondary: false }
+ } else {
+ PickerTarget::Paint { fills: false, index: 0, ids: vec![b] }
+ };
+ let mut u = Ui::new(s);
+ u.app.ui.picker = Some(target.clone());
+ let at = eyedropper_button(&mut u);
+ click(&mut u, at);
+ assert_eq!(u.app.session.tool.tool, ToolId::Eyedropper, "the button picks up the eyedropper");
+ let undo = u.app.session.undo_depth().0;
+ let on_red = u.app.to_screen(30.0, 30.0);
+ click(&mut u, on_red);
+ let doc = u.app.session.doc().unwrap();
+ let n = doc.node(b).unwrap();
+ let hex = |p: Option| p.map(|c| c.to_hex()).unwrap_or_default();
+ let picked = if effect { hex(n.effects[0].color(false)) } else { hex(n.strokes[0].solid_color()) };
+ assert_eq!(picked, "FF0000", "the picker's paint takes the colour (effect: {effect})");
+ assert_eq!(hex(n.fills[0].solid_color()), "00FF00", "the fill is left alone (effect: {effect})");
+ assert!(doc.node(red).is_some());
+ assert_eq!(u.app.session.tool.tool, ToolId::Move, "picking ends the eyedropper");
+ assert_eq!(u.app.ui.picker, Some(target), "the picker stays open on its paint");
+ assert_eq!(u.app.session.undo_depth().0, undo + 1, "one undo step");
+ }
+}
+
+#[test]
+fn closing_the_picker_drops_its_eyedropper() {
+ // The colour sampled after the picker closed has no paint to go to: it must not wait for the
+ // next picker, and the eyedropper gives way to the Move tool.
+ let mut s = Session::new();
+ let b = rect(&mut s, 200.0, json!({"fill": "#00FF00", "stroke": "#0000FF"}));
+ s.execute("select.set", &json!({"ids": [b]})).unwrap();
+ let mut u = Ui::new(s);
+ u.app.ui.picker = Some(PickerTarget::Paint { fills: false, index: 0, ids: vec![b] });
+ let at = eyedropper_button(&mut u);
+ click(&mut u, at);
+ assert_eq!(u.app.session.tool.tool, ToolId::Eyedropper);
+ u.app.ui.picker = None;
+ u.app.session.tool.picked = figcraft_color::Color::from_hex("#FF0000");
+ u.frame(vec![]);
+ u.frame(vec![]);
+ assert_eq!(u.app.session.tool.tool, ToolId::Move);
+ assert_eq!(u.app.session.tool.picked, None, "a colour sampled for a closed picker is dropped");
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
index c93333b3..00d94e22 100644
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -87,6 +87,8 @@ mod canvas_fixes_tests;
#[cfg(test)]
mod canvas_image_rect_tests;
#[cfg(test)]
+mod colorpicker_eyedropper_tests;
+#[cfg(test)]
mod colorpicker_selection_tests;
#[cfg(test)]
mod comments_tests;
From 2a6e460a7b8961ce8670418138cabc62ff47e93a Mon Sep 17 00:00:00 2001
From: Marco Arnulfo
Date: Fri, 23 Oct 2026 10:17:56 +0200
Subject: [PATCH 091/123] Let the box of another layer's points win over a
region of the active one
With points selected only on another edited layer, a press inside the
combined box over an unselected filled region of the active layer picked
the region instead of dragging the box. Other layers' selections now count
in the same priority check; a press on an already selected region still
drags the regions.
---
crates/engine/src/vectoredit/pointer.rs | 6 ++--
.../engine/src/vectoredit/transform_tests.rs | 29 +++++++++++++++++++
2 files changed, 32 insertions(+), 3 deletions(-)
diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs
index fc6f7891..ed13ed70 100644
--- a/crates/engine/src/vectoredit/pointer.rs
+++ b/crates/engine/src/vectoredit/pointer.rs
@@ -428,11 +428,11 @@ impl Session {
}
/// The region of the active layer a press at `ev` would take (Move tool, `h` the network's part
- /// there). Inside the transform box around points selected in their own right, the box keeps
- /// its drag unless the region is already selected.
+ /// there). Inside the transform box around points selected in their own right (in any edited
+ /// layer), the box keeps its drag unless the region is already selected.
fn region_under(doc: &Document, ve: &VectorEdit, h: Option, ev: &PointerEvent) -> Option {
let r = region_hit(doc, ve, ev.p(), ev.zoom)?;
- let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty();
+ let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty() || ve.others_selected();
let in_box = !ev.shift && own && transform::box_hit(doc, ve, h, ev.p(), ev.zoom) == Some(BoxHit::Inside);
(!in_box || ve.regions.contains(&r)).then_some(r)
}
diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/vectoredit/transform_tests.rs
index 73a49aab..f29ed2a5 100644
--- a/crates/engine/src/vectoredit/transform_tests.rs
+++ b/crates/engine/src/vectoredit/transform_tests.rs
@@ -397,3 +397,32 @@ fn inside_the_box_of_several_layers_drags_and_a_click_clears_them_all() {
click(&mut s, 150.0, 50.0);
assert!(ve(&s).parts().iter().all(|p| p.vertices.is_empty()), "a click inside clears every layer's points");
}
+
+#[test]
+fn inside_the_box_of_another_layer_drags_over_a_region_of_the_active_one() {
+ // `b`'s points selected, `a` (a filled 300 square around `b`) active with nothing selected:
+ // the press at the box's centre lands on `a`'s region too.
+ let mut s = Session::new();
+ let mut square = |x: f64, size: f64| {
+ crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": x, "y": x, "width": size, "height": size}))["id"]).unwrap()
+ };
+ let (a, b) = (square(0.0, 300.0), square(100.0, 50.0));
+ ex(&mut s, "view.snapGeometry", json!({"on": false}));
+ ex(&mut s, "view.snapObjects", json!({"on": false}));
+ ex(&mut s, "view.snapPixel", json!({"on": false}));
+ ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]}));
+ ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3]}));
+ ex(&mut s, "vector.edit.select", json!({"id": a, "add": true}));
+ let doc = s.doc().unwrap().clone();
+ let e = ve(&s);
+ assert!(e.id == a && e.vertices.is_empty() && e.others_selected());
+ assert!(hit_any(&doc, &e, pt(125.0, 125.0), 6.0).is_none());
+ assert!(regions::region_hit(&doc, &e, pt(125.0, 125.0), 1.0).is_some(), "a region of the active layer under the press");
+ assert_eq!(box_hit(&doc, &e, None, pt(125.0, 125.0), 1.0), Some(BoxHit::Inside));
+ let before: Vec = (0..4).map(|v| page_pt(&s, b, v)).collect();
+ drag(&mut s, (125.0, 125.0), (145.0, 135.0));
+ assert!(ve(&s).regions.is_empty(), "the region isn't selected");
+ for (v, p) in before.iter().enumerate() {
+ assert!(near(page_pt(&s, b, v), *p + Vec2::new(20.0, 10.0)), "point {v} of b moves along");
+ }
+}
From 42502a81d72229a5415fbc6a68670af37a523b14 Mon Sep 17 00:00:00 2001
From: oracola91 <118899664+oracola91@users.noreply.github.com>
Date: Fri, 23 Oct 2026 11:59:36 +0300
Subject: [PATCH 092/123] Control server: wait after a failed accept instead of
retrying at once
The accepting thread read listener.incoming().flatten(), which drops a
failed accept and asks again at once. While accepting keeps failing, for
example when the process has run out of file descriptors, that thread
kept a core busy. It now waits 100 ms after a failed accept. The loop goes
through a small accepted helper so the wait can be tested.
---
apps/figcraft/src/control_server.rs | 11 ++++++++++-
apps/figcraft/src/control_server_tests.rs | 12 ++++++++++++
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs
index 1c91840f..ff9c2fbe 100644
--- a/apps/figcraft/src/control_server.rs
+++ b/apps/figcraft/src/control_server.rs
@@ -16,6 +16,10 @@ const MAX_LINE: usize = 32 * 1024 * 1024;
const MAX_CONNECTIONS: usize = 8;
const MAX_QUEUED_REQUESTS: usize = 8;
const TIMEOUT: Duration = Duration::from_secs(60);
+/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
+/// example while the process has no file descriptors left, and retrying at once would keep a
+/// core busy until it recovers.
+const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
pub fn drain_queue(queue: &Queue, target: &mut Vec) {
@@ -38,7 +42,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
eprintln!("FigCraft control server listening on 127.0.0.1:{port}");
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
let mut workers = Vec::new();
- for stream in listener.incoming().flatten() {
+ for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
}
});
@@ -47,6 +51,11 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
}
}
+/// The connections `incoming` accepts, calling `wait` after each failed accept.
+fn accepted(incoming: impl Iterator>, mut wait: impl FnMut()) -> impl Iterator {
+ incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
+}
+
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec>, timeout: Duration) {
workers.retain(|worker| !worker.is_finished());
if workers.len() >= MAX_CONNECTIONS {
diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs
index 008170fb..011b0613 100644
--- a/apps/figcraft/src/control_server_tests.rs
+++ b/apps/figcraft/src/control_server_tests.rs
@@ -131,6 +131,18 @@ fn idle_and_partial_frame_connections_expire() {
}
}
+/// A failed accept is followed by a wait before the next one, so a listener that keeps failing
+/// does not spin, and the connections around the failures still come through.
+#[test]
+fn a_failed_accept_waits_before_the_next() {
+ let failed = || Err(std::io::Error::other("no file descriptors left"));
+ let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
+ let mut waits = 0;
+ let connections: Vec = accepted(incoming.into_iter(), || waits += 1).collect();
+ assert_eq!(connections, [1, 2]);
+ assert_eq!(waits, 3, "one wait per failed accept");
+}
+
#[test]
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
let pending = queue();
From 32735549c4d05e2b8f8dd163e2d9b038775f24bc Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 09:29:39 -0400
Subject: [PATCH 093/123] test: explain the discarded reads in the fake servers
(SonarCloud)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
read() returns Result, not a lock guard; these test loops
deliberately ignore what the client sent first.
🤖 Generated with Codebuff
Co-Authored-By: Codebuff
---
apps/figcraft-server/src/server_tests.rs | 4 +++-
crates/collab-net/src/code_tests.rs | 4 +++-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/apps/figcraft-server/src/server_tests.rs b/apps/figcraft-server/src/server_tests.rs
index 153bb5ab..f60150da 100644
--- a/apps/figcraft-server/src/server_tests.rs
+++ b/apps/figcraft-server/src/server_tests.rs
@@ -261,7 +261,9 @@ fn fuzzed_frames_dont_hurt_the_server() {
break;
}
}
- let _ = ws.read();
+ // read() returns Result, not a lock guard; the fuzz loop ends here on purpose and
+ // the close frame from the server is not interesting.
+ let _ = ws.read(); // NOSONAR
}
// Still serving, the file intact.
let mut b = join(&p.edit, "Bo");
diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs
index 4880242f..7fe766ec 100644
--- a/crates/collab-net/src/code_tests.rs
+++ b/crates/collab-net/src/code_tests.rs
@@ -141,7 +141,9 @@ fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) {
let t = std::thread::spawn(move || {
for stream in listener.incoming().take(2).flatten() {
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
- let _ = ws.read();
+ // read() returns Result, not a lock guard; this test server deliberately
+ // ignores whatever the client sent first.
+ let _ = ws.read(); // NOSONAR
let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() };
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&refused).unwrap())));
let _ = ws.close(None);
From 8d790607ff1ad40e32343ca2116d8fdcf3244c8d Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 11:17:32 -0400
Subject: [PATCH 094/123] packaging: bash test and function hygiene (SonarCloud
S7688, S7679, S7682, S131, S7677) (#1201)
- Use [[ ]] instead of [ ] for conditional tests in the bash packaging scripts (S7688). Every
converted test compares quoted values to literals, so no pattern-matching change.
- Name positional parameters with locals inside functions (S7679).
- End the env.sh helpers with an explicit return that keeps their exit status (S7682).
- Give case statements a default branch (S131).
- Send the check-app-specific.sh error to stderr (S7677).
packaging/freebsd/package.sh is left alone while #1158 rewrites it.
---
packaging/check-app-specific.sh | 2 +-
packaging/env.sh | 26 ++++++----
packaging/linux/apprun-test.sh | 30 +++++------
packaging/linux/flatpak-bundle.sh | 10 ++--
packaging/linux/package.sh | 18 +++----
packaging/macos/import-cert.sh | 6 +--
packaging/macos/package.sh | 26 +++++-----
packaging/macos/verify.sh | 30 +++++------
packaging/server/install-test.sh | 6 +--
packaging/server/install.sh | 84 ++++++++++++++++---------------
packaging/server/package.sh | 3 +-
packaging/web/package.sh | 10 ++--
12 files changed, 131 insertions(+), 120 deletions(-)
diff --git a/packaging/check-app-specific.sh b/packaging/check-app-specific.sh
index ebf23c0b..e9b03946 100644
--- a/packaging/check-app-specific.sh
+++ b/packaging/check-app-specific.sh
@@ -6,7 +6,7 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.."
bash packaging/server/install-test.sh
if grep -rniE 'photoshop|psd|photocraft|vectorcraft|illustrator|adobe|figma' packaging/ .github/ \
--exclude=packaging-lint.yml --exclude=check-app-specific.sh; then
- echo "::error::package metadata mentions another product (see above)"
+ echo "::error::package metadata mentions another product (see above)" >&2
exit 1
fi
diff --git a/packaging/env.sh b/packaging/env.sh
index b68406a0..eb6547c9 100644
--- a/packaging/env.sh
+++ b/packaging/env.sh
@@ -24,10 +24,11 @@ workspace_version() {
/^\[/ { in_pkg = ($0 == "[workspace.package]") ; next }
in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit }
' "$ROOT/Cargo.toml"
+ return $?
}
VERSION="${FIGCRAFT_VERSION:-$(workspace_version)}"
-if [ -z "$VERSION" ]; then
+if [[ -z "$VERSION" ]]; then
echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2
exit 1
fi
@@ -37,7 +38,7 @@ DIST="${DIST:-$ROOT/dist/release}"
mkdir -p "$DIST"
export DIST
-if [ -z "${FIGCRAFT_BUILD_SHA:-}" ]; then
+if [[ -z "${FIGCRAFT_BUILD_SHA:-}" ]]; then
FIGCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)"
fi
export FIGCRAFT_BUILD_SHA
@@ -47,7 +48,7 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}"
# The headless CLI ships next to the app once apps/figcraft-cli exists.
BINS=(figcraft)
HAS_CLI=0
-if [ -f "$ROOT/apps/figcraft-cli/Cargo.toml" ]; then
+if [[ -f "$ROOT/apps/figcraft-cli/Cargo.toml" ]]; then
BINS+=(figcraft-cli)
HAS_CLI=1
fi
@@ -58,7 +59,8 @@ unset _bin
# The binary used for a `--version` smoke test: the CLI when shipped, else the app (which answers
# --version before opening a window).
smoke_bin() {
- if [ "$HAS_CLI" = 1 ]; then echo figcraft-cli; else echo figcraft; fi
+ if [[ "$HAS_CLI" = 1 ]]; then echo figcraft-cli; else echo figcraft; fi
+ return 0
}
# Emit a GitHub Actions warning (plain stderr outside Actions).
@@ -68,31 +70,33 @@ retry() {
local attempt
for attempt in 1 2 3 4 5; do
"$@" && return 0
- [ "$attempt" = 5 ] || { echo "retrying ($attempt/5): $*" >&2; sleep 15; }
+ [[ "$attempt" = 5 ]] || { echo "retrying ($attempt/5): $*" >&2; sleep 15; }
done
return 1
}
warn() {
- if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
+ if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
+ return 0
}
# Copy licence and readme files that exist into a package directory.
copy_docs() {
local dest="$1" f
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE NOTICE COPYRIGHT; do
- if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi
+ if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi
done
copy_font_licences "$dest"
+ return $?
}
# Builds made with craft-fonts (CRAFT_FONTS_DIR, all official releases) embed its fonts, which
# are OFL-1.1: ship each font's licence as OFL-.txt. No-op without CRAFT_FONTS_DIR.
copy_font_licences() {
local dest="$1" lic fam
- [ -n "${CRAFT_FONTS_DIR:-}" ] || return 0
+ [[ -n "${CRAFT_FONTS_DIR:-}" ]] || return 0
for lic in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do
- [ -f "$lic" ] || continue
+ [[ -f "$lic" ]] || continue
fam="$(basename "$(dirname "$lic")")"
cp "$lic" "$dest/OFL-$fam.txt"
done
@@ -100,5 +104,7 @@ copy_font_licences() {
# Portable SHA-256 of a file (prints just the hash).
sha256() {
- if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi
+ local file="$1"
+ if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi
+ return $?
}
diff --git a/packaging/linux/apprun-test.sh b/packaging/linux/apprun-test.sh
index c6d98da0..75625480 100755
--- a/packaging/linux/apprun-test.sh
+++ b/packaging/linux/apprun-test.sh
@@ -37,10 +37,10 @@ fail() {
# 1. Launch works and passes arguments through.
out="$("$APPDIR/AppRun" open file.png)" || fail "AppRun exited non-zero"
-[ "$out" = "stub: open file.png" ] || fail "arguments not forwarded: $out"
+[[ "$out" = "stub: open file.png" ]] || fail "arguments not forwarded: $out"
DESKTOP="$XDG_DATA_HOME/applications/$APP_ID.desktop"
-[ -f "$DESKTOP" ] || fail "desktop entry not installed"
+[[ -f "$DESKTOP" ]] || fail "desktop entry not installed"
# 2. Exec rewritten to the AppImage (quoted, spaces intact), TryExec dropped,
# everything else preserved from the packaged entry.
@@ -55,15 +55,15 @@ fi
# 3. Icons landed under the name the .desktop entry references.
for f in "16x16/apps/$APP_ID.png" "256x256/apps/$APP_ID.png" "scalable/apps/$APP_ID.svg"; do
- [ -f "$XDG_DATA_HOME/icons/hicolor/$f" ] || fail "icon missing: $f"
+ [[ -f "$XDG_DATA_HOME/icons/hicolor/$f" ]] || fail "icon missing: $f"
done
# 4. Idempotent: a second launch must not rewrite anything (inode unchanged).
before="$(stat -c %i "$DESKTOP")"
out="$("$APPDIR/AppRun")" || fail "second launch exited non-zero"
-[ "$out" = "stub: " ] || fail "second launch output: $out"
+[[ "$out" = "stub: " ]] || fail "second launch output: $out"
after="$(stat -c %i "$DESKTOP")"
-[ "$before" = "$after" ] || fail "desktop entry rewritten although unchanged"
+[[ "$before" = "$after" ]] || fail "desktop entry rewritten although unchanged"
# 5. Moving the AppImage refreshes Exec.
mv "$APPIMAGE" "$TMP/moved.AppImage"
@@ -74,40 +74,40 @@ grep -Fxq "Exec=\"$(readlink -f "$APPIMAGE")\" %U" "$DESKTOP" || fail "Exec not
# 6. Opt-out installs nothing.
rm -rf "$XDG_DATA_HOME"
FIGCRAFT_NO_DESKTOP_INTEGRATION=1 "$APPDIR/AppRun" >/dev/null || fail "opt-out launch exited non-zero"
-[ ! -e "$XDG_DATA_HOME" ] || fail "opt-out still integrated"
+[[ ! -e "$XDG_DATA_HOME" ]] || fail "opt-out still integrated"
# 7. An unwritable data dir never blocks the launch.
mkdir -p "$TMP/ro" && chmod a-w "$TMP/ro"
out="$(XDG_DATA_HOME="$TMP/ro/data" "$APPDIR/AppRun" out.txt)" || fail "launch blocked by unwritable data dir"
-[ "$out" = "stub: out.txt" ] || fail "stub not run against unwritable data dir: $out"
+[[ "$out" = "stub: out.txt" ]] || fail "stub not run against unwritable data dir: $out"
chmod u+w "$TMP/ro"
# 8. No $APPIMAGE (extracted AppDir): launch only, nothing installed.
rm -rf "$XDG_DATA_HOME"
env -u APPIMAGE "$APPDIR/AppRun" >/dev/null || fail "extracted-AppDir launch exited non-zero"
-[ ! -e "$XDG_DATA_HOME" ] || fail "integrated without $APPIMAGE"
+[[ ! -e "$XDG_DATA_HOME" ]] || fail "integrated without $APPIMAGE"
# 9. A path that would need Exec escaping (here a `$`): launch only, nothing installed.
rm -rf "$XDG_DATA_HOME"
odd="$TMP/odd\$(id).AppImage"
: >"$odd"
out="$(APPIMAGE="$odd" "$APPDIR/AppRun" x)" || fail "launch with an odd path exited non-zero"
-[ "$out" = "stub: x" ] || fail "odd path: $out"
-[ ! -e "$XDG_DATA_HOME" ] || fail "integrated an AppImage path that needs escaping"
+[[ "$out" = "stub: x" ]] || fail "odd path: $out"
+[[ ! -e "$XDG_DATA_HOME" ]] || fail "integrated an AppImage path that needs escaping"
# 10. `cli ` runs figcraft-cli with the remaining arguments and integrates nothing.
rm -rf "$XDG_DATA_HOME"
out="$("$APPDIR/AppRun" cli mcp --attach 7979 2>&1)" && fail "cli ran without figcraft-cli in the AppDir"
-[ "$out" = "figcraft: this AppImage was built without figcraft-cli" ] || fail "missing cli: $out"
+[[ "$out" = "figcraft: this AppImage was built without figcraft-cli" ]] || fail "missing cli: $out"
printf '#!/bin/sh\necho "cli stub: $*"\n' >"$APPDIR/usr/bin/figcraft-cli"
chmod +x "$APPDIR/usr/bin/figcraft-cli"
out="$("$APPDIR/AppRun" cli mcp --attach 7979)" || fail "cli launch exited non-zero"
-[ "$out" = "cli stub: mcp --attach 7979" ] || fail "cli arguments not forwarded: $out"
+[[ "$out" = "cli stub: mcp --attach 7979" ]] || fail "cli arguments not forwarded: $out"
out="$("$APPDIR/AppRun" cli)" || fail "bare cli launch exited non-zero"
-[ "$out" = "cli stub: " ] || fail "bare cli output: $out"
-[ ! -e "$XDG_DATA_HOME" ] || fail "cli launch integrated the desktop"
+[[ "$out" = "cli stub: " ]] || fail "bare cli output: $out"
+[[ ! -e "$XDG_DATA_HOME" ]] || fail "cli launch integrated the desktop"
# Only the first argument selects the CLI.
out="$("$APPDIR/AppRun" file.figcraft cli)" || fail "app launch with a later cli argument exited non-zero"
-[ "$out" = "stub: file.figcraft cli" ] || fail "later cli argument hijacked the launch: $out"
+[[ "$out" = "stub: file.figcraft cli" ]] || fail "later cli argument hijacked the launch: $out"
echo "AppRun desktop integration ok"
diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh
index 127c3af9..b3353b38 100755
--- a/packaging/linux/flatpak-bundle.sh
+++ b/packaging/linux/flatpak-bundle.sh
@@ -22,7 +22,7 @@ FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
TARBALL=""
-while [ $# -gt 0 ]; do
+while [[ $# -gt 0 ]]; do
case "$1" in
--no-test) TEST=0; shift ;;
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
@@ -39,7 +39,7 @@ case "$ARCH" in
esac
BASENAME="figcraft-$VERSION-linux-$ARCH"
TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
-[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
+[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
for tool in flatpak flatpak-builder; do
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
done
@@ -50,7 +50,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle"
rm -rf "$WORK"
mkdir -p "$WORK/stage"
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
-[ -x "$WORK/stage/bin/figcraft" ] || { echo "error: $TARBALL has no bin/figcraft" >&2; exit 1; }
+[[ -x "$WORK/stage/bin/figcraft" ]] || { echo "error: $TARBALL has no bin/figcraft" >&2; exit 1; }
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
@@ -63,10 +63,10 @@ OUT="$DIST/$BASENAME.flatpak"
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
-if [ "$TEST" = 1 ]; then
+if [[ "$TEST" = 1 ]]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
flatpak info --user "$APP_ID"
- if [ -x "$WORK/stage/bin/figcraft-cli" ]; then smoke=figcraft-cli; else smoke=figcraft; fi
+ if [[ -x "$WORK/stage/bin/figcraft-cli" ]]; then smoke=figcraft-cli; else smoke=figcraft; fi
flatpak run --command="$smoke" "$APP_ID" --version
flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/256x256/apps'
fi
diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh
index d38dc0bc..2480f204 100755
--- a/packaging/linux/package.sh
+++ b/packaging/linux/package.sh
@@ -27,7 +27,7 @@ APP_ID=ai.storyteller.figcraft
SKIP_BUILD=0
FORMATS="appimage deb rpm tar"
-while [ $# -gt 0 ]; do
+while [[ $# -gt 0 ]]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--formats) FORMATS="$2"; shift 2 ;;
@@ -48,7 +48,7 @@ BASENAME="figcraft-$VERSION-linux-$ARCH"
echo "==> FigCraft $VERSION for Linux $ARCH ($FORMATS; ${BINS[*]})"
-if [ "$SKIP_BUILD" = 0 ]; then
+if [[ "$SKIP_BUILD" = 0 ]]; then
(cd "$ROOT" && cargo build --release --locked "${CARGO_PKGS[@]}" ${CROSS_TARGET:+--target "$CROSS_TARGET"})
fi
BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release"
@@ -78,7 +78,7 @@ if command -v appstreamcli >/dev/null; then
appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml"
fi
-has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
+has() { local format="$1"; case " $FORMATS " in *" $format "*) return 0 ;; *) return 1 ;; esac; }
# ---- .tar.gz ------------------------------------------------------------------------------------
if has tar; then
@@ -96,7 +96,7 @@ if has deb || has rpm; then
# nfpm expands env vars in fields like `version` and `arch`, but not in `contents[].src`.
# Without the CLI, drop its `#cli` lines.
cli_filter='/#cli$/d'
- [ "$HAS_CLI" = 1 ] && cli_filter='s/ #cli$//'
+ [[ "$HAS_CLI" = 1 ]] && cli_filter='s/ #cli$//'
sed -e "s|\${STAGE}|$STAGE|g" -e "$cli_filter" "$HERE/nfpm.yaml" >"$WORK/nfpm.yaml"
for fmt in deb rpm; do
if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$WORK/nfpm.yaml" -p "$fmt" -t "$DIST/$BASENAME.$fmt"); fi
@@ -118,9 +118,9 @@ if has appimage; then
ln -s "$APP_ID.png" "$APPDIR/.DirIcon"
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
- if [ -z "$TOOL" ]; then
+ if [[ -z "$TOOL" ]]; then
TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
- if [ ! -x "$TOOL" ]; then
+ if [[ ! -x "$TOOL" ]]; then
curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
chmod +x "$TOOL"
fi
@@ -143,16 +143,16 @@ if has appimage; then
# package) appimagetool also writes the .zsync, into its working directory, hence the cd.
(cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT")
echo "wrote $OUT"
- if [ -s "$OUT.zsync" ]; then
+ if [[ -s "$OUT.zsync" ]]; then
echo "wrote $OUT.zsync"
else
warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it"
fi
fi
-if [ -z "${CROSS_TARGET:-}" ]; then
+if [[ -z "${CROSS_TARGET:-}" ]]; then
"$STAGE/usr/bin/$(smoke_bin)" --version
-elif [ -n "${EMULATOR:-}" ]; then
+elif [[ -n "${EMULATOR:-}" ]]; then
"$EMULATOR" "$STAGE/usr/bin/$(smoke_bin)" --version
fi
echo "==> done"
diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh
index 1150163e..77611537 100755
--- a/packaging/macos/import-cert.sh
+++ b/packaging/macos/import-cert.sh
@@ -12,7 +12,7 @@ umask 077
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
-if [ -z "${APPLE_CERTIFICATE:-}" ]; then
+if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then
warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized"
exit 0
fi
@@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS"
security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"')
IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')"
-if [ -z "$IDENTITY" ]; then
+if [[ -z "$IDENTITY" ]]; then
security find-identity -v -p codesigning "$KEYCHAIN" >&2
echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2
exit 1
fi
echo "Imported signing identity $IDENTITY"
-if [ -n "${GITHUB_ENV:-}" ]; then
+if [[ -n "${GITHUB_ENV:-}" ]]; then
{ echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV"
else
echo "MACOS_SIGN_IDENTITY=$IDENTITY"
diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh
index 87abe94c..7407af7b 100755
--- a/packaging/macos/package.sh
+++ b/packaging/macos/package.sh
@@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos"
ARCH=universal
SKIP_BUILD=0
-while [ $# -gt 0 ]; do
+while [[ $# -gt 0 ]]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
@@ -44,9 +44,9 @@ DMG="$DIST/figcraft-$VERSION-macos-$ARCH.dmg"
CLI_ZIP="$DIST/figcraft-cli-$VERSION-macos-$ARCH.zip"
NOTARIZE=0
-if [ "$IDENTITY" = "-" ]; then
+if [[ "$IDENTITY" = "-" ]]; then
warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized"
-elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
+elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then
NOTARIZE=1
else
warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized"
@@ -55,7 +55,7 @@ fi
echo "==> FigCraft $VERSION for macOS ($ARCH; ${BINS[*]}), identity: $IDENTITY, notarize: $NOTARIZE"
# ---- build -------------------------------------------------------------------------------------
-if [ "$SKIP_BUILD" = 0 ]; then
+if [[ "$SKIP_BUILD" = 0 ]]; then
args=()
for t in "${TARGETS[@]}"; do args+=(--target "$t"); done
(cd "$ROOT" && cargo build --release --locked "${CARGO_PKGS[@]}" "${args[@]}")
@@ -74,9 +74,9 @@ done
sign() {
# Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped.
local ts=(--timestamp)
- [ "$IDENTITY" = "-" ] && ts=(--timestamp=none)
+ [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none)
local kc=()
- [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN")
+ [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN")
codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@"
}
@@ -88,8 +88,8 @@ notarize() {
echo "$out"
id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)"
status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)"
- if [ "$status" != "Accepted" ]; then
- if [ -n "$id" ]; then
+ if [[ "$status" != "Accepted" ]]; then
+ if [[ -n "$id" ]]; then
xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true
fi
echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2
@@ -116,7 +116,7 @@ mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$WORK/bin/figcraft" "$APP/Contents/MacOS/FigCraft"
cp "$WORK/FigCraft.icns" "$APP/Contents/Resources/FigCraft.icns"
# Licences of the embedded craft-fonts fonts (only when built with CRAFT_FONTS_DIR).
-if [ -n "${CRAFT_FONTS_DIR:-}" ]; then
+if [[ -n "${CRAFT_FONTS_DIR:-}" ]]; then
mkdir -p "$APP/Contents/Resources/Licenses"
copy_font_licences "$APP/Contents/Resources/Licenses"
fi
@@ -132,7 +132,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP"
codesign --verify --strict --deep --verbose=2 "$APP"
-if [ "$NOTARIZE" = 1 ]; then
+if [[ "$NOTARIZE" = 1 ]]; then
ditto -c -k --keepParent "$APP" "$WORK/FigCraft-notarize.zip"
notarize "$WORK/FigCraft-notarize.zip"
retry xcrun stapler staple "$APP"
@@ -154,7 +154,7 @@ hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
sign "$DMG"
codesign --verify --strict --verbose=2 "$DMG"
-if [ "$NOTARIZE" = 1 ]; then
+if [[ "$NOTARIZE" = 1 ]]; then
notarize "$DMG"
retry xcrun stapler staple "$DMG"
retry xcrun stapler validate "$DMG"
@@ -162,7 +162,7 @@ if [ "$NOTARIZE" = 1 ]; then
fi
# ---- CLI ---------------------------------------------------------------------------------------
-if [ "$HAS_CLI" = 1 ]; then
+if [[ "$HAS_CLI" = 1 ]]; then
echo "==> building $CLI_ZIP"
CLI_DIR="$WORK/figcraft-cli-$VERSION-macos-$ARCH"
mkdir -p "$CLI_DIR"
@@ -176,7 +176,7 @@ if [ "$HAS_CLI" = 1 ]; then
ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP"
# notarytool accepts a zip of the bare binary. A bare Mach-O can't carry a stapled ticket, so
# Gatekeeper looks the notarization up online when a quarantined copy first runs.
- if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi
+ if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi
"$WORK/bin/figcraft-cli" --version
else
warn "apps/figcraft-cli is not in this workspace: no CLI zip"
diff --git a/packaging/macos/verify.sh b/packaging/macos/verify.sh
index 6080cf87..071629cb 100755
--- a/packaging/macos/verify.sh
+++ b/packaging/macos/verify.sh
@@ -23,7 +23,7 @@ set -euo pipefail
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
ARCH=universal
-while [ $# -gt 0 ]; do
+while [[ $# -gt 0 ]]; do
case "$1" in
--arch) ARCH="$2"; shift 2 ;;
-h | --help) sed -n '2,20p' "$0"; exit 0 ;;
@@ -41,13 +41,13 @@ DMG="$DIST/figcraft-$VERSION-macos-$ARCH.dmg"
CLI_ZIP="$DIST/figcraft-cli-$VERSION-macos-$ARCH.zip"
IDENTITY="${MACOS_SIGN_IDENTITY:--}"
STRICT=0
-if [ "$IDENTITY" != "-" ] && [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
+if [[ "$IDENTITY" != "-" ]] && [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then
STRICT=1
fi
FAILED=0
fail() {
- if [ "$STRICT" = 1 ]; then
+ if [[ "$STRICT" = 1 ]]; then
echo "error: $*" >&2
FAILED=1
else
@@ -58,22 +58,22 @@ fail() {
# spctl's online ticket lookup can lag a few seconds behind notarytool's "Accepted".
assess() {
local out attempt tries=1
- [ "$STRICT" = 1 ] && tries=5
+ [[ "$STRICT" = 1 ]] && tries=5
for attempt in $(seq "$tries"); do
if out="$(spctl "$@" 2>&1)"; then
printf '%s\n' "$out"
return 0
fi
- [ "$attempt" = "$tries" ] || sleep 10
+ [[ "$attempt" = "$tries" ]] || sleep 10
done
printf '%s\n' "$out"
return 1
}
FILES=("$DMG")
-if [ "$HAS_CLI" = 1 ]; then FILES+=("$CLI_ZIP"); fi
+if [[ "$HAS_CLI" = 1 ]]; then FILES+=("$CLI_ZIP"); fi
for f in "${FILES[@]}"; do
- if [ ! -f "$f" ]; then
+ if [[ ! -f "$f" ]]; then
echo "error: missing $f" >&2
exit 1
fi
@@ -83,11 +83,11 @@ WORK="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/figcraft-verify.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
# ---- CLI zip (when apps/figcraft-cli exists) ----------------------------------------------------
-if [ "$HAS_CLI" = 1 ]; then
+if [[ "$HAS_CLI" = 1 ]]; then
echo "==> $(basename "$CLI_ZIP")"
ditto -x -k "$CLI_ZIP" "$WORK/cli"
CLI="$WORK/cli/figcraft-cli-$VERSION-macos-$ARCH/figcraft-cli"
- if [ ! -x "$CLI" ]; then
+ if [[ ! -x "$CLI" ]]; then
echo "error: $(basename "$CLI_ZIP") has no executable figcraft-cli-$VERSION-macos-$ARCH/figcraft-cli" >&2
exit 1
fi
@@ -111,7 +111,7 @@ if [ "$HAS_CLI" = 1 ]; then
|| fail "figcraft-cli is not signed with a Developer ID Application identity"
printf '%s\n' "$details" | grep -q '^Timestamp=' \
|| fail "figcraft-cli signature has no secure timestamp"
- if [ -n "${APPLE_TEAM_ID:-}" ]; then
+ if [[ -n "${APPLE_TEAM_ID:-}" ]]; then
printf '%s\n' "$details" | grep -qx "TeamIdentifier=$APPLE_TEAM_ID" \
|| fail "figcraft-cli is signed by a different team"
fi
@@ -129,25 +129,25 @@ fi
# ---- DMG ---------------------------------------------------------------------------------------
echo "==> $(basename "$DMG")"
codesign --verify --strict --verbose=2 "$DMG"
-if [ "$STRICT" = 1 ]; then
+if [[ "$STRICT" = 1 ]]; then
retry xcrun stapler validate "$DMG" || fail "no notarization ticket stapled to the DMG"
assess --assess --type open --context context:primary-signature -vv "$DMG" \
|| fail "Gatekeeper rejects the DMG"
fi
-if [ "$FAILED" != 0 ]; then
+if [[ "$FAILED" != 0 ]]; then
echo "error: macOS artifacts failed verification" >&2
exit 1
fi
-if [ "$STRICT" = 1 ]; then
+if [[ "$STRICT" = 1 ]]; then
result="signed (Developer ID, hardened runtime), notarized; DMG stapled"
else
result="signature intact; Developer ID and notarization not required (no signing secrets)"
fi
echo "==> verified: $result"
-if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
+if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
names="\`$(basename "$DMG")\`"
- if [ "$HAS_CLI" = 1 ]; then names="$names, \`$(basename "$CLI_ZIP")\`"; fi
+ if [[ "$HAS_CLI" = 1 ]]; then names="$names, \`$(basename "$CLI_ZIP")\`"; fi
echo "- macOS $names: $result" >>"$GITHUB_STEP_SUMMARY"
fi
diff --git a/packaging/server/install-test.sh b/packaging/server/install-test.sh
index 303d08de..2ebe5176 100755
--- a/packaging/server/install-test.sh
+++ b/packaging/server/install-test.sh
@@ -10,7 +10,7 @@ WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
FAILED=0
-main() { bash "$HERE/install.sh" --caddy-main "$1" ops@example.com; }
+main() { local caddyfile="$1"; bash "$HERE/install.sh" --caddy-main "$caddyfile" ops@example.com; }
fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=1; }
# Missing: ours.
@@ -28,7 +28,7 @@ printf 'shop.example.com {\n\treverse_proxy 127.0.0.1:8080\n}\n' >"$WORK/theirs"
cp "$WORK/theirs" "$WORK/run1"
main "$WORK/run1" >"$WORK/run2"
grep -q 'shop.example.com' "$WORK/run2" || fail "the operator's site is kept"
-[ "$(grep -c 'import figcraft-server.caddy' "$WORK/run2")" = 1 ] || fail "one import line is added"
+[[ "$(grep -c 'import figcraft-server.caddy' "$WORK/run2")" = 1 ]] || fail "one import line is added"
main "$WORK/run2" >"$WORK/run3"
cmp -s "$WORK/run2" "$WORK/run3" || fail "running again leaves a Caddyfile with other sites as it is"
main "$WORK/run3" >"$WORK/run4"
@@ -39,5 +39,5 @@ main "$WORK/none" >"$WORK/ours1"
main "$WORK/ours1" >"$WORK/ours2"
cmp -s "$WORK/ours1" "$WORK/ours2" || fail "our own Caddyfile stays the same"
-if [ "$FAILED" = 0 ]; then echo "install.sh Caddyfile handling: ok"; fi
+if [[ "$FAILED" = 0 ]]; then echo "install.sh Caddyfile handling: ok"; fi
exit "$FAILED"
diff --git a/packaging/server/install.sh b/packaging/server/install.sh
index 826c2bb4..94acd578 100755
--- a/packaging/server/install.sh
+++ b/packaging/server/install.sh
@@ -41,7 +41,7 @@ SERVICE=figcraft-server
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The source tree this script sits in (packaging/server/ of a copy or checkout), if any.
LOCAL_SRC=""
-if [ -f "$HERE/../../Cargo.toml" ] && [ -d "$HERE/../../apps/figcraft-server" ]; then
+if [[ -f "$HERE/../../Cargo.toml" ]] && [[ -d "$HERE/../../apps/figcraft-server" ]]; then
LOCAL_SRC="$(cd "$HERE/../.." && pwd)"
fi
@@ -64,9 +64,9 @@ MARK="# figcraft-server (install.sh)"
caddy_main() { # caddy_main FILE EMAIL: the main Caddyfile to install, on stdout
local file="$1" email="$2"
- if [ -f "$file" ] && grep -qE '^[[:space:]]*import[[:space:]]+figcraft-server\.caddy[[:space:]]*$' "$file"; then
+ if [[ -f "$file" ]] && grep -qE '^[[:space:]]*import[[:space:]]+figcraft-server\.caddy[[:space:]]*$' "$file"; then
cat "$file" # it imports our site already: kept exactly as it is
- elif [ ! -f "$file" ] || ! grep -qvE '^[[:space:]]*(#|$|:80|\{|\}|root|file_server)' "$file"; then
+ elif [[ ! -f "$file" ]] || ! grep -qvE '^[[:space:]]*(#|$|:80|\{|\}|root|file_server)' "$file"; then
# Missing, or only the example site the package ships: ours, with the certificate email.
printf '%s\n{\n\temail %s\n}\n\nimport figcraft-server.caddy\n' "$MARK" "$email"
else
@@ -76,7 +76,7 @@ caddy_main() { # caddy_main FILE EMAIL: the main Caddyfile to install, on stdout
fi
}
-while [ $# -gt 0 ]; do
+while [[ $# -gt 0 ]]; do
case "$1" in
# For tests (packaging/server/install-test.sh): print the Caddyfile this script would write.
--caddy-main) caddy_main "${2:-}" "${3:-}"; exit 0 ;;
@@ -95,7 +95,7 @@ while [ $# -gt 0 ]; do
esac
done
-[ "$(id -u)" -eq 0 ] || die "run it as root (sudo ./install.sh ...)"
+[[ "$(id -u)" -eq 0 ]] || die "run it as root (sudo ./install.sh ...)"
# shellcheck source=/dev/null
. /etc/os-release
case " ${ID:-} ${ID_LIKE:-} " in
@@ -104,9 +104,9 @@ case " ${ID:-} ${ID_LIKE:-} " in
esac
ask() { # ask VAR "question" FLAG: read an answer from the terminal when the value wasn't given
- local var="$1" question="$2" answer
- if [ -z "${!var}" ]; then
- [ -t 0 ] || die "give $3 (no terminal to ask on)"
+ local var="$1" question="$2" flag="$3" answer
+ if [[ -z "${!var}" ]]; then
+ [[ -t 0 ]] || die "give $flag (no terminal to ask on)"
read -r -p "$question " answer
printf -v "$var" '%s' "$answer"
fi
@@ -115,6 +115,7 @@ ask HOST "DNS name people will use (e.g. figcraft.example.com):" "--host"
ask EMAIL "Email for Let's Encrypt and the abuse contact shown to users:" "--email"
case "$HOST" in
"" | *[!A-Za-z0-9.-]* | .* | *. ) die "--host must be a DNS name like figcraft.example.com" ;;
+ *) ;;
esac
case "$EMAIL" in
*@*.*) ;;
@@ -126,6 +127,7 @@ case "$TERMS" in
esac
case "$EMAIL$TERMS" in
*[[:space:]\"\\]*) die "--email and --terms can't contain spaces, quotes or backslashes" ;;
+ *) ;;
esac
export DEBIAN_FRONTEND=noninteractive
@@ -138,6 +140,7 @@ ARCH="$(uname -m)"
case "$ARCH" in
amd64) ARCH=x86_64 ;;
arm64) ARCH=aarch64 ;;
+ *) ;; # already a release name (x86_64, aarch64)
esac
# --- The binary -----------------------------------------------------------------------------
@@ -146,9 +149,9 @@ FILES="$HERE" # where the units and the Caddyfile come from
from_release() { # download and check the release archive; sets NEW_BIN and FILES
local version="$VERSION" name dir
- if [ -z "$version" ]; then
+ if [[ -z "$version" ]]; then
version="$(curl --proto '=https' -fsSL "https://api.github.com/repos/$REPO/releases/latest" | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p' | head -n 1)" || return 1
- [ -n "$version" ] || return 1
+ [[ -n "$version" ]] || return 1
fi
name="figcraft-server-$version-linux-$ARCH"
dir="$WORK/release"
@@ -165,7 +168,7 @@ from_release() { # download and check the release archive; sets NEW_BIN and FILE
SWAP=""
cleanup() {
- if [ -n "$SWAP" ]; then
+ if [[ -n "$SWAP" ]]; then
swapoff "$SWAP" 2>/dev/null || true
rm -f "$SWAP"
fi
@@ -174,7 +177,7 @@ trap cleanup EXIT
as_builder() { # run a command as the unprivileged build user, with its own toolchain
local jobs=()
- if [ -n "${CARGO_BUILD_JOBS:-}" ]; then jobs=("CARGO_BUILD_JOBS=$CARGO_BUILD_JOBS"); fi
+ if [[ -n "${CARGO_BUILD_JOBS:-}" ]]; then jobs=("CARGO_BUILD_JOBS=$CARGO_BUILD_JOBS"); fi
runuser -u "$BUILDER" -- env HOME="$BUILD" RUSTUP_HOME="$BUILD/rustup" CARGO_HOME="$BUILD/cargo" \
CARGO_TARGET_DIR="$BUILD/target" "${jobs[@]}" "$@"
}
@@ -184,27 +187,27 @@ from_source() { # build the source tree around this script, else a fresh clone;
apt-get install -y -qq build-essential git pkg-config >/dev/null
id -u "$BUILDER" >/dev/null 2>&1 || useradd --system --home-dir "$BUILD" --shell /usr/sbin/nologin "$BUILDER"
install -d -o "$BUILDER" -g "$BUILDER" -m 755 "$BUILD"
- if [ ! -x "$BUILD/cargo/bin/cargo" ]; then
+ if [[ ! -x "$BUILD/cargo/bin/cargo" ]]; then
as_builder sh -c "curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable --no-modify-path" >/dev/null
else
as_builder "$BUILD/cargo/bin/rustup" update stable >/dev/null
fi
# The source root installs files from is root's own (the copy beside this script, or root's
# clone); the build user only ever builds a copy of it.
- if [ -n "$LOCAL_SRC" ]; then
+ if [[ -n "$LOCAL_SRC" ]]; then
src="$LOCAL_SRC"
say "Building the source in $LOCAL_SRC: this takes a while"
else
src="$WORK/src"
- if [ -d "$src/.git" ]; then
+ if [[ -d "$src/.git" ]]; then
git -C "$src" fetch --quiet --tags origin
else
git clone --quiet "https://github.com/$REPO" "$src" ||
die "can't clone $REPO (a private repository: copy the source to this machine and run its packaging/server/install.sh)"
fi
- if [ -z "$ref" ]; then
- if [ -n "$VERSION" ]; then ref="v$VERSION"; else ref="$(git -C "$src" tag --list 'v*' --sort=-v:refname | head -n 1)"; fi
- [ -n "$ref" ] || ref=main
+ if [[ -z "$ref" ]]; then
+ if [[ -n "$VERSION" ]]; then ref="v$VERSION"; else ref="$(git -C "$src" tag --list 'v*' --sort=-v:refname | head -n 1)"; fi
+ [[ -n "$ref" ]] || ref=main
fi
say "Building $ref from a clone: this takes a while"
# A branch as it is upstream now, or a tag.
@@ -216,7 +219,7 @@ from_source() { # build the source tree around this script, else a fresh clone;
tar -C "$src" --exclude=./target --exclude=./plugins/target --exclude=./.git -cf - . | as_builder tar -C "$copy" -xf -
# The compiler needs about 2 GB: add a temporary swap file on smaller machines.
mem_kb="$(awk '/^MemTotal:/ {print $2}' /proc/meminfo)"
- if [ "$mem_kb" -lt 2000000 ] && [ -z "$(swapon --show --noheadings)" ]; then
+ if [[ "$mem_kb" -lt 2000000 ]] && [[ -z "$(swapon --show --noheadings)" ]]; then
SWAP="$WORK/swapfile"
say "Adding a temporary 2 GB swap file for the build"
fallocate -l 2G "$SWAP" 2>/dev/null || dd if=/dev/zero of="$SWAP" bs=1M count=2048 status=none
@@ -237,19 +240,19 @@ from_source() { # build the source tree around this script, else a fresh clone;
FILES="$src/packaging/server"
}
-if [ -x "$HERE/figcraft-server" ] && [ "$FROM_SOURCE" = false ]; then
+if [[ -x "$HERE/figcraft-server" ]] && [[ "$FROM_SOURCE" = false ]]; then
NEW_BIN="$HERE/figcraft-server" # run from an unpacked release archive
-elif [ -n "$LOCAL_SRC" ] && [ -z "$VERSION" ]; then
+elif [[ -n "$LOCAL_SRC" ]] && [[ -z "$VERSION" ]]; then
from_source # the source copied to (or checked out on) this machine, as it is
-elif [ "$FROM_SOURCE" = true ] || ! from_release; then
- [ "$FROM_SOURCE" = true ] || say "No release archive for linux-$ARCH: building from source instead"
+elif [[ "$FROM_SOURCE" = true ]] || ! from_release; then
+ [[ "$FROM_SOURCE" = true ]] || say "No release archive for linux-$ARCH: building from source instead"
from_source
fi
-[ -x "$NEW_BIN" ] || die "no figcraft-server binary to install"
+[[ -x "$NEW_BIN" ]] || die "no figcraft-server binary to install"
# The units and the Caddyfile beside this script when it has them (a checkout or an archive).
-[ -f "$HERE/figcraft-backup.timer" ] && FILES="$HERE"
+[[ -f "$HERE/figcraft-backup.timer" ]] && FILES="$HERE"
for f in figcraft-server.service Caddyfile figcraft-backup.service figcraft-backup.timer; do
- [ -f "$FILES/$f" ] || die "$FILES/$f is missing"
+ [[ -f "$FILES/$f" ]] || die "$FILES/$f is missing"
done
# The server's own account runs the binary, from here on too (root never runs what was built).
@@ -267,7 +270,7 @@ fi
# --- Data and settings ----------------------------------------------------------------------
install -d -o figcraft -g figcraft -m 700 "$DATA"
install -d -m 755 "$CONFIG_DIR"
-if [ ! -f "$CONFIG" ]; then
+if [[ ! -f "$CONFIG" ]]; then
say "Writing $CONFIG"
{
printf '# figcraft-server settings (docs/server.md, "Configuration"). install.sh never overwrites this file.\n'
@@ -303,8 +306,9 @@ as_server "$BIN" --config "$CONFIG" check >/dev/null ||
die "$CONFIG isn't right for this figcraft-server (see above; a release older than open hosting needs --from-source)"
install_file() { # install_file SOURCE TARGET MODE: true when it changed
- if cmp -s "$1" "$2"; then return 1; fi
- install -m "$3" "$1" "$2"
+ local source="$1" target="$2" mode="$3"
+ if cmp -s "$source" "$target"; then return 1; fi
+ install -m "$mode" "$source" "$target"
}
# --- systemd --------------------------------------------------------------------------------
@@ -312,16 +316,16 @@ UNITS=false
install_file "$FILES/figcraft-server.service" /etc/systemd/system/figcraft-server.service 644 && UNITS=true
install_file "$FILES/figcraft-backup.service" /etc/systemd/system/figcraft-backup.service 644 && UNITS=true
install_file "$FILES/figcraft-backup.timer" /etc/systemd/system/figcraft-backup.timer 644 && UNITS=true
-if [ "$UNITS" = true ]; then
+if [[ "$UNITS" = true ]]; then
systemctl daemon-reload
CHANGED=true
fi
systemctl enable --quiet "$SERVICE"
-if [ "$CHANGED" = true ] || ! systemctl is-active --quiet "$SERVICE"; then
+if [[ "$CHANGED" = true ]] || ! systemctl is-active --quiet "$SERVICE"; then
say "Starting figcraft-server"
systemctl restart "$SERVICE"
fi
-if [ "$BACKUPS" = true ]; then
+if [[ "$BACKUPS" = true ]]; then
systemctl enable --now --quiet figcraft-backup.timer
fi
@@ -339,15 +343,15 @@ MAIN="$(mktemp)"
caddy_main "$CADDYFILE" "$EMAIL" >"$MAIN"
CADDY_CHANGED=false
for f in "$CADDYFILE" "$SITE_FILE"; do
- if [ -f "$f" ]; then cp -p "$f" "$f.before-figcraft"; else rm -f "$f.before-figcraft"; fi
+ if [[ -f "$f" ]]; then cp -p "$f" "$f.before-figcraft"; else rm -f "$f.before-figcraft"; fi
done
install_file "$SITE" "$SITE_FILE" 644 && CADDY_CHANGED=true
install_file "$MAIN" "$CADDYFILE" 644 && CADDY_CHANGED=true
rm -f "$SITE" "$MAIN"
-if [ "$CADDY_CHANGED" = true ]; then
+if [[ "$CADDY_CHANGED" = true ]]; then
if ! caddy validate --config "$CADDYFILE" --adapter caddyfile >/dev/null 2>&1; then
for f in "$CADDYFILE" "$SITE_FILE"; do
- if [ -f "$f.before-figcraft" ]; then cp -p "$f.before-figcraft" "$f"; else rm -f "$f"; fi
+ if [[ -f "$f.before-figcraft" ]]; then cp -p "$f.before-figcraft" "$f"; else rm -f "$f"; fi
done
die "Caddy doesn't accept the new configuration (the old one is back; see: caddy validate --config $CADDYFILE)"
fi
@@ -356,10 +360,10 @@ fi
systemctl enable --now --quiet caddy
# --- Firewall -------------------------------------------------------------------------------
-if [ "$FIREWALL" = true ]; then
+if [[ "$FIREWALL" = true ]]; then
# The ports sshd really listens on (sudo doesn't pass SSH_CONNECTION on).
SSH_PORTS="$(sshd -T 2>/dev/null | awk '/^port / {print $2}' | sort -u)"
- [ -n "$SSH_PORTS" ] || SSH_PORTS=22
+ [[ -n "$SSH_PORTS" ]] || SSH_PORTS=22
say "Firewall: SSH ($(echo "$SSH_PORTS" | tr '\n' ' ' | sed 's/ $//')), 80 and 443"
for p in $SSH_PORTS; do ufw allow "$p/tcp" >/dev/null; done
ufw allow 80/tcp >/dev/null
@@ -370,12 +374,12 @@ if [ "$FIREWALL" = true ]; then
# Other services reachable from outside would be cut off by enabling ufw.
OTHERS=""
for a in $(ss -Hltn | awk '{print $4}'); do
- case "$a" in 127.* | "[::1]:"* | ::1:*) continue ;; esac
+ case "$a" in 127.* | "[::1]:"* | ::1:*) continue ;; *) ;; esac
port="${a##*:}"
case " $(echo "$SSH_PORTS" | tr '\n' ' ') 80 443 " in *" $port "*) continue ;; esac
OTHERS="$OTHERS $port"
done
- if [ -n "$OTHERS" ] && [ "$FIREWALL_ANYWAY" = false ]; then
+ if [[ -n "$OTHERS" ]] && [[ "$FIREWALL_ANYWAY" = false ]]; then
warn "other services listen on port(s)$OTHERS: ufw was not enabled (rules added; allow those ports, then 'ufw enable', or run again with --firewall-anyway)"
else
ufw --force enable >/dev/null
@@ -395,7 +399,7 @@ cat < Publish to server..., address wss://$HOST/
- $(if [ "$OPEN" = true ]; then echo "(open to the public: no key needed; each file gets an owner token)"; else echo "(with the key: $ADMIN key)"; fi)
+ $(if [[ "$OPEN" = true ]]; then echo "(open to the public: no key needed; each file gets an owner token)"; else echo "(with the key: $ADMIN key)"; fi)
Check: curl https://$HOST/healthz (once DNS points here and Caddy has its certificate)
Logs: journalctl -u $SERVICE -f
diff --git a/packaging/server/package.sh b/packaging/server/package.sh
index 7d2f9c54..5f306327 100755
--- a/packaging/server/package.sh
+++ b/packaging/server/package.sh
@@ -25,11 +25,12 @@ ARCH="$(uname -m)"
case "$ARCH" in
arm64) ARCH=aarch64 ;;
amd64) ARCH=x86_64 ;;
+ *) ;; # already a release name (x86_64, aarch64)
esac
NAME="figcraft-server-$VERSION-$OS-$ARCH"
echo "==> figcraft-server $VERSION for $OS $ARCH"
-if [ "$SKIP_BUILD" = 0 ]; then
+if [[ "$SKIP_BUILD" = 0 ]]; then
(cd "$ROOT" && cargo build --release --locked -p figcraft-server)
fi
WORK="$CARGO_TARGET_DIR/server-package"
diff --git a/packaging/web/package.sh b/packaging/web/package.sh
index 0e45c515..f9ee34a7 100755
--- a/packaging/web/package.sh
+++ b/packaging/web/package.sh
@@ -11,13 +11,13 @@ set -euo pipefail
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/web"
-if [ "${1:-}" != "--skip-build" ]; then
+if [[ "${1:-}" != "--skip-build" ]]; then
command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; }
(cd "$ROOT/apps/figcraft-web" && trunk build --release)
fi
SITE="$ROOT/dist/web"
-[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
+[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; }
# Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml).
if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then
echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2
@@ -30,16 +30,16 @@ fi
MAX_WASM_BYTES="${FIGCRAFT_WASM_MAX_BYTES:-25165824}" # 24 MiB
WASM_COUNT=0
for wasm in "$SITE"/*.wasm; do
- [ -f "$wasm" ] || continue
+ [[ -f "$wasm" ]] || continue
WASM_COUNT=$((WASM_COUNT + 1))
size=$(wc -c <"$wasm" | tr -d ' ')
echo "$(basename "$wasm"): $size bytes ($((size / 1048576)) MiB; limit $MAX_WASM_BYTES)"
- if [ "$size" -gt "$MAX_WASM_BYTES" ]; then
+ if [[ "$size" -gt "$MAX_WASM_BYTES" ]]; then
echo "error: $(basename "$wasm") is $size bytes, over the $MAX_WASM_BYTES-byte limit (Cloudflare's per-file cap is 25 MiB)" >&2
exit 1
fi
done
-[ "$WASM_COUNT" -gt 0 ] || { echo "error: no .wasm in $SITE" >&2; exit 1; }
+[[ "$WASM_COUNT" -gt 0 ]] || { echo "error: no .wasm in $SITE" >&2; exit 1; }
NAME="figcraft-web-$VERSION"
WORK="$CARGO_TARGET_DIR/web-package"
From e66780a39beeb75c0e5721fa9cc3576b0949909f Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor"
Date: Fri, 23 Oct 2026 12:56:34 -0400
Subject: [PATCH 095/123] packaging: use [[ for bash conditional tests
(SonarCloud S7688) (#1225)
SonarCloud flags every bare [ conditional in bash scripts under packaging/ as a reliability smell (rule S7688). Convert the flagged [ ... ] tests to [[ ... ]]: safer word-splitting and no glob surprises, same semantics for these string and file tests.
---
packaging/freebsd/package.sh | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh
index bb9a7799..e11a58a0 100755
--- a/packaging/freebsd/package.sh
+++ b/packaging/freebsd/package.sh
@@ -21,7 +21,7 @@ APP_ID=ai.storyteller.figcraft
SKIP_BUILD=0
DRY_RUN=0
-while [ $# -gt 0 ]; do
+while [[ $# -gt 0 ]]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
--dry-run) DRY_RUN=1; SKIP_BUILD=1; shift ;;
@@ -37,7 +37,7 @@ case "$MACHINE" in
arm64 | aarch64) ARCH=aarch64 ;;
*) echo "unsupported architecture $MACHINE" >&2; exit 2 ;;
esac
-if [ "$DRY_RUN" = 0 ] && [ "$(uname -s)" != FreeBSD ]; then
+if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then
echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2
exit 2
fi
@@ -47,7 +47,7 @@ echo "==> FigCraft $VERSION for FreeBSD $ARCH"
# The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory.
export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}"
-if [ "$SKIP_BUILD" = 0 ]; then
+if [[ "$SKIP_BUILD" = 0 ]]; then
(cd "$ROOT" && cargo build --release --locked "${CARGO_PKGS[@]}")
fi
WORK="$CARGO_TARGET_DIR/freebsd-package"
@@ -57,7 +57,7 @@ mkdir -p "$WORK"
BIN="$CARGO_TARGET_DIR/release"
OUT_DIR="$DIST"
-if [ "$DRY_RUN" = 1 ]; then
+if [[ "$DRY_RUN" = 1 ]]; then
# Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds.
BIN="$WORK/stub-bin"
OUT_DIR="$WORK"
@@ -86,12 +86,12 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$FIGCRAFT_BUILD_DATE/g" \
"$LINUX/$APP_ID.metainfo.xml.in" >"$STAGE/share/metainfo/$APP_ID.metainfo.xml"
cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/share/icons/"
copy_docs "$STAGE/share/doc/figcraft"
-if [ -f "$ROOT/ATTRIBUTION.md" ]; then install -m 644 "$ROOT/ATTRIBUTION.md" "$STAGE/share/doc/figcraft/"; fi
+if [[ -f "$ROOT/ATTRIBUTION.md" ]]; then install -m 644 "$ROOT/ATTRIBUTION.md" "$STAGE/share/doc/figcraft/"; fi
for f in "${BINS[@]/#/bin/}" "share/applications/$APP_ID.desktop" \
"share/icons/hicolor/256x256/apps/$APP_ID.png" \
share/doc/figcraft/LICENSE-MIT share/doc/figcraft/LICENSE-APACHE; do
- if [ ! -e "$STAGE/$f" ]; then echo "error: $f is missing from the package" >&2; exit 1; fi
+ if [[ ! -e "$STAGE/$f" ]]; then echo "error: $f is missing from the package" >&2; exit 1; fi
done
# ---- .tar.gz ------------------------------------------------------------------------------------
@@ -100,7 +100,7 @@ tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME"
echo "wrote $OUT_DIR/$BASENAME.tar.gz"
"$STAGE/bin/$(smoke_bin)" --version
-if [ "$DRY_RUN" = 1 ]; then
+if [[ "$DRY_RUN" = 1 ]]; then
echo "==> dry run: tarball contents"
tar -tzvf "$OUT_DIR/$BASENAME.tar.gz"
fi
From fc7a74451936c62d6aa219ab20a0065df203ac1a Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Fri, 23 Oct 2026 13:01:12 -0400
Subject: [PATCH 096/123] CI: one test binary per crate, nextest, line tables
only (#1208)
* Dev profile: line tables for workspace crates, no debug info for dependencies
* Tests: one integration-test binary per crate with several test files
* xtask ci: nextest, doctests, tests without examples, --keep-going; add xtask test
---
Cargo.toml | 10 +++++---
apps/figcraft-cli/tests/{ => it}/cli.rs | 1 -
.../tests/{ => it}/export_depth.rs | 1 -
apps/figcraft-cli/tests/{ => it}/fonts.rs | 1 -
apps/figcraft-cli/tests/it/main.rs | 12 +++++++++
apps/figcraft-cli/tests/{ => it}/pdf.rs | 1 -
apps/figcraft-cli/tests/{ => it}/pdf_print.rs | 1 -
.../tests/{ => it}/header_recovery.rs | 1 -
apps/figcraft-server/tests/it/main.rs | 5 ++++
.../tests/{ => it}/problem_retention.rs | 0
.../tests/{ => it}/empty_text_box_undo.rs | 2 --
.../{ => it}/library_style_acceptance.rs | 1 -
.../tests/{ => it}/list_indent_response.rs | 1 -
crates/engine/tests/it/main.rs | 14 +++++++++++
crates/engine/tests/{ => it}/scope_reset.rs | 2 --
.../engine/tests/{ => it}/selection_colors.rs | 2 --
.../tests/{ => it}/shape_builder_response.rs | 1 -
.../tests/{ => it}/distributed_alignment.rs | 0
.../tests/{ => it}/import_link_contract.rs | 0
crates/format/tests/it/main.rs | 5 ++++
.../{ => it}/range_hyperlink_round_trip.rs | 0
crates/mcp/tests/{ => it}/agent_tasks.rs | 3 +--
crates/mcp/tests/{ => it}/attached.rs | 1 -
crates/mcp/tests/{ => it}/automation_roots.rs | 1 -
crates/mcp/tests/{ => it}/common/mod.rs | 1 -
.../mcp/tests/{ => it}/export_hidden_roots.rs | 3 +--
crates/mcp/tests/{ => it}/garbage.rs | 1 -
crates/mcp/tests/it/main.rs | 18 +++++++++++++
crates/mcp/tests/{ => it}/protocol.rs | 3 +--
.../tests/{ => it}/screenshot_pixel_limit.rs | 3 +--
.../tests/{ => it}/canvas_pan_release.rs | 1 -
.../tests/{ => it}/comment_open_other_page.rs | 0
.../ui-egui/tests/{ => it}/file_menu_pdf.rs | 1 -
.../tests/{ => it}/guide_label_narrow.rs | 2 --
crates/ui-egui/tests/it/main.rs | 11 ++++++++
.../tests/{ => it}/recent_file_names.rs | 1 -
docs/mcp.md | 2 +-
xtask/src/main.rs | 25 ++++++++++++++++---
38 files changed, 98 insertions(+), 40 deletions(-)
rename apps/figcraft-cli/tests/{ => it}/cli.rs (99%)
rename apps/figcraft-cli/tests/{ => it}/export_depth.rs (97%)
rename apps/figcraft-cli/tests/{ => it}/fonts.rs (98%)
create mode 100644 apps/figcraft-cli/tests/it/main.rs
rename apps/figcraft-cli/tests/{ => it}/pdf.rs (97%)
rename apps/figcraft-cli/tests/{ => it}/pdf_print.rs (97%)
rename apps/figcraft-server/tests/{ => it}/header_recovery.rs (98%)
create mode 100644 apps/figcraft-server/tests/it/main.rs
rename apps/figcraft-server/tests/{ => it}/problem_retention.rs (100%)
rename crates/engine/tests/{ => it}/empty_text_box_undo.rs (96%)
rename crates/engine/tests/{ => it}/library_style_acceptance.rs (97%)
rename crates/engine/tests/{ => it}/list_indent_response.rs (97%)
create mode 100644 crates/engine/tests/it/main.rs
rename crates/engine/tests/{ => it}/scope_reset.rs (95%)
rename crates/engine/tests/{ => it}/selection_colors.rs (99%)
rename crates/engine/tests/{ => it}/shape_builder_response.rs (95%)
rename crates/format/tests/{ => it}/distributed_alignment.rs (100%)
rename crates/format/tests/{ => it}/import_link_contract.rs (100%)
create mode 100644 crates/format/tests/it/main.rs
rename crates/format/tests/{ => it}/range_hyperlink_round_trip.rs (100%)
rename crates/mcp/tests/{ => it}/agent_tasks.rs (99%)
rename crates/mcp/tests/{ => it}/attached.rs (99%)
rename crates/mcp/tests/{ => it}/automation_roots.rs (98%)
rename crates/mcp/tests/{ => it}/common/mod.rs (97%)
rename crates/mcp/tests/{ => it}/export_hidden_roots.rs (96%)
rename crates/mcp/tests/{ => it}/garbage.rs (98%)
create mode 100644 crates/mcp/tests/it/main.rs
rename crates/mcp/tests/{ => it}/protocol.rs (99%)
rename crates/mcp/tests/{ => it}/screenshot_pixel_limit.rs (96%)
rename crates/ui-egui/tests/{ => it}/canvas_pan_release.rs (98%)
rename crates/ui-egui/tests/{ => it}/comment_open_other_page.rs (100%)
rename crates/ui-egui/tests/{ => it}/file_menu_pdf.rs (97%)
rename crates/ui-egui/tests/{ => it}/guide_label_narrow.rs (97%)
create mode 100644 crates/ui-egui/tests/it/main.rs
rename crates/ui-egui/tests/{ => it}/recent_file_names.rs (97%)
diff --git a/Cargo.toml b/Cargo.toml
index 95c956d5..90297f29 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -103,11 +103,15 @@ todo = "deny"
unimplemented = "deny"
unreachable = "deny"
-[profile.dev.package."*"]
-opt-level = 2
-
+# Workspace crates keep file:line in panic backtraces; dependencies carry no debug info. Both make
+# the test build faster and the target dir smaller.
[profile.dev]
opt-level = 1
+debug = "line-tables-only"
+
+[profile.dev.package."*"]
+opt-level = 2
+debug = false
[profile.release]
lto = "thin"
diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/it/cli.rs
similarity index 99%
rename from apps/figcraft-cli/tests/cli.rs
rename to apps/figcraft-cli/tests/it/cli.rs
index eb42b8d8..4af73d69 100644
--- a/apps/figcraft-cli/tests/cli.rs
+++ b/apps/figcraft-cli/tests/it/cli.rs
@@ -1,5 +1,4 @@
//! The binary end to end.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::process::{Command, Stdio};
diff --git a/apps/figcraft-cli/tests/export_depth.rs b/apps/figcraft-cli/tests/it/export_depth.rs
similarity index 97%
rename from apps/figcraft-cli/tests/export_depth.rs
rename to apps/figcraft-cli/tests/it/export_depth.rs
index d8b3ef36..37350c1e 100644
--- a/apps/figcraft-cli/tests/export_depth.rs
+++ b/apps/figcraft-cli/tests/it/export_depth.rs
@@ -1,5 +1,4 @@
//! Depth refusal through the actual CLI, including its exit status and destination writes.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_doc::color::{Color, Paint};
use figcraft_doc::{Document, NodeKind};
diff --git a/apps/figcraft-cli/tests/fonts.rs b/apps/figcraft-cli/tests/it/fonts.rs
similarity index 98%
rename from apps/figcraft-cli/tests/fonts.rs
rename to apps/figcraft-cli/tests/it/fonts.rs
index ef5c638f..a48056bd 100644
--- a/apps/figcraft-cli/tests/fonts.rs
+++ b/apps/figcraft-cli/tests/it/fonts.rs
@@ -1,6 +1,5 @@
//! Headless runs lay text out with the installed fonts, like the desktop app, unless
//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
diff --git a/apps/figcraft-cli/tests/it/main.rs b/apps/figcraft-cli/tests/it/main.rs
new file mode 100644
index 00000000..ae4b0488
--- /dev/null
+++ b/apps/figcraft-cli/tests/it/main.rs
@@ -0,0 +1,12 @@
+//! The crate's integration tests, built as one test binary.
+
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod cli;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod export_depth;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod fonts;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod pdf;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod pdf_print;
diff --git a/apps/figcraft-cli/tests/pdf.rs b/apps/figcraft-cli/tests/it/pdf.rs
similarity index 97%
rename from apps/figcraft-cli/tests/pdf.rs
rename to apps/figcraft-cli/tests/it/pdf.rs
index 28d5ca6c..185d5b60 100644
--- a/apps/figcraft-cli/tests/pdf.rs
+++ b/apps/figcraft-cli/tests/it/pdf.rs
@@ -1,5 +1,4 @@
//! `figcraft-cli export --format pdf` and `convert … .pdf` end to end.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
diff --git a/apps/figcraft-cli/tests/pdf_print.rs b/apps/figcraft-cli/tests/it/pdf_print.rs
similarity index 97%
rename from apps/figcraft-cli/tests/pdf_print.rs
rename to apps/figcraft-cli/tests/it/pdf_print.rs
index b2ce3c9a..f12725ed 100644
--- a/apps/figcraft-cli/tests/pdf_print.rs
+++ b/apps/figcraft-cli/tests/it/pdf_print.rs
@@ -1,5 +1,4 @@
//! `figcraft-cli export` with PDF print options: bleed, marks, CMYK; bad lengths are usage errors.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
diff --git a/apps/figcraft-server/tests/header_recovery.rs b/apps/figcraft-server/tests/it/header_recovery.rs
similarity index 98%
rename from apps/figcraft-server/tests/header_recovery.rs
rename to apps/figcraft-server/tests/it/header_recovery.rs
index d728908d..bb0be1d0 100644
--- a/apps/figcraft-server/tests/header_recovery.rs
+++ b/apps/figcraft-server/tests/it/header_recovery.rs
@@ -2,7 +2,6 @@
//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
//! still restores silently.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_collab::message::{Access, ClientMsg};
use figcraft_collab::{Client, Outgoing, Replica, Server};
diff --git a/apps/figcraft-server/tests/it/main.rs b/apps/figcraft-server/tests/it/main.rs
new file mode 100644
index 00000000..3027ed99
--- /dev/null
+++ b/apps/figcraft-server/tests/it/main.rs
@@ -0,0 +1,5 @@
+//! The crate's integration tests, built as one test binary.
+
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod header_recovery;
+mod problem_retention;
diff --git a/apps/figcraft-server/tests/problem_retention.rs b/apps/figcraft-server/tests/it/problem_retention.rs
similarity index 100%
rename from apps/figcraft-server/tests/problem_retention.rs
rename to apps/figcraft-server/tests/it/problem_retention.rs
diff --git a/crates/engine/tests/empty_text_box_undo.rs b/crates/engine/tests/it/empty_text_box_undo.rs
similarity index 96%
rename from crates/engine/tests/empty_text_box_undo.rs
rename to crates/engine/tests/it/empty_text_box_undo.rs
index ec73b83a..0a302ac2 100644
--- a/crates/engine/tests/empty_text_box_undo.rs
+++ b/crates/engine/tests/it/empty_text_box_undo.rs
@@ -1,5 +1,3 @@
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-
use figcraft_engine::{
Session,
doc::NodeKind,
diff --git a/crates/engine/tests/library_style_acceptance.rs b/crates/engine/tests/it/library_style_acceptance.rs
similarity index 97%
rename from crates/engine/tests/library_style_acceptance.rs
rename to crates/engine/tests/it/library_style_acceptance.rs
index 233ddc8b..07940132 100644
--- a/crates/engine/tests/library_style_acceptance.rs
+++ b/crates/engine/tests/it/library_style_acceptance.rs
@@ -1,5 +1,4 @@
//! Accepting a library update that changes only a paint style refreshes the layers linked to it.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
use figcraft_doc::{NodeId, StyleKind};
use figcraft_engine::Session;
diff --git a/crates/engine/tests/list_indent_response.rs b/crates/engine/tests/it/list_indent_response.rs
similarity index 97%
rename from crates/engine/tests/list_indent_response.rs
rename to crates/engine/tests/it/list_indent_response.rs
index 1dae8754..e6d47956 100644
--- a/crates/engine/tests/list_indent_response.rs
+++ b/crates/engine/tests/it/list_indent_response.rs
@@ -1,5 +1,4 @@
//! `text.listIndent` / `text.listOutdent` report `changed` only when the document changed.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::{
Session,
diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs
new file mode 100644
index 00000000..a97e3434
--- /dev/null
+++ b/crates/engine/tests/it/main.rs
@@ -0,0 +1,14 @@
+//! The crate's integration tests, built as one test binary.
+
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod empty_text_box_undo;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
+mod library_style_acceptance;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod list_indent_response;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod scope_reset;
+#[allow(clippy::unwrap_used)]
+mod selection_colors;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod shape_builder_response;
diff --git a/crates/engine/tests/scope_reset.rs b/crates/engine/tests/it/scope_reset.rs
similarity index 95%
rename from crates/engine/tests/scope_reset.rs
rename to crates/engine/tests/it/scope_reset.rs
index 6d875d12..1838e8a9 100644
--- a/crates/engine/tests/scope_reset.rs
+++ b/crates/engine/tests/it/scope_reset.rs
@@ -1,5 +1,3 @@
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-
use figcraft_engine::Session;
use serde_json::json;
diff --git a/crates/engine/tests/selection_colors.rs b/crates/engine/tests/it/selection_colors.rs
similarity index 99%
rename from crates/engine/tests/selection_colors.rs
rename to crates/engine/tests/it/selection_colors.rs
index e13f9f59..4e559d27 100644
--- a/crates/engine/tests/selection_colors.rs
+++ b/crates/engine/tests/it/selection_colors.rs
@@ -1,5 +1,3 @@
-#![allow(clippy::unwrap_used)]
-
use figcraft_color::Paint;
use figcraft_doc::NodeId;
use figcraft_engine::{
diff --git a/crates/engine/tests/shape_builder_response.rs b/crates/engine/tests/it/shape_builder_response.rs
similarity index 95%
rename from crates/engine/tests/shape_builder_response.rs
rename to crates/engine/tests/it/shape_builder_response.rs
index e1426823..6187e556 100644
--- a/crates/engine/tests/shape_builder_response.rs
+++ b/crates/engine/tests/it/shape_builder_response.rs
@@ -1,4 +1,3 @@
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_engine::Session;
use serde_json::{Value, json};
diff --git a/crates/format/tests/distributed_alignment.rs b/crates/format/tests/it/distributed_alignment.rs
similarity index 100%
rename from crates/format/tests/distributed_alignment.rs
rename to crates/format/tests/it/distributed_alignment.rs
diff --git a/crates/format/tests/import_link_contract.rs b/crates/format/tests/it/import_link_contract.rs
similarity index 100%
rename from crates/format/tests/import_link_contract.rs
rename to crates/format/tests/it/import_link_contract.rs
diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs
new file mode 100644
index 00000000..6286e8db
--- /dev/null
+++ b/crates/format/tests/it/main.rs
@@ -0,0 +1,5 @@
+//! The crate's integration tests, built as one test binary.
+
+mod distributed_alignment;
+mod import_link_contract;
+mod range_hyperlink_round_trip;
diff --git a/crates/format/tests/range_hyperlink_round_trip.rs b/crates/format/tests/it/range_hyperlink_round_trip.rs
similarity index 100%
rename from crates/format/tests/range_hyperlink_round_trip.rs
rename to crates/format/tests/it/range_hyperlink_round_trip.rs
diff --git a/crates/mcp/tests/agent_tasks.rs b/crates/mcp/tests/it/agent_tasks.rs
similarity index 99%
rename from crates/mcp/tests/agent_tasks.rs
rename to crates/mcp/tests/it/agent_tasks.rs
index b35f3e7f..b83fc3b8 100644
--- a/crates/mcp/tests/agent_tasks.rs
+++ b/crates/mcp/tests/it/agent_tasks.rs
@@ -1,8 +1,7 @@
//! End-to-end agent tasks: an in-process MCP client completes realistic design tasks only through
//! MCP tool calls, and checks every result through inspect_document / get_node like an agent would.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-mod common;
+use crate::common;
use common::{Client, approx, png_dims};
use serde_json::{Value, json};
diff --git a/crates/mcp/tests/attached.rs b/crates/mcp/tests/it/attached.rs
similarity index 99%
rename from crates/mcp/tests/attached.rs
rename to crates/mcp/tests/it/attached.rs
index 69194604..f1343dd2 100644
--- a/crates/mcp/tests/attached.rs
+++ b/crates/mcp/tests/it/attached.rs
@@ -1,6 +1,5 @@
//! The attached backend against a fake app: a loopback control server answering with a headless
//! session, speaking the desktop app's JSON-lines protocol.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/it/automation_roots.rs
similarity index 98%
rename from crates/mcp/tests/automation_roots.rs
rename to crates/mcp/tests/it/automation_roots.rs
index ced1e145..aa8e1b24 100644
--- a/crates/mcp/tests/automation_roots.rs
+++ b/crates/mcp/tests/it/automation_roots.rs
@@ -1,6 +1,5 @@
//! `--automation-read-root` / `--automation-write-root` over MCP (#715): a tool call reads only
//! inside the read and write roots and writes only inside the write roots.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
use std::path::PathBuf;
diff --git a/crates/mcp/tests/common/mod.rs b/crates/mcp/tests/it/common/mod.rs
similarity index 97%
rename from crates/mcp/tests/common/mod.rs
rename to crates/mcp/tests/it/common/mod.rs
index be4dcea2..51e74865 100644
--- a/crates/mcp/tests/common/mod.rs
+++ b/crates/mcp/tests/it/common/mod.rs
@@ -1,5 +1,4 @@
//! An in-process MCP client: feeds JSON-RPC lines to `Server::handle_line`.
-#![allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_mcp::Server;
use serde_json::{Value, json};
diff --git a/crates/mcp/tests/export_hidden_roots.rs b/crates/mcp/tests/it/export_hidden_roots.rs
similarity index 96%
rename from crates/mcp/tests/export_hidden_roots.rs
rename to crates/mcp/tests/it/export_hidden_roots.rs
index ded6f731..0a3b8de4 100644
--- a/crates/mcp/tests/export_hidden_roots.rs
+++ b/crates/mcp/tests/it/export_hidden_roots.rs
@@ -1,7 +1,6 @@
//! Hidden top-level layers in an export take no room in the image (issue #447).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-mod common;
+use crate::common;
use common::{Client, png_dims};
use serde_json::{Value, json};
diff --git a/crates/mcp/tests/garbage.rs b/crates/mcp/tests/it/garbage.rs
similarity index 98%
rename from crates/mcp/tests/garbage.rs
rename to crates/mcp/tests/it/garbage.rs
index 262934c7..5d9e237e 100644
--- a/crates/mcp/tests/garbage.rs
+++ b/crates/mcp/tests/it/garbage.rs
@@ -1,5 +1,4 @@
//! Fuzz-ish: no input line may crash the server or leave it unable to answer.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_mcp::{Server, TOOL_NAMES};
use proptest::prelude::*;
diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs
new file mode 100644
index 00000000..b5e3d89d
--- /dev/null
+++ b/crates/mcp/tests/it/main.rs
@@ -0,0 +1,18 @@
+//! The crate's integration tests, built as one test binary.
+
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod agent_tasks;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod attached;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
+mod automation_roots;
+#[allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod common;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod export_hidden_roots;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod garbage;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod protocol;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod screenshot_pixel_limit;
diff --git a/crates/mcp/tests/protocol.rs b/crates/mcp/tests/it/protocol.rs
similarity index 99%
rename from crates/mcp/tests/protocol.rs
rename to crates/mcp/tests/it/protocol.rs
index 6e943a1e..617790f4 100644
--- a/crates/mcp/tests/protocol.rs
+++ b/crates/mcp/tests/it/protocol.rs
@@ -1,7 +1,6 @@
//! JSON-RPC / MCP protocol behaviour.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-mod common;
+use crate::common;
use common::Client;
use figcraft_mcp::{PROTOCOL_VERSION, Server, TOOL_NAMES};
diff --git a/crates/mcp/tests/screenshot_pixel_limit.rs b/crates/mcp/tests/it/screenshot_pixel_limit.rs
similarity index 96%
rename from crates/mcp/tests/screenshot_pixel_limit.rs
rename to crates/mcp/tests/it/screenshot_pixel_limit.rs
index 095992ff..e575f1d5 100644
--- a/crates/mcp/tests/screenshot_pixel_limit.rs
+++ b/crates/mcp/tests/it/screenshot_pixel_limit.rs
@@ -1,6 +1,5 @@
//! `screenshot` with `ids` must honour `maxSize` for the rendered paint area (shadows, overflow).
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
-mod common;
+use crate::common;
use common::{Client, png_dims};
use serde_json::{Value, json};
diff --git a/crates/ui-egui/tests/canvas_pan_release.rs b/crates/ui-egui/tests/it/canvas_pan_release.rs
similarity index 98%
rename from crates/ui-egui/tests/canvas_pan_release.rs
rename to crates/ui-egui/tests/it/canvas_pan_release.rs
index c54d20dc..cd1a6e1c 100644
--- a/crates/ui-egui/tests/canvas_pan_release.rs
+++ b/crates/ui-egui/tests/it/canvas_pan_release.rs
@@ -1,6 +1,5 @@
//! A canvas pan (middle button or Space + primary) ends when its button is released, even when the
//! release happens over a panel: returning to the canvas with no button held must not move the view.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, vec2};
use figcraft_engine::Session;
diff --git a/crates/ui-egui/tests/comment_open_other_page.rs b/crates/ui-egui/tests/it/comment_open_other_page.rs
similarity index 100%
rename from crates/ui-egui/tests/comment_open_other_page.rs
rename to crates/ui-egui/tests/it/comment_open_other_page.rs
diff --git a/crates/ui-egui/tests/file_menu_pdf.rs b/crates/ui-egui/tests/it/file_menu_pdf.rs
similarity index 97%
rename from crates/ui-egui/tests/file_menu_pdf.rs
rename to crates/ui-egui/tests/it/file_menu_pdf.rs
index 30b847b7..812529e0 100644
--- a/crates/ui-egui/tests/file_menu_pdf.rs
+++ b/crates/ui-egui/tests/it/file_menu_pdf.rs
@@ -1,6 +1,5 @@
//! File > Export frames to PDF: the menu asks where to save and writes the frames' PDF there,
//! one visible top-level frame per page.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
diff --git a/crates/ui-egui/tests/guide_label_narrow.rs b/crates/ui-egui/tests/it/guide_label_narrow.rs
similarity index 97%
rename from crates/ui-egui/tests/guide_label_narrow.rs
rename to crates/ui-egui/tests/it/guide_label_narrow.rs
index 6c4276ad..464fbfed 100644
--- a/crates/ui-egui/tests/guide_label_narrow.rs
+++ b/crates/ui-egui/tests/it/guide_label_narrow.rs
@@ -1,5 +1,3 @@
-#![allow(clippy::unwrap_used)]
-
use egui::{Pos2, RawInput, Rect, Vec2, vec2};
use figcraft_engine::Session;
use figcraft_ui_egui::{FigApp, NoServices};
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
new file mode 100644
index 00000000..007f450d
--- /dev/null
+++ b/crates/ui-egui/tests/it/main.rs
@@ -0,0 +1,11 @@
+//! The crate's integration tests, built as one test binary.
+
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod canvas_pan_release;
+mod comment_open_other_page;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod file_menu_pdf;
+#[allow(clippy::unwrap_used)]
+mod guide_label_narrow;
+#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+mod recent_file_names;
diff --git a/crates/ui-egui/tests/recent_file_names.rs b/crates/ui-egui/tests/it/recent_file_names.rs
similarity index 97%
rename from crates/ui-egui/tests/recent_file_names.rs
rename to crates/ui-egui/tests/it/recent_file_names.rs
index 461d0c73..41e37ec2 100644
--- a/crates/ui-egui/tests/recent_file_names.rs
+++ b/crates/ui-egui/tests/it/recent_file_names.rs
@@ -1,5 +1,4 @@
//! Recent file rows keep their full accessible name.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use egui::{Context, Id, Pos2, RawInput, Rect, accesskit::Role, vec2};
use figcraft_engine::Session;
diff --git a/docs/mcp.md b/docs/mcp.md
index dfea95cc..211b98d8 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -1675,7 +1675,7 @@ other key next to `$step` fails the whole batch, which is rolled back. Only an o
value too) is read as a reference. A step that is itself a `command.batch` is passed on as written,
so the references in its steps are to its own steps, not the outer batch's.
-The end-to-end tests in `crates/mcp/tests/agent_tasks.rs` are worked examples of realistic tasks
+The end-to-end tests in `crates/mcp/tests/it/agent_tasks.rs` are worked examples of realistic tasks
(auto layout, a card built in one batch with step references, components and overrides, gradients
and shadows, paint styles, Light/Dark variables, align/distribute/group, boolean subtract + flatten,
export, undo/redo, drawing with tools).
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index e3f00e8b..81d48471 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -1,6 +1,7 @@
//! Project tasks: `cargo xtask `.
//!
//! - `ci` fmt check, clippy -D warnings, tests, layers, assets, wasm check
+//! - `test` the workspace tests (nextest when installed) and doctests
//! - `layers` crate dependency layering check
//! - `assets` every file under assets/ and docs/ has a row in ATTRIBUTION.md
//! - `parity` menu-catalog coverage → docs/parity-checklist.md
@@ -16,7 +17,7 @@
mod icon;
mod layers;
-use std::process::{Command, ExitCode};
+use std::process::{Command, ExitCode, Stdio};
fn run(cmd: &mut Command) -> bool {
eprintln!("$ {cmd:?}");
@@ -36,6 +37,7 @@ fn main() -> ExitCode {
let task = args.first().map(String::as_str).unwrap_or("help");
let ok = match task {
"ci" => ci(),
+ "test" => test(),
"layers" => layers::check(&root()),
"assets" => assets(),
"parity" => parity(),
@@ -45,7 +47,7 @@ fn main() -> ExitCode {
"plugins" => plugins(),
"bench" => bench(args.get(1).map_or("1000,10000,50000", String::as_str)),
_ => {
- eprintln!("tasks: ci, layers, assets, parity, version [set X.Y.Z], icon, wasm, plugins, bench [SIZES]");
+ eprintln!("tasks: ci, test, layers, assets, parity, version [set X.Y.Z], icon, wasm, plugins, bench [SIZES]");
task == "help"
}
};
@@ -54,16 +56,31 @@ fn main() -> ExitCode {
fn ci() -> bool {
run(cargo().args(["fmt", "--all", "--check"]))
- && run(cargo().args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))
- && run(cargo().args(["test", "--workspace"]))
+ && run(cargo().args(["clippy", "--workspace", "--all-targets", "--keep-going", "--", "-D", "warnings"]))
+ && test()
&& layers::check(&root())
&& assets()
&& wasm()
}
+/// Unit and integration tests with nextest when it is installed (`cargo install cargo-nextest`),
+/// else with `cargo test`; then the doctests, which nextest does not run. Examples are not built:
+/// clippy's `--all-targets` already checks them.
+fn test() -> bool {
+ let nextest = cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success());
+ let units = if nextest {
+ run(cargo().args(["nextest", "run", "--workspace", "--lib", "--bins", "--tests"]))
+ } else {
+ eprintln!("xtask: cargo-nextest not installed; running unit and integration tests with `cargo test`");
+ run(cargo().args(["test", "--workspace", "--lib", "--bins", "--tests"]))
+ };
+ units && run(cargo().args(["test", "--workspace", "--doc"]))
+}
+
fn wasm() -> bool {
run(cargo().args([
"check",
+ "--keep-going",
"--target",
"wasm32-unknown-unknown",
"-p",
From c48102350b1ef00614b105ac07451ff0ca8a5141 Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Fri, 23 Oct 2026 13:02:11 -0400
Subject: [PATCH 097/123] Packaging: pin the signing client and appimagetool,
and check their hashes (#950)
* Packaging: pin the Windows signing client and check its hash
* Packaging: pin appimagetool and check its hash
* Reject AppImage download for architectures without a pinned appimagetool
* Pin the AppImage runtime
---
packaging/linux/package.sh | 46 +++++++++++++++++++++++++++++++++-----
packaging/windows/sign.ps1 | 13 ++++++++---
2 files changed, 51 insertions(+), 8 deletions(-)
diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh
index 2480f204..a3ea29b1 100755
--- a/packaging/linux/package.sh
+++ b/packaging/linux/package.sh
@@ -105,6 +105,23 @@ fi
# ---- AppImage -----------------------------------------------------------------------------------
if has appimage; then
+ APPIMAGETOOL_VERSION=1.9.1
+ case "$ARCH" in
+ x86_64) APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 ;;
+ aarch64) APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 ;;
+ *) APPIMAGETOOL_SHA256='' ;;
+ esac
+ # appimagetool otherwise fetches the unpinned type2-runtime `continuous` build and embeds it.
+ APPIMAGE_RUNTIME_VERSION=20251108
+ case "$ARCH" in
+ x86_64) APPIMAGE_RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d ;;
+ aarch64) APPIMAGE_RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 ;;
+ *) APPIMAGE_RUNTIME_SHA256='' ;;
+ esac
+ if [[ -z "$APPIMAGE_RUNTIME_SHA256" ]]; then
+ echo "no pinned AppImage runtime for $ARCH; build the AppImage on x86_64 or aarch64" >&2
+ exit 2
+ fi
APPDIR="$WORK/FigCraft.AppDir"
cp -R "$STAGE" "$APPDIR"
mv "$APPDIR/usr/share/doc" "$WORK/doc-unused"
@@ -119,17 +136,36 @@ if has appimage; then
TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}"
if [[ -z "$TOOL" ]]; then
- TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage"
- if [[ ! -x "$TOOL" ]]; then
- curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage"
- chmod +x "$TOOL"
+ if [[ -z "$APPIMAGETOOL_SHA256" ]]; then
+ echo "no pinned appimagetool for $ARCH; set APPIMAGETOOL or put appimagetool on PATH" >&2
+ exit 2
fi
+ TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage"
+ if [[ ! -x "$TOOL" ]]; then
+ download="$TOOL.download"
+ curl --proto '=https' -fsSL -o "$download" \
+ "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage"
+ printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$download" | sha256sum -c -
+ chmod 755 "$download"
+ mv "$download" "$TOOL"
+ fi
+ printf '%s %s\n' "$APPIMAGETOOL_SHA256" "$TOOL" | sha256sum -c -
fi
+ RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH"
+ if [[ ! -f "$RUNTIME" ]]; then
+ download="$RUNTIME.download"
+ curl --proto '=https' -fsSL -o "$download" \
+ "https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH"
+ printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$download" | sha256sum -c -
+ mv "$download" "$RUNTIME"
+ fi
+ printf '%s %s\n' "$APPIMAGE_RUNTIME_SHA256" "$RUNTIME" | sha256sum -c -
# Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be
# relative, e.g. target/agent-).
OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage"
APPDIR="$(cd "$APPDIR" && pwd)"
TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")"
+ RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")"
# A .zsync left by an earlier run would hide a missing zsyncmake and describe another file.
rm -f "$OUT.zsync"
# Update information: AppImageUpdate, AppImageLauncher and the like read it from the
@@ -141,7 +177,7 @@ if has appimage; then
# Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime,
# so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync
# package) appimagetool also writes the .zsync, into its working directory, hence the cd.
- (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT")
+ (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream --runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT")
echo "wrote $OUT"
if [[ -s "$OUT.zsync" ]]; then
echo "wrote $OUT.zsync"
diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1
index 3d94747a..795ef067 100644
--- a/packaging/windows/sign.ps1
+++ b/packaging/windows/sign.ps1
@@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) {
$script:SignTool = Find-SignTool
$common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'FigCraft', '/du', 'https://github.com/storytold/figcraft')
-$tmp = Join-Path ([IO.Path]::GetTempPath()) "figcraft-sign-$PID"
-New-Item -ItemType Directory -Force -Path $tmp | Out-Null
+$tmp = Join-Path ([IO.Path]::GetTempPath()) "figcraft-sign-$([IO.Path]::GetRandomFileName())"
+New-Item -ItemType Directory -Path $tmp | Out-Null
try {
if ($haveCert) {
@@ -73,8 +73,15 @@ try {
Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')"
# The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID,
# AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment.
+ $trustedSigningVersion = '1.0.95'
+ $trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f'
$pkg = Join-Path $tmp 'trusted-signing.zip'
- Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg
+ $uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg"
+ Invoke-WebRequest -Uri $uri -OutFile $pkg
+ $actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant()
+ if ($actualHash -ne $trustedSigningSha256) {
+ throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch"
+ }
Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force
$dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll |
Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1
From 6ba02236367320fef0b373b461f6e9796bfc6de0 Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Fri, 23 Oct 2026 13:50:42 -0400
Subject: [PATCH 098/123] Tests: the paragraph indent test loads system fonts
and skips only without glyphs (#1227)
---
crates/engine/src/text_behaviour_tests.rs | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs
index 84315f1f..b9a76fbc 100644
--- a/crates/engine/src/text_behaviour_tests.rs
+++ b/crates/engine/src/text_behaviour_tests.rs
@@ -177,20 +177,19 @@ fn vertical_trim_through_node_set() {
#[test]
fn paragraph_indent_only_moves_left_aligned_text() {
+ figcraft_text::scan_system_fonts();
let mut s = Session::new();
let t = text(&mut s, "Indented paragraph", json!({"fontSize": 20, "paragraphIndent": 30}));
let first_x = |s: &Session| {
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
- (l.glyphs.first().map_or(0.0, |g| g.x), l.width)
+ (l.glyphs.first().map(|g| g.x), l.width)
};
- let (x, w) = first_x(&s);
- if w <= 0.0 {
- return;
- }
+ // Without any font there are no glyphs, but the indent alone still gives the box a width.
+ let (Some(x), w) = first_x(&s) else { return };
assert!((x - 30.0).abs() < 1e-6, "left: indented");
s.execute("node.set", &json!({"id": t, "props": {"textAlign": "center"}})).unwrap();
let (x, w2) = first_x(&s);
- assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it");
+ assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.is_some_and(|x| x.abs() < 1e-6), "centered auto-width text: no indent, the box shrinks by it");
}
/// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding
From 87e0c8a7da1e06ec76f0eb83ae3600e72f09db81 Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Fri, 23 Oct 2026 14:31:08 -0400
Subject: [PATCH 099/123] CI: shared gate workflow; FreeBSD nightly and on
release (#1206)
* CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release
ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD runs nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.
* CI: build the shared gate without craft-fonts, as before
* CI: build the shared gate with release.yml's craft-fonts pin
---
.github/workflows/ci.yml | 89 +++++++----------------------------
.github/workflows/freebsd.yml | 10 ++--
2 files changed, 21 insertions(+), 78 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3dfa31d9..1968076f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,9 +1,7 @@
-# CI for pull requests and main: format, clippy, tests, dependency layering, asset attribution and
-# the wasm32 check (the same gates as `cargo xtask ci`). Linux only: macOS is built and tested only
-# by release.yml (the org's macOS runners are kept for releases; craftrules release/playbook.md),
-# and Windows/FreeBSD packaging have their own workflows.
name: CI
+# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
+# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
@@ -13,76 +11,21 @@ on:
permissions:
contents: read
-env:
- CARGO_TERM_COLOR: always
- CARGO_INCREMENTAL: 0
- RUST_BACKTRACE: 1
- # Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
- # debug info: faster codegen and links, and a smaller target dir.
- CARGO_PROFILE_DEV_DEBUG: line-tables-only
-
-# A newer push to a PR branch cancels its older run. Runs on main are never cancelled, so every
-# main state that gets a run is checked to the end.
+# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
- group: ci-${{ github.ref }}
+ group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
- test:
- timeout-minutes: 35
- name: test (ubuntu-latest)
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
-
- - name: Install Linux system dependencies (egui/winit/rfd)
- run: |
- sudo apt-get update
- sudo apt-get install -y --no-install-recommends \
- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
- libgl1-mesa-dev libgtk-3-dev
-
- - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- with:
- components: clippy, rustfmt
-
- # Only main saves caches; PRs restore main's, so PR caches don't evict main's.
- - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- with:
- save-if: ${{ github.ref == 'refs/heads/main' }}
- cache-on-failure: true
-
- - name: Format
- run: cargo fmt --all -- --check
-
- - name: Clippy
- run: cargo clippy --workspace --all-targets --locked -- -D warnings
-
- # Without craft-fonts (the optional CRAFT_FONTS_DIR input, which .cargo/config.toml points at
- # a sibling checkout that CI doesn't have): everything must build and pass without it.
- - name: Test
- run: cargo test --workspace --locked
-
- - name: Dependency layering
- run: cargo run --locked -q -p xtask -- layers
-
- - name: Asset attribution
- run: cargo run --locked -q -p xtask -- assets
-
- wasm:
- timeout-minutes: 30
- name: wasm32 check
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
- with:
- targets: wasm32-unknown-unknown
- - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
- with:
- key: wasm32
- save-if: ${{ github.ref == 'refs/heads/main' }}
- cache-on-failure: true
- # The engine, the UI crate and apps/figcraft-web must keep compiling for the browser.
- - name: Check wasm-safe crates
- run: cargo run --locked -q -p xtask -- wasm
+ gate:
+ # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
+ uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
+ with:
+ # The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter):
+ # crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts'
+ # pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin
+ # once one commit has both (storytold/craft-fonts#42).
+ craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
+ packages: >-
+ libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
+ libgtk-3-dev
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index ff15bfbc..3b03a99c 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -4,12 +4,10 @@
# before it can fail a release. Nothing is uploaded and no secrets are used.
name: FreeBSD
-# The VM build takes one to several hours, so it runs nightly on main rather than on every push
-# or dependency change: a drifted dependency still shows up within a day. PRs that change the
-# FreeBSD packaging itself still run it.
+# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
+# request: a drifted dependency still shows up within a day, and always before a release ships.
+# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
- schedule:
- - cron: "17 2 * * *"
push:
branches: [release]
pull_request:
@@ -17,6 +15,8 @@ on:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
+ schedule:
+ - cron: "17 2 * * *"
workflow_dispatch:
concurrency:
From d6e8a6e8745434b0f4e495b5216ad19b4a820722 Mon Sep 17 00:00:00 2001
From: BFlat
Date: Sat, 24 Oct 2026 02:02:19 +0700
Subject: [PATCH 100/123] Docs: roadmap entries for the community PRs in #507,
#510, #529 and #530 (#587)
Roadmap, milestone and parity rows for the 93 community PRs landed in batches #507, #510, #529 and #530.
---
ROADMAP.md | 65 ++++++++++++++++++++++++++++++++++---
docs/file-format-parity.md | 7 ++--
docs/gaps.md | 7 ++--
docs/hardware-parity.md | 7 ++--
docs/localization-parity.md | 9 +++--
docs/roadmap.md | 58 ++++++++++++++++++++++++++++++---
docs/target-app-parity.md | 3 +-
docs/ui-parity.md | 11 ++++---
8 files changed, 141 insertions(+), 26 deletions(-)
diff --git a/ROADMAP.md b/ROADMAP.md
index f43cd015..b3edc4ee 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -50,7 +50,7 @@ against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h);
-native speakers' reviews of the six language drafts need people.
+native speakers' reviews of the nine language drafts need people.
## By dimension
@@ -63,7 +63,7 @@ native speakers' reviews of the six language drafts need people.
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
-| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
+| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
@@ -126,11 +126,19 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
-4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h).
+4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
## Progress log
+- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged.
+- **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged.
+- **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged.
+- **2026-10-15** · Removing someone from a live session (M9, #338): the host or the file's owner removes a person from the avatar's menu or with `collab.removePerson`; they keep their copy and can't rejoin while the session runs. Numbers unchanged.
+- **2026-10-15** · Languages (M10, #257, #263, #469, #477, #288): Russian (one/few/many plurals) and Turkish catalogs reviewed by native speakers, Czech and Indonesian drafts, the Japanese draft completed; Preferences ▸ Language lists 12 languages and Latin American Spanish. Numbers unchanged.
+- **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged.
+- **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged.
+- **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged.
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
@@ -316,6 +324,55 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
+| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
+| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
+| 2026-10-15 | minor | Removing someone from a live session (#338): right-click a person's avatar ▸ Remove *name* from session, or `collab.removePerson {client}`, when you host the session or own the file on a server; the session ends for them (they keep their copy), the code or link they hold no longer gets them back in while it runs, and everyone else stays. Numbers unchanged |
+| 2026-10-15 | minor | Pages list (#272, #258): a long pages list scrolls in its own area, so the layers below stay in view; page and layer drops count only inside the list's visible rows. Numbers unchanged |
+| 2026-10-15 | minor | Variables export in the browser (#368): Export… ▸ Variables JSON / CSS custom properties did nothing in the web build and now arrives as a download; importing there still needs a file picker. Numbers unchanged |
+| 2026-10-15 | minor | Font picker keys (#352, #345): Up and Down step through the font families from the current one, keeping the highlight in view and previewing it on the canvas; Enter applies it, Escape leaves the text as it was. Numbers unchanged |
+| 2026-10-15 | minor | Leaving Design ends the pen path (#308, #307): switching to Prototype or Inspect while the Pen draws ends the path and goes back to Move (pressing Design again keeps drawing), and a click on the toolbar no longer reaches the canvas under it. Numbers unchanged |
+| 2026-10-15 | minor | Vector edit mode keeps the canvas still (#322): the edit mode hints in the Design panel wrap, so entering edit mode and picking a region no longer shift the canvas by about 25 points in the middle of the click (which dragged the region picked). Numbers unchanged |
+| 2026-10-15 | minor | Drop shadows under a background blur (#317, #255): a layer with both casts its shadows from what it paints (a translucent fill casts a lighter shadow than an opaque one) and reads the backdrop only after drawing them, so a shadow shown behind transparent areas is seen through the blur. Numbers unchanged |
+| 2026-10-15 | minor | When the window can't open (#306, #261): Windows release builds have no console, so a graphics failure at start quit without a word; the error is now logged and shown in a message box in the interface language, with what to try (unset `WGPU_BACKEND`, update the driver, OpenGL) and where to report it, skipped with `--control`. Numbers unchanged |
+| 2026-10-15 | minor | Crashes on Windows (#271, #314, #337; #224 and two more): a text field focused before it was drawn aborted the screen-reader layer; double-clicking a page, creating a variable collection and renaming a style folder no longer close the app. Numbers unchanged |
+| 2026-10-15 | minor | Deeply nested layers (#330, #210): one depth guard (256) on the three recursive layout paths, so a deep file can't run the layout out of stack. Numbers unchanged |
+| 2026-10-15 | minor | Image memory budgets (#326; #203, #189): decoded images and a file's images have budgets; the decode cache evicts its least recently used entries. Numbers unchanged |
+| 2026-10-15 | minor | macOS file dialogs (#253, #250) no longer re-enter the event loop; a dialog's result goes to the file and window it was opened for, and dialogs are parented to the main window. Numbers unchanged |
+| 2026-10-15 | minor | Recovery copies (#297, #303, #331, #336; #202, #185, #187): a copy counts only after its write succeeds and a failed one is retried; saves and closes wait for the write in flight; copies keep version history and branches; a recovered copy stays until this run has its own. Numbers unchanged |
+| 2026-10-15 | minor | Text edits (#280, #278) no longer roll back earlier changes; a drag released off the canvas ends at the pointer's last canvas position. Numbers unchanged |
+| 2026-10-15 | minor | Rollback (#282, #301; #196): a failed command rolls back during a drag or other gesture too; a failed batch keeps the redo steps. Numbers unchanged |
+| 2026-10-15 | minor | Atomic save and export (#284): staging files are uniquely named and created exclusively, so a file or link already at the old fixed staging name is never written through, and two writers of one file no longer share one. Numbers unchanged |
+| 2026-10-15 | minor | Alt-drag copies (#283) snap to their original, and Duplicate (Cmd+D) then repeats the last copy's offset. Numbers unchanged |
+| 2026-10-15 | minor | The pen (#277) shows its Bezier handles while a point is dragged out. Numbers unchanged |
+| 2026-10-15 | minor | Corner handles (#293, #292): a press on a corner handle resizes instead of picking the corner-radius handle. Numbers unchanged |
+| 2026-10-15 | minor | New text (#372, #362) takes a readable colour from the frame it is drawn in, not only the page (text drawn in a white frame on a dark page was white). Numbers unchanged |
+| 2026-10-15 | minor | Outline stroke (#386) works on every selected layer. Numbers unchanged |
+| 2026-10-15 | minor | Send to back (#444) counts per parent when the selection spans frames. Numbers unchanged |
+| 2026-10-15 | minor | Align buttons (#276) are off when the selection has nothing to align to. Numbers unchanged |
+| 2026-10-15 | minor | Gradient type icons (#275) show their gradient. Numbers unchanged |
+| 2026-10-15 | minor | Corner resize cursors (#388) stay diagonal at any proportions; the text selection highlight (#390) stays inside the box on the last line. Numbers unchanged |
+| 2026-10-15 | minor | New pages (#311, #295) take the current page's background; the theme still decides a new file's page. Numbers unchanged |
+| 2026-10-15 | minor | A slice's export preview (#265) shows the content under it. Numbers unchanged |
+| 2026-10-15 | minor | SVG export (#327, #325) keeps a nested frame's fill and stroke inside its parent's clip. Numbers unchanged |
+| 2026-10-15 | minor | Home screen (#318, #324): recent files notice files deleted while it is open; a long path no longer draws over the file name. Numbers unchanged |
+| 2026-10-15 | minor | The Small caps sample in Type settings (#355) draws from capitals, no longer a box where the UI font lacks the small-capital letter. Numbers unchanged |
+| 2026-10-15 | minor | Web clipboard (#375): copy and paste in the browser go through the system clipboard, so a paste after a copy brings the copied layers instead of whatever text the clipboard held. Numbers unchanged |
+| 2026-10-15 | minor | Zoom previews (#312): when renders from scratch are slow, a new zoom first shows the whole view coarsely instead of the page colour, then the refined image; images of another file or page never show. Numbers unchanged |
+| 2026-10-15 | minor | Hidden windows (#313, #332; #269): a minimised or covered window answers control and MCP requests (commands, inspection, `ui.render`) at once, each window on its own; screenshots and input still wait for it to be on screen. Numbers unchanged |
+| 2026-10-15 | minor | Server (#340, #302): a compaction cut short closes the file instead of confirming lost edits (#183); each team library version is kept in its own file (#206). Numbers unchanged |
+| 2026-10-15 | minor | Counters (#343, #344, #353; #309): collab sequence numbers, layer name counters and opacity digits can't overflow. Numbers unchanged |
+| 2026-10-15 | minor | Commands refuse bad indexes and ids (#475, #384, #310): `page.select` and `doc.switch`, `effect.set` and `export.update` / `export.remove`, `page.move` with a non-page id. Numbers unchanged |
+| 2026-10-15 | minor | Find and replace (#358, #361) changes every occurrence, case-insensitively, also when the replacement contains the search text. Numbers unchanged |
+| 2026-10-15 | minor | The gradient shorthand (#254, #252) is read by the engine, so the CLI, control channel and MCP share it. Numbers unchanged |
+| 2026-10-15 | minor | .fig import (#279, #281): resized instances lay out their layers by their constraints (#266); explicit variable modes whose collection is named by its asset key, as library collections are, come across (#267). Numbers unchanged |
+| 2026-10-15 | minor | Colour emoji (#300, #268) draw from their own colour tables (sbix and CBDT/EBDT bitmap strikes, COLR v0 layers), a colour face tried first; COLR v1 still draws outlines. Numbers unchanged |
+| 2026-10-15 | minor | macOS (#290, #289): `.figcraft` files open from Finder, Open With and the Dock. Numbers unchanged |
+| 2026-10-15 | minor | Windows (#294, #291): the first preferred language alone picks the UI language. Numbers unchanged |
+| 2026-10-15 | minor | Unix font discovery (#474) follows `XDG_DATA_HOME`. Numbers unchanged |
+| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged |
+| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged |
+| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index adf0b0f2..a701cf6b 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -1,6 +1,6 @@
# File format parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (`.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -20,7 +20,7 @@ and prototype interactions are still unchecked there.
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
-| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — |
+| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305) | — |
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
## Import (place / paste / drop)
@@ -51,7 +51,7 @@ export limit, not a native document nesting limit.
|---|---|---|---|---|
| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them; sides round up to whole pixels | — |
| JPG | yes | yes | white where transparent; fixed quality (90) | — |
-| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `` with one `` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
+| SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `` with one `` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — |
| PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 |
| WebP | — | yes (ours) | | — |
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
@@ -165,6 +165,7 @@ each.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | `.fig` import (#279, #281; #266, #267): resized instances lay out their layers by their constraints, and explicit variable modes whose collection is named by its asset key come across (before, they were dropped and bound layers resolved in the default mode); fixture-tested, real files still owed. `.figcraft`: atomic saves and exports through unique staging files (#284), optional auto-save (#305). SVG export keeps a nested frame's fill and stroke inside its parent's clip (#327). Numbers unchanged |
| 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged |
| 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged |
| 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged |
diff --git a/docs/gaps.md b/docs/gaps.md
index 9b1a770c..936d2047 100644
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) |
| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) |
| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) |
-| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) |
+| 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) |
| 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) |
| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) |
| 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
@@ -178,7 +178,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
- **Impact:** Figma users notice the feel within minutes; it decides whether they stay.
- **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md).
-### 5. Localization: English, reviewed Russian, plus unreviewed drafts
+### 5. Localization: English, reviewed Russian and Turkish, plus unreviewed drafts
- **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese,
Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish
overlay, the strings added since each catalog was drafted (1–5% of each), Hindi, Arabic and
@@ -444,6 +444,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding |
| 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged |
| 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. |
| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. |
diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md
index df822981..f5be7263 100644
--- a/docs/hardware-parity.md
+++ b/docs/hardware-parity.md
@@ -1,6 +1,6 @@
# Hardware and platform parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -12,13 +12,13 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu
| Feature | Figma | FigCraft (macOS / Windows / Linux / web) | Hours |
|---|---|---|---|
-| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
+| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
| Effects (blurs, glass, noise) | GPU | CPU | in the above |
| HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — |
| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 |
| Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — |
| Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 |
-| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes. Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 |
+| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes; a minimised or covered window still answers control and MCP requests, while screenshots and input fail at once until it is on screen (#313, #332, #720). Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 |
| Screen readers | partial | AccessKit tree on desktop (names, roles, states), never tested with a real screen reader; none in the browser | 4–8 + human QA |
| Keyboard layouts (non-US) | yes | keymap uses logical keys; not checked on AZERTY/QWERTZ/JIS | 1–2 |
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | Community fixes (#290, #306, #312, #313, #328, #332, #346, #474): coarse zoom previews when renders from scratch are slow; a minimised or covered window answers control and MCP requests (screenshots and input fail at once while it is hidden); `.figcraft` files open from Finder on macOS; a message box when the window can't open on Windows; on Linux the window id matches the desktop entry, the RPM requires glibc 2.35 and user fonts under `XDG_DATA_HOME` are found. Numbers unchanged |
| 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged |
| 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged |
| 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. |
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index 50fcd4ec..44e35f39 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,6 +1,6 @@
# Localization parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
@@ -37,7 +37,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
only the first preferred language counts, since that list also holds the languages added for a
keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional
tag falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the
- other Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans,
+ other Latin American and US Spanish tags to es-419; regional ru, tr, cs and id tags (ru-RU,
+ tr-TR, cs-CZ, id-ID) to their catalogs; zh, zh-CN, zh-SG and zh-Hans read zh-Hans,
while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the
next OS language, never Simplified) or the browser's language on the web.
`pref.language {language}` sets it from the CLI, MCP and the control channel.
@@ -63,7 +64,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
- **Script support in the UI:** menus widen to their longest label; dropdowns, the two-column
labels of the right panel and the home screen's cards keep a long translation on one line with an
ellipsis and show it in full on hover (checked in German, the longest); no mirrored
- (right-to-left) layout of panels. CJK translations are shorter than English; the same ellipsis
+ (right-to-left) layout of panels. Cyrillic, Turkish and Czech letters draw from the UI fonts
+ (no font added; Russian checked in screenshots). CJK translations are shorter than English; the same ellipsis
and tooltip cover the few that don't fit (the sizing dropdowns).
- **CJK UI fonts:** egui's fonts and Inter have no CJK glyphs. Once the UI language, the open
file's names or comments, typed text or the language picker needs Japanese kana, Hangul or Han,
@@ -120,6 +122,7 @@ Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; it
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | Community catalogs (#257, #263, #469, #477, #288): Russian (`ru`, Русский, reviewed and approved by VanTan) and Turkish (`tr`, Türkçe, reviewed by a native speaker), Czech (`cs`, Čeština) and Indonesian (`id`, Bahasa Indonesia) drafts, and the Japanese draft caught up; Preferences ▸ Language lists them and System maps their regional tags. Catalogs may give few and many plural forms; Russian picks one, few or many by its integer rule (11–14 take many), the catalog supplying the text choosing the rule. Only Spanish is held to completeness. Counts re-measured: 1,587 UI keys (38 plural), 559 command labels and 31 menu wordings, 2,177 entries; Spanish 100%, Japanese 2,176, Czech and Indonesian 2,166, Russian and Turkish 2,165, the five older drafts 2,088–2,089 (95%). Localization 39% and 29–51 h unchanged at this rounding |
| 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac |
| 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) |
| 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog |
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 8d63a469..b18b7b68 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -16,8 +16,9 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr
3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)):
40–60 h, parallel by tool.
4. **Languages** on the landed localization layer: native reviews of the es, fr, de, pt-BR, ja,
- ko and zh-Hans drafts and the es-419 overlay (people), then the other key languages: 3–7 h per language
- plus native review.
+ ko, zh-Hans, id and cs drafts and the es-419 overlay (people; Russian and Turkish are reviewed),
+ the entries added since each catalog was written, then the other key languages (Hindi,
+ Vietnamese, Arabic): 3–7 h per language plus native review.
5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h.
## Alpha gate
@@ -57,7 +58,7 @@ blockers.
| M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 |
| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 |
| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 |
-| M10 Localization (new) | not started | layer + 11 languages | 60–100 |
+| M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts | native reviews, Hindi, Vietnamese, Arabic (with mirrored panels), engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 |
| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 |
| M12 AI features (new, owner decision) | not started | local models | 30–60 |
@@ -73,6 +74,55 @@ blockers.
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
+| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
+| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
+| 2026-10-15 | minor | Removing someone from a live session (#338): right-click a person's avatar ▸ Remove *name* from session, or `collab.removePerson {client}`, when you host the session or own the file on a server; the session ends for them (they keep their copy), the code or link they hold no longer gets them back in while it runs, and everyone else stays. Numbers unchanged |
+| 2026-10-15 | minor | Pages list (#272, #258): a long pages list scrolls in its own area, so the layers below stay in view; page and layer drops count only inside the list's visible rows. Numbers unchanged |
+| 2026-10-15 | minor | Variables export in the browser (#368): Export… ▸ Variables JSON / CSS custom properties did nothing in the web build and now arrives as a download; importing there still needs a file picker. Numbers unchanged |
+| 2026-10-15 | minor | Font picker keys (#352, #345): Up and Down step through the font families from the current one, keeping the highlight in view and previewing it on the canvas; Enter applies it, Escape leaves the text as it was. Numbers unchanged |
+| 2026-10-15 | minor | Leaving Design ends the pen path (#308, #307): switching to Prototype or Inspect while the Pen draws ends the path and goes back to Move (pressing Design again keeps drawing), and a click on the toolbar no longer reaches the canvas under it. Numbers unchanged |
+| 2026-10-15 | minor | Vector edit mode keeps the canvas still (#322): the edit mode hints in the Design panel wrap, so entering edit mode and picking a region no longer shift the canvas by about 25 points in the middle of the click (which dragged the region picked). Numbers unchanged |
+| 2026-10-15 | minor | Drop shadows under a background blur (#317, #255): a layer with both casts its shadows from what it paints (a translucent fill casts a lighter shadow than an opaque one) and reads the backdrop only after drawing them, so a shadow shown behind transparent areas is seen through the blur. Numbers unchanged |
+| 2026-10-15 | minor | When the window can't open (#306, #261): Windows release builds have no console, so a graphics failure at start quit without a word; the error is now logged and shown in a message box in the interface language, with what to try (unset `WGPU_BACKEND`, update the driver, OpenGL) and where to report it, skipped with `--control`. Numbers unchanged |
+| 2026-10-15 | minor | Crashes on Windows (#271, #314, #337; #224 and two more): a text field focused before it was drawn aborted the screen-reader layer; double-clicking a page, creating a variable collection and renaming a style folder no longer close the app. Numbers unchanged |
+| 2026-10-15 | minor | Deeply nested layers (#330, #210): one depth guard (256) on the three recursive layout paths, so a deep file can't run the layout out of stack. Numbers unchanged |
+| 2026-10-15 | minor | Image memory budgets (#326; #203, #189): decoded images and a file's images have budgets; the decode cache evicts its least recently used entries. Numbers unchanged |
+| 2026-10-15 | minor | macOS file dialogs (#253, #250) no longer re-enter the event loop; a dialog's result goes to the file and window it was opened for, and dialogs are parented to the main window. Numbers unchanged |
+| 2026-10-15 | minor | Recovery copies (#297, #303, #331, #336; #202, #185, #187): a copy counts only after its write succeeds and a failed one is retried; saves and closes wait for the write in flight; copies keep version history and branches; a recovered copy stays until this run has its own. Numbers unchanged |
+| 2026-10-15 | minor | Text edits (#280, #278) no longer roll back earlier changes; a drag released off the canvas ends at the pointer's last canvas position. Numbers unchanged |
+| 2026-10-15 | minor | Rollback (#282, #301; #196): a failed command rolls back during a drag or other gesture too; a failed batch keeps the redo steps. Numbers unchanged |
+| 2026-10-15 | minor | Atomic save and export (#284): staging files are uniquely named and created exclusively, so a file or link already at the old fixed staging name is never written through, and two writers of one file no longer share one. Numbers unchanged |
+| 2026-10-15 | minor | Alt-drag copies (#283) snap to their original, and Duplicate (Cmd+D) then repeats the last copy's offset. Numbers unchanged |
+| 2026-10-15 | minor | The pen (#277) shows its Bezier handles while a point is dragged out. Numbers unchanged |
+| 2026-10-15 | minor | Corner handles (#293, #292): a press on a corner handle resizes instead of picking the corner-radius handle. Numbers unchanged |
+| 2026-10-15 | minor | New text (#372, #362) takes a readable colour from the frame it is drawn in, not only the page (text drawn in a white frame on a dark page was white). Numbers unchanged |
+| 2026-10-15 | minor | Outline stroke (#386) works on every selected layer. Numbers unchanged |
+| 2026-10-15 | minor | Send to back (#444) counts per parent when the selection spans frames. Numbers unchanged |
+| 2026-10-15 | minor | Align buttons (#276) are off when the selection has nothing to align to. Numbers unchanged |
+| 2026-10-15 | minor | Gradient type icons (#275) show their gradient. Numbers unchanged |
+| 2026-10-15 | minor | Corner resize cursors (#388) stay diagonal at any proportions; the text selection highlight (#390) stays inside the box on the last line. Numbers unchanged |
+| 2026-10-15 | minor | New pages (#311, #295) take the current page's background; the theme still decides a new file's page. Numbers unchanged |
+| 2026-10-15 | minor | A slice's export preview (#265) shows the content under it. Numbers unchanged |
+| 2026-10-15 | minor | SVG export (#327, #325) keeps a nested frame's fill and stroke inside its parent's clip. Numbers unchanged |
+| 2026-10-15 | minor | Home screen (#318, #324): recent files notice files deleted while it is open; a long path no longer draws over the file name. Numbers unchanged |
+| 2026-10-15 | minor | System theme on first run (#262): the first frame follows the system theme, and going back to System from a manual theme works. Numbers unchanged |
+| 2026-10-15 | minor | Web clipboard (#375): copy and paste in the browser go through the system clipboard, so a paste after a copy brings the copied layers instead of whatever text the clipboard held. Numbers unchanged |
+| 2026-10-15 | minor | Zoom previews (#312): when renders from scratch are slow, a new zoom first shows the whole view coarsely instead of the page colour, then the refined image; images of another file or page never show. Numbers unchanged |
+| 2026-10-15 | minor | Hidden windows (#313, #332; #269): a minimised or covered window answers control and MCP requests (commands, inspection, `ui.render`) at once, each window on its own; screenshots and input still wait for it to be on screen. Numbers unchanged |
+| 2026-10-15 | minor | Server (#340, #302): a compaction cut short closes the file instead of confirming lost edits (#183); each team library version is kept in its own file (#206). Numbers unchanged |
+| 2026-10-15 | minor | Counters (#343, #344, #353; #309): collab sequence numbers, layer name counters and opacity digits can't overflow. Numbers unchanged |
+| 2026-10-15 | minor | Commands refuse bad indexes and ids (#475, #384, #310): `page.select` and `doc.switch`, `effect.set` and `export.update` / `export.remove`, `page.move` with a non-page id. Numbers unchanged |
+| 2026-10-15 | minor | Find and replace (#358, #361) changes every occurrence, case-insensitively, also when the replacement contains the search text. Numbers unchanged |
+| 2026-10-15 | minor | The gradient shorthand (#254, #252) is read by the engine, so the CLI, control channel and MCP share it. Numbers unchanged |
+| 2026-10-15 | minor | .fig import (#279, #281): resized instances lay out their layers by their constraints (#266); explicit variable modes whose collection is named by its asset key, as library collections are, come across (#267). Numbers unchanged |
+| 2026-10-15 | minor | Colour emoji (#300, #268) draw from their own colour tables (sbix and CBDT/EBDT bitmap strikes, COLR v0 layers), a colour face tried first; COLR v1 still draws outlines. Numbers unchanged |
+| 2026-10-15 | minor | macOS (#290, #289): `.figcraft` files open from Finder, Open With and the Dock. Numbers unchanged |
+| 2026-10-15 | minor | Windows (#294, #291): the first preferred language alone picks the UI language. Numbers unchanged |
+| 2026-10-15 | minor | Unix font discovery (#474) follows `XDG_DATA_HOME`. Numbers unchanged |
+| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged |
+| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged |
+| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). Numbers unchanged. |
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
index a1a5bf12..7531a455 100644
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -1,6 +1,6 @@
# FigCraft parity with Figma
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
@@ -638,6 +638,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | Localization (#263, #469, #477, #288): Turkish reviewed by a native speaker, Czech and Indonesian drafts, the Japanese draft caught up; coverage re-measured (2,177 entries; the five older drafts at 95%). Localization 39% and 29–51 h unchanged at this rounding (see [localization-parity](localization-parity.md)) |
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
| 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) |
| 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. |
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
index d87103f0..6e509859 100644
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -1,6 +1,6 @@
# UI and interaction parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -41,7 +41,7 @@ interaction feel not yet compared.
| Eye and padlock: click, or drag across rows to set several in one step; layers in a locked frame show a faint padlock | done | the toggles no longer vanish under the pointer (clicks on them could miss) |
| Rename: double click, F2 / Cmd+R, Tab to the next row; several layers open Rename layers (match, regular expressions, current name and number codes, start value, preview) | done | numbered in stacking order, pinned |
| Find layers (filter by name) | done | not covered by the documentation |
-| Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | the line between Pages and Layers drags to size the pages list, kept in the preferences (`layers_behaviour_tests`) |
+| Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | the line between Pages and Layers drags to size the pages list, kept in the preferences; a long list scrolls in its own area and a page dropped outside the visible list stays put (#272) (`layers_behaviour_tests`) |
| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | Shift+I (Quick insert) and the toolbar's Resources open it (they didn't, #710) |
| Hide when publishing from the Assets panel's context menu | done | a right-click on one of this file's components (also Libraries ▸ Hide when publishing for the selection, `component.hideWhenPublishing {ids?, on?}`): the model's sign of it is the name, so hiding puts a `.` before the name and showing takes the leading `.` and `_` away (one undo step, saved and synced like a rename; a variant or a layer inside hides its whole component or set); a hidden component shows a crossed eye on its tile and a dimmer name, the menu item its check mark; the next publish leaves it out and brings it back when shown again (`hide_publishing_tests`). Library components (read-only here) have no such menu |
@@ -81,11 +81,11 @@ interaction feel not yet compared.
| Frame (presets panel, swap orientation), section, slice | done | |
| Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) |
| Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only |
-| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`) |
-| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once |
+| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) |
+| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once |
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | |
-| Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
+| Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) |
| Comment (C), Shift+C to show/hide | done | local only |
| Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only |
@@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
+| 2026-10-15 | minor | Community fixes (#262, #272, #277, #300, #308, #311, #322, #352, #363, #372, #390): first runs follow the system theme; new text is readable on the frame it is drawn in; a long pages list scrolls on its own and new pages take the current page's colour; the pen shows its handles while dragging a point out and ends its path when you leave Design; vector edit mode keeps the canvas still; a drag with the Text tool makes a fixed-size box; colour emoji; the selection highlight on the last line; Up and Down in the font picker with a live preview. Numbers unchanged |
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
| 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel |
From 70f702ca170f1c44e439a5965e8f57090e39dd3f Mon Sep 17 00:00:00 2001
From: aiongg <67543387+aiongg@users.noreply.github.com>
Date: Fri, 23 Oct 2026 15:43:21 -0400
Subject: [PATCH 101/123] i18n: a missing translation fails no check; cargo
xtask i18n-report lists them (#1228)
---
crates/ui-egui/src/i18n_tests.rs | 108 +++++++++++++------------------
docs/localization-parity.md | 13 ++--
docs/translating.md | 20 +++---
xtask/src/main.rs | 14 +++-
4 files changed, 72 insertions(+), 83 deletions(-)
diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs
index 046212c1..3921cb1b 100644
--- a/crates/ui-egui/src/i18n_tests.rs
+++ b/crates/ui-egui/src/i18n_tests.rs
@@ -1,13 +1,11 @@
//! The catalog checks: every key the code looks up is in the English catalog, no catalog has keys
-//! the code doesn't use, placeholders match English, and complete languages translate everything.
+//! the code doesn't use, and placeholders match English. A missing translation reads English and
+//! fails nothing: [`missing_translations_report`] lists them (`cargo xtask i18n-report`).
use std::collections::BTreeSet;
use super::*;
-/// Languages that must translate every UI string and command label.
-const COMPLETE: &[&str] = &["es"];
-
/// Keys looked up from data rather than written as `tr("…")` in the code: the main menu's
/// submenu names (the engine's menu catalog) and labels of engine and document enums.
fn dynamic_keys() -> BTreeSet {
@@ -274,14 +272,19 @@ fn english_text_is_its_key_except_plurals() {
assert!(english().commands.is_empty() && english().menu.is_empty(), "English command labels come from the engine");
}
-#[test]
-fn catalogs_have_known_keys_and_matching_placeholders() {
- let en = english();
- let ids: BTreeSet<&str> = figcraft_engine::all_commands()
+/// The ids `[commands]` entries translate: every command and every main-menu item.
+fn command_ids() -> BTreeSet<&'static str> {
+ figcraft_engine::all_commands()
.iter()
.map(|c| c.id)
.chain(figcraft_engine::catalog::MENU.iter().map(|m| m.id).filter(|id| !id.is_empty()))
- .collect();
+ .collect()
+}
+
+#[test]
+fn catalogs_have_known_keys_and_matching_placeholders() {
+ let en = english();
+ let ids = command_ids();
for lang in LANGS {
assert!(!lang.name.is_empty(), "{}: [meta] name is missing", lang.code);
for (k, t) in lang.ui {
@@ -302,13 +305,29 @@ fn catalogs_have_known_keys_and_matching_placeholders() {
for (id, _) in lang.menu {
assert!(menu_worded_differently().contains(id), "{}: [menu] {id:?} reads like the command; [commands] covers it", lang.code);
}
- if COMPLETE.contains(&lang.code) {
- let missing: Vec<_> = en.ui.iter().filter(|(k, _)| find(lang.ui, k).is_none()).map(|(k, _)| k).collect();
- assert!(missing.is_empty(), "{}: untranslated: {missing:?}", lang.code);
- let missing: Vec<_> = ids.iter().filter(|id| find(lang.commands, id).is_none()).collect();
- assert!(missing.is_empty(), "{}: untranslated commands: {missing:?}", lang.code);
- let missing: Vec<_> = menu_worded_differently().into_iter().filter(|id| find(lang.menu, id).is_none()).collect();
- assert!(missing.is_empty(), "{}: menu items worded unlike their command, missing from [menu]: {missing:?}", lang.code);
+ }
+}
+
+/// What each language lacks: UI strings, command labels and menu wordings that read as in English
+/// because neither its catalog nor the one it overlays has them. It prints them and always passes.
+#[test]
+fn missing_translations_report() {
+ let ids = command_ids();
+ let worded = menu_worded_differently();
+ for (i, lang) in LANGS.iter().enumerate().filter(|(_, l)| l.code != "en") {
+ let own = || chain(i).filter(|l| l.code != "en");
+ let ui: Vec<_> = english().ui.iter().map(|(k, _)| *k).filter(|k| own().all(|l| find(l.ui, k).is_none())).collect();
+ let commands: Vec<_> = ids.iter().filter(|id| own().all(|l| find(l.commands, id).is_none())).collect();
+ let menu: Vec<_> = worded.iter().filter(|id| own().all(|l| find(l.menu, id).is_none())).collect();
+ println!("{}: {} UI strings, {} command labels, {} menu wordings missing", lang.code, ui.len(), commands.len(), menu.len());
+ for k in ui {
+ println!(" {k:?}");
+ }
+ for id in commands {
+ println!(" [commands] {id}");
+ }
+ for id in menu {
+ println!(" [menu] {id}");
}
}
}
@@ -344,8 +363,6 @@ fn system_languages_pick_the_first_one_with_a_catalog() {
assert_eq!(code("system", Some("cs_CZ.UTF-8")), "cs");
assert_eq!(code("system", Some("cs-CZ,en-US")), "cs");
assert_eq!(code("cs", None), "cs");
- assert_eq!(LANGS[resolve("cs", None)].name, "Čeština");
- assert!(LANGS[resolve("cs", None)].draft);
assert_eq!(code("system", Some("pt-BR")), "pt-BR");
assert_eq!(code("system", Some("tr_TR.UTF-8")), "tr");
assert_eq!(code("system", Some("id_ID.UTF-8")), "id");
@@ -368,8 +385,6 @@ fn swedish_is_picked_for_swedish_locales() {
assert_eq!(code("system", Some(tag)), "sv", "{tag}");
}
let sv = LANGS.iter().position(|l| l.code == "sv").unwrap();
- assert_eq!(LANGS[sv].name, "Svenska");
- assert!(!LANGS[sv].draft);
assert!(!plural_one(sv, 0) && plural_one(sv, 1) && !plural_one(sv, 2));
}
@@ -387,10 +402,11 @@ fn latin_american_spanish_overlays_spanish() {
assert_eq!(LANGS[la].parent, es);
assert!(es < la, "the picker lists Español first");
// Its own wording, else Spanish's, else English's.
- assert!(matches!(lookup(la, "Add fill"), Some(Text::Plain("Agregar relleno"))));
- assert!(matches!(lookup(la, "Fill"), Some(Text::Plain("Relleno"))));
+ for (k, _) in LANGS[es].ui.iter().filter(|(k, _)| find(LANGS[la].ui, k).is_none()) {
+ assert!(lookup(la, k).is_some_and(|t| std::ptr::eq(t, find(LANGS[es].ui, k).unwrap())), "{k:?}");
+ }
let undo = |i: usize| chain(i).find_map(|l| find(l.commands, "edit.undo")).map(forms);
- assert!(undo(es).is_some() && undo(la) == undo(es), "command labels fall back too");
+ assert_eq!(undo(la), undo(es), "command labels fall back too");
// An overlay lists only what differs from the catalog under it.
for lang in LANGS.iter().filter(|l| l.parent != 0) {
let under = &LANGS[lang.parent];
@@ -431,19 +447,11 @@ fn japanese_korean_and_chinese_have_no_plural_forms() {
}
#[test]
-fn georgian_is_a_draft_with_count_neutral_plurals() {
+fn georgian_is_picked_for_georgian_locales() {
let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code;
for tag in ["ka", "ka-GE", "ka_GE.UTF-8"] {
assert_eq!(code("system", Some(tag)), "ka", "{tag}");
}
- let ka = LANGS.iter().position(|l| l.code == "ka").unwrap();
- assert!(LANGS[ka].draft && LANGS[ka].name == "ქართული");
- // A noun stays singular after a numeral: both forms read the same where English counts in both.
- for (k, t) in LANGS[ka].ui {
- if let (Text::Plural { one: Some(one), other, .. }, Some(Text::Plural { one: Some(en_one), .. })) = (t, find(english().ui, k)) {
- assert!(!en_one.contains("{n}") || one == other, "ka: {k:?}");
- }
- }
}
/// Release builds embed craft-fonts' Noto Sans Georgian (`theme::install_fonts`' built-in faces),
@@ -477,7 +485,6 @@ fn chinese_reads_simplified_and_traditional_has_no_catalog() {
assert_eq!(code(tag, None), "en", "{tag}");
}
assert_eq!(code("system", Some("zh-TW,ja-JP")), "ja");
- assert_eq!(LANGS[resolve("zh-Hans", None)].name, "简体中文");
}
#[test]
@@ -491,28 +498,18 @@ fn the_key_scanner_reads_rust_literals() {
#[test]
fn czech_integer_plurals() {
- let cs = LANGS.iter().position(|l| l.code == "cs").unwrap();
assert_eq!(plural("cs", 1), Plural::One);
- assert_eq!(fill(counted(cs, "{n} layers", 1), &[("n", &1)]), "1 vrstva");
for n in [2, 3, 4] {
assert_eq!(plural("cs", n), Plural::Few, "{n}");
- assert_eq!(fill(counted(cs, "{n} layers", n), &[("n", &n)]), format!("{n} vrstvy"));
}
for n in [0, 5, 11, 12, 21, 22, 100, 1_000_000] {
assert_eq!(plural("cs", n), Plural::Other, "{n}");
- assert_eq!(fill(counted(cs, "{n} layers", n), &[("n", &n)]), format!("{n} vrstev"));
- }
- for (key, text) in LANGS[cs].ui {
- if let Text::Plural { one, few, .. } = text {
- assert!(one.is_some() && few.is_some(), "{key}: missing Czech plural forms");
- }
}
}
#[test]
fn russian_language_tags_and_integer_plurals() {
let ru = LANGS.iter().position(|l| l.code == "ru").unwrap();
- assert_eq!(LANGS[ru].name, "Русский");
for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "RU", "ru-BY", "ru-Cyrl-RU"] {
assert_eq!(resolve(tag, None), ru, "{tag}");
assert_eq!(resolve("system", Some(tag)), ru, "{tag}");
@@ -520,49 +517,34 @@ fn russian_language_tags_and_integer_plurals() {
assert_eq!(resolve("system", Some("hi,ru-RU,en-US")), ru);
for n in [1, 21, 31, 101, 1_001] {
assert_eq!(plural("ru", n), Plural::One, "{n}");
- assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слой"));
}
for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] {
assert_eq!(plural("ru", n), Plural::Few, "{n}");
- assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слоя"));
}
for n in [0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 111, 112, 114, 1_000_000] {
assert_eq!(plural("ru", n), Plural::Many, "{n}");
- assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слоёв"));
- }
- for (key, text) in LANGS[ru].ui {
- if let Text::Plural { one, few, many, .. } = text {
- assert!(one.is_some() && few.is_some() && many.is_some(), "{key}: missing Russian plural forms");
- }
}
}
#[test]
fn polish_integer_plurals() {
- let pl = LANGS.iter().position(|l| l.code == "pl").unwrap();
- assert_eq!(LANGS[pl].name, "Polski");
assert_eq!(plural("pl", 1), Plural::One);
- assert_eq!(fill(counted(pl, "{n} layers", 1), &[("n", &1)]), "1 warstwa");
for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] {
assert_eq!(plural("pl", n), Plural::Few, "{n}");
- assert_eq!(fill(counted(pl, "{n} layers", n), &[("n", &n)]), format!("{n} warstwy"));
}
for n in [0, 5, 10, 11, 12, 13, 14, 19, 20, 21, 25, 31, 100, 101, 111, 112, 114, 1_000_000] {
assert_eq!(plural("pl", n), Plural::Many, "{n}");
- assert_eq!(fill(counted(pl, "{n} layers", n), &[("n", &n)]), format!("{n} warstw"));
- }
- for (key, text) in LANGS[pl].ui {
- if let Text::Plural { one, few, many, .. } = text {
- assert!(one.is_some() && few.is_some() && many.is_some(), "{key}: missing Polish plural forms");
- }
}
}
#[test]
fn plural_fallback_uses_the_language_of_its_catalog() {
+ // A plural es-419 doesn't overlay reads Spanish's entry under Spanish's rule.
+ let es = LANGS.iter().position(|l| l.code == "es").unwrap();
let la = LANGS.iter().position(|l| l.code == "es-419").unwrap();
- assert_eq!(counted(la, "{n} layers", 1), "{n} capa");
- assert_eq!(counted(la, "{n} layers", 21), "{n} capas");
+ for n in [1, 21] {
+ assert_eq!(counted(la, "{n} layers", n), counted(es, "{n} layers", n), "{n}");
+ }
// Unsupported optional categories use other; existing one/other languages are unchanged.
for code in ["en", "es", "de"] {
for n in [0, 2, 11, 21, 1_000_000] {
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
index 44e35f39..aa4cbb52 100644
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,6 +1,6 @@
# Localization parity
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (a missing translation fails no check: `cargo xtask i18n-report` lists them; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
@@ -46,11 +46,11 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
(`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the
overlay, then the base language, then English.
- **Check:** `cargo test -p figcraft-ui-egui i18n` fails on keys the code uses that `en.toml`
- lacks, keys no code uses, unknown command ids, placeholders that differ from English, overlay
- entries that read as in their base language and, for complete languages (Spanish), anything
- untranslated. The other catalogs are not held to it: a string added later shows English there
- until someone translates it. Every draft translated every string on 2026-10-12; by 2026-10-17
- the strings added since left them at 95–99% (table below).
+ lacks, keys no code uses, unknown command ids, placeholders that differ from English and overlay
+ entries that read as in their base language. No language is held to full coverage: a string
+ added later shows English until someone translates it, and `cargo xtask i18n-report` (part of
+ `cargo xtask ci`) lists what each language lacks. Every draft translated every string on
+ 2026-10-12; by 2026-10-17 the strings added since left them at 95–99% (table below).
- **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values
such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules
(`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian
@@ -122,6 +122,7 @@ Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; it
| Date | Change | Summary |
|---|---|---|
+| 2026-10-23 | minor | A missing translation fails no check: Spanish is no longer held to full coverage, and the tests of one language's wording, names and plural forms are gone (the plural rules and language-tag tests stay). `cargo xtask i18n-report`, run by `cargo xtask ci`, lists each language's missing UI strings, command labels and menu wordings. Grade unchanged |
| 2026-10-15 | minor | Community catalogs (#257, #263, #469, #477, #288): Russian (`ru`, Русский, reviewed and approved by VanTan) and Turkish (`tr`, Türkçe, reviewed by a native speaker), Czech (`cs`, Čeština) and Indonesian (`id`, Bahasa Indonesia) drafts, and the Japanese draft caught up; Preferences ▸ Language lists them and System maps their regional tags. Catalogs may give few and many plural forms; Russian picks one, few or many by its integer rule (11–14 take many), the catalog supplying the text choosing the rule. Only Spanish is held to completeness. Counts re-measured: 1,587 UI keys (38 plural), 559 command labels and 31 menu wordings, 2,177 entries; Spanish 100%, Japanese 2,176, Czech and Indonesian 2,166, Russian and Turkish 2,165, the five older drafts 2,088–2,089 (95%). Localization 39% and 29–51 h unchanged at this rounding |
| 2026-10-17 | minor | Chinese showed as boxes on macOS (#406): the font scan also walks the system's downloaded font assets (`/System/Library/AssetsV2` and the older `/System/Library/Assets`, only their `com_apple_MobileAsset_Font*` folders), where current versions keep PingFang SC, and Hiragino Sans GB and Heiti SC join the Chinese UI faces and the text fallbacks. Tested with a folder laid out the same way; not yet run on a Mac |
| 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) |
diff --git a/docs/translating.md b/docs/translating.md
index eaa59755..9dfca60c 100644
--- a/docs/translating.md
+++ b/docs/translating.md
@@ -91,13 +91,9 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its
three forms. Czech's CLDR `many` applies only to decimals; `trn` takes integer counts.
Polish uses `one` for 1 only, `few` like Russian (2–4, 22–24… except 12–14) and `many` for
the other integers, including 0 and 21 (e.g. `{n} warstwa`, `{n} warstwy`, `{n} warstw`).
-4. To require full coverage, add the code to `COMPLETE` in
- [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that
- adds a string then has to translate it into each complete language, so only Spanish is there:
- French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and
- Georgian are registered as drafts, Russian and Turkish are reviewed but not required, and a string added
- later shows English in them until it is translated. To list what a draft lacks,
- add its code to `COMPLETE` locally and run the check.
+4. A string added later shows English in every language until someone translates it; a missing
+ translation never fails a check or a build. `cargo xtask i18n-report` lists what each language
+ lacks (UI strings, command labels, menu wordings), and `cargo xtask ci` prints the same list.
5. Run the check and look at the panels in the app (longer words must fit the right panel):
cargo test -p figcraft-ui-egui i18n
@@ -106,9 +102,9 @@ picker leaves Georgian out. A new CJK or other non-Latin language also needs its
with "…" and show it in full on hover; elsewhere, look for clipped or wrapped text.
The check fails on keys the code uses that `en.toml` lacks (it prints the lines to paste), keys no
-code uses, unknown command ids, placeholders that don't match English, and, for complete languages,
-anything untranslated. Machine-drafted catalogs stay marked as drafts until a native speaker has
-reviewed them; a reviewed catalog stays a draft until a maintainer approves it (its `status` says which).
+code uses, unknown command ids and placeholders that don't match English. Machine-drafted catalogs
+stay marked as drafts until a native speaker has reviewed them; a reviewed catalog stays a draft
+until a maintainer approves it (its `status` says which).
## Adding strings in code
@@ -116,11 +112,11 @@ Wrap every string a user sees or a screen reader hears: `tr` for plain text, `tr
placeholders, `trn` for counts, `tr_noop` for literals in `const` tables that are looked up later
with `tr(label)`. The key must be a string literal as the first argument (the check reads the
source). Don't wrap ids, command ids, JSON, log messages, file extensions or document content. Then
-add the key to `en.toml` and to each complete language (Spanish and Russian); the drafts may follow later.
+add the key to `en.toml`; translations may follow later.
Labels from the engine and document crates (tool names, blend modes, easing curves…) are looked up
at display with `tr(x.label())`; they are listed in `dynamic_labels` in `i18n_tests.rs`.
The importers' fixed notes (the string literals passed to `warn` in `crates/format/src/fig` and
`crates/format/src/rest`) are keys too: the import report shows them in the UI language, so a new
-note goes into `en.toml` and each complete language. Notes built with `format!` show in English.
+note goes into `en.toml`. Notes built with `format!` show in English.
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
index 81d48471..6c09c921 100644
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -1,7 +1,9 @@
//! Project tasks: `cargo xtask `.
//!
-//! - `ci` fmt check, clippy -D warnings, tests, layers, assets, wasm check
+//! - `ci` fmt check, clippy -D warnings, tests, the translation report, layers, assets, wasm check
//! - `test` the workspace tests (nextest when installed) and doctests
+//! - `i18n-report` the UI strings, command labels and menu wordings each language lacks (never fails
+//! on them: a missing translation reads English)
//! - `layers` crate dependency layering check
//! - `assets` every file under assets/ and docs/ has a row in ATTRIBUTION.md
//! - `parity` menu-catalog coverage → docs/parity-checklist.md
@@ -38,6 +40,7 @@ fn main() -> ExitCode {
let ok = match task {
"ci" => ci(),
"test" => test(),
+ "i18n-report" => i18n_report(),
"layers" => layers::check(&root()),
"assets" => assets(),
"parity" => parity(),
@@ -47,7 +50,7 @@ fn main() -> ExitCode {
"plugins" => plugins(),
"bench" => bench(args.get(1).map_or("1000,10000,50000", String::as_str)),
_ => {
- eprintln!("tasks: ci, test, layers, assets, parity, version [set X.Y.Z], icon, wasm, plugins, bench [SIZES]");
+ eprintln!("tasks: ci, test, i18n-report, layers, assets, parity, version [set X.Y.Z], icon, wasm, plugins, bench [SIZES]");
task == "help"
}
};
@@ -58,6 +61,7 @@ fn ci() -> bool {
run(cargo().args(["fmt", "--all", "--check"]))
&& run(cargo().args(["clippy", "--workspace", "--all-targets", "--keep-going", "--", "-D", "warnings"]))
&& test()
+ && i18n_report()
&& layers::check(&root())
&& assets()
&& wasm()
@@ -77,6 +81,12 @@ fn test() -> bool {
units && run(cargo().args(["test", "--workspace", "--doc"]))
}
+/// Prints what each language's catalog lacks, from the test that lists it. `--workspace --lib`
+/// reuses the binaries `test` built.
+fn i18n_report() -> bool {
+ run(cargo().args(["test", "-q", "--workspace", "--lib", "--", "--exact", "i18n::tests::missing_translations_report", "--nocapture"]))
+}
+
fn wasm() -> bool {
run(cargo().args([
"check",
From 068634b586b5a4a1f24f0698a1362dc8caa7dad1 Mon Sep 17 00:00:00 2001
From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com>
Date: Fri, 23 Oct 2026 21:53:25 +0200
Subject: [PATCH 102/123] Honour even-odd fill rule when hit testing vectors
Found while testing crates/doc/src/geometry.rs:75 (hits_node_itself, the `_` arm, using path.winding(lp) != 0); compare VectorNetwork::fill_contains at crates/doc/src/vector.rs:321
Co-Authored-By: Claude Haiku 5.5
Co-Authored-By: Claude Sonnet 5.5
---
crates/doc/src/geometry.rs | 11 ++++++++++-
crates/doc/src/tests.rs | 28 ++++++++++++++++++++++++++++
2 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs
index 2a7b42a6..4c48713a 100644
--- a/crates/doc/src/geometry.rs
+++ b/crates/doc/src/geometry.rs
@@ -72,7 +72,16 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
n.local_rect().inflate(tol_l, tol_l).contains(lp)
}
- _ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && path.winding(lp) != 0)),
+ _ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))),
+ }
+}
+
+/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of
+/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it).
+fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool {
+ match &n.kind {
+ NodeKind::Vector { network } => network.fill_contains(lp),
+ _ => path.winding(lp) != 0,
}
}
diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs
index a5ead3b7..4b82fbac 100644
--- a/crates/doc/src/tests.rs
+++ b/crates/doc/src/tests.rs
@@ -464,3 +464,31 @@ fn text_style_empty_range_links_and_unlinks_all() {
e.unlink_text_style(0, 0);
assert!(e.style.style_id.is_none());
}
+
+#[test]
+fn hit_test_even_odd_vector_hole_misses() {
+ let ring = |rule| {
+ let sq = |o: f64, s: f64, base: usize| {
+ (
+ [(o, o), (o + s, o), (o + s, o + s), (o, o + s)].map(|(x, y)| Vertex::new(x, y)),
+ [Segment::line(base, base + 1), Segment::line(base + 1, base + 2), Segment::line(base + 2, base + 3), Segment::line(base + 3, base)],
+ )
+ };
+ let (v1, s1) = sq(0.0, 200.0, 0);
+ let (v2, s2) = sq(60.0, 80.0, 4);
+ VectorNetwork {
+ vertices: v1.into_iter().chain(v2).collect(),
+ segments: s1.into_iter().chain(s2).collect(),
+ regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }],
+ }
+ };
+ let mut d = Document::new("x");
+ let p = page(&d);
+ let eo = d.create(p, NodeKind::Vector { network: ring(WindingRule::EvenOdd) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap();
+ assert_eq!(d.hit_test(p, Point::new(20.0, 100.0), 0.0, None), Some(eo));
+ assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), None, "the hole is not filled");
+ let mut d = Document::new("x");
+ let p = page(&d);
+ let nz = d.create(p, NodeKind::Vector { network: ring(WindingRule::NonZero) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap();
+ assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), Some(nz));
+}
From 17ed21d1285192bc12eb9fe4c07e0fa602d7a4c6 Mon Sep 17 00:00:00 2001
From: BFlat
Date: Sat, 24 Oct 2026 02:56:55 +0700
Subject: [PATCH 103/123] M6: Dev Mode tail: imports in layer trees, compare
with versions, an inspect-only canvas (#538)
Import lines for connected components inside generated layer trees; Compare changes against any saved version; a Dev Mode behaviour suite with an inspect-only canvas (menus, shortcuts, paste and Revert to saved refuse design edits; a drag under way is dropped on entering), Changed in the ready list, statuses only on top-level frames and sections; read-only plugins' panels from the Inspect tab.
---
ROADMAP.md | 4 +-
crates/engine/src/annotations_tests.rs | 2 +-
crates/engine/src/cmd/file.rs | 2 +-
crates/engine/src/cmd/props.rs | 6 +-
crates/engine/src/cmd/section.rs | 28 ++-
crates/engine/src/code_connect.rs | 119 +++++++++-
crates/engine/src/codegen.rs | 2 +-
crates/engine/src/component_codegen.rs | 10 +-
crates/engine/src/devcompare.rs | 51 +++--
crates/engine/src/devfocus_tests.rs | 5 +-
crates/engine/src/devinspect.rs | 31 ++-
crates/engine/src/devinspect_tests.rs | 2 +-
crates/engine/src/devmode_behaviour_tests.rs | 224 +++++++++++++++++++
crates/engine/src/devmode_tail_tests.rs | 163 ++++++++++++++
crates/engine/src/lib.rs | 4 +
crates/engine/src/tools.rs | 63 ++++--
crates/ui-egui/locales/de.toml | 8 +
crates/ui-egui/locales/en.toml | 8 +
crates/ui-egui/locales/es.toml | 8 +
crates/ui-egui/locales/fr.toml | 8 +
crates/ui-egui/locales/ja.toml | 8 +
crates/ui-egui/locales/ko.toml | 8 +
crates/ui-egui/locales/pt-BR.toml | 8 +
crates/ui-egui/locales/zh-Hans.toml | 8 +
crates/ui-egui/src/canvas.rs | 5 +-
crates/ui-egui/src/control.rs | 8 +-
crates/ui-egui/src/dev_compare.rs | 149 +++++++++---
crates/ui-egui/src/dev_mode.rs | 42 ++--
crates/ui-egui/src/devmode_tail_tests.rs | 110 +++++++++
crates/ui-egui/src/lib.rs | 25 ++-
crates/ui-egui/src/menus.rs | 54 ++++-
crates/ui-egui/src/plugin_panel_tests.rs | 2 +-
crates/ui-egui/src/shortcuts.rs | 11 +-
crates/ui-egui/src/toolbar.rs | 16 +-
docs/control-protocol.md | 9 +-
docs/dev-mode-parity.md | 22 +-
docs/gaps.md | 26 ++-
docs/mcp.md | 33 ++-
38 files changed, 1117 insertions(+), 175 deletions(-)
create mode 100644 crates/engine/src/devmode_behaviour_tests.rs
create mode 100644 crates/engine/src/devmode_tail_tests.rs
create mode 100644 crates/ui-egui/src/devmode_tail_tests.rs
diff --git a/ROADMAP.md b/ROADMAP.md
index b3edc4ee..cb45e6af 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -86,7 +86,7 @@ native speakers' reviews of the nine language drafts need people.
| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) |
| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
-| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) |
+| Dev Mode / inspect | 97% | 76% | 2–4 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
@@ -142,6 +142,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
+- **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%.
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
@@ -388,6 +389,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
+| 2026-10-15 | minor | Dev Mode tail (M6, gap 16): import lines for connected components inside generated layer trees (definitions, slots and swapped-in instances too); `dev.compare {version}` and the compare view's Compare with menu compare with any saved version; behaviour suite (5 tests; 4 failed before the fixes): an inspect-only Dev Mode canvas with menus, shortcuts, nudges, opacity keys and paste refusing design edits, toolbar drawing tools returning to the design, Changed in the ready list (`inspect.readyForDev {status: changed}`), statuses only on sections and top-level frames; read-only plugins' panels from the Inspect tab. Dev Mode 96%/72% → 97%/76%, 0–1 h → 2–4 h (measurements kept on the canvas and a read-only layers panel counted as missing); header totals not recomputed |
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
diff --git a/crates/engine/src/annotations_tests.rs b/crates/engine/src/annotations_tests.rs
index 7fc7376f..00065049 100644
--- a/crates/engine/src/annotations_tests.rs
+++ b/crates/engine/src/annotations_tests.rs
@@ -171,7 +171,7 @@ fn ready_for_dev_lists_frames_by_status() {
};
let ready = ex(&mut s, "inspect.readyForDev", json!({}));
assert_eq!(names(&ready), vec!["Login", "Onboarding", "Profile"], "every page, page and layer order");
- assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev"}));
+ assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev", "changed": false}));
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]);
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any", "page": page}))), vec!["Login", "Signup", "Onboarding"]);
assert!(s.execute("inspect.readyForDev", &json!({"status": "none"})).is_err());
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 7380711f..60784750 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -366,7 +366,7 @@ fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result {
if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) }
}
-fn read_version(v: &Version) -> Result> {
+pub(crate) fn read_version(v: &Version) -> Result> {
v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name)))
}
diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs
index f180b53c..3ed0f9b0 100644
--- a/crates/engine/src/cmd/props.rs
+++ b/crates/engine/src/cmd/props.rs
@@ -533,8 +533,12 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map