Batch r2-2 review: zip export cap, Dev Mode refuses grouped edits, path sizes fix their axis, single-call Tailwind variables, rem only where it converts, .fig pages keep room past library copies, MCP pages past an oversized child, catalog and doc fixes

This commit is contained in:
bflatastic
2026-10-24 11:52:59 +07:00
parent ccff6c8658
commit 021a62dd95
17 changed files with 154 additions and 33 deletions
+12
View File
@@ -221,3 +221,15 @@ fn canvas_stacking_paints_and_exports_the_first_child_on_top() {
s.undo().unwrap();
assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]);
}
#[test]
fn a_size_given_with_a_new_path_fixes_that_axis() {
let mut s = Session::new();
let f = al_frame(&mut s, json!({}));
let v = id(s.execute("node.create", &json!({"type": "vector", "path": "M0 0 L10 10", "closed": false, "parent": f})).unwrap());
set(&mut s, v, json!({"sizingH": "fill"}));
assert_eq!(sizing(&s, v).0, Sizing::Fill);
set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30}));
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
}
+30 -1
View File
@@ -1166,6 +1166,20 @@ fn size_param(cmd: &str, p: &Value) -> Result<Option<ExportSize>> {
/// Most files one `file.exportLayers` writes.
const MAX_EXPORT_FILES: usize = 1000;
/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy
/// are all in memory at once (in the browser too).
const MAX_ZIP_BYTES: usize = 256 << 20;
/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together.
fn add_member<'a>(members: &mut Vec<(&'a str, Vec<u8>)>, name: &'a str, bytes: Vec<u8>, limit: usize) -> Result<()> {
let total = members.iter().map(|(_, b)| b.len()).sum::<usize>().saturating_add(bytes.len());
if total > limit {
let mb = limit >> 20;
return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder")));
}
members.push((name, bytes));
Ok(())
}
/// One file of a layer export: the layer, its setting and the file name.
pub struct ExportJob {
@@ -1259,7 +1273,7 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
write(&path, &bytes)?;
f["path"] = json!(path);
}
None if zip => members.push((job.file.as_str(), bytes)),
None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?,
None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)),
}
files.push(f);
@@ -1803,3 +1817,18 @@ mod staging_write_tests {
let _ = std::fs::remove_dir_all(&d);
}
}
#[cfg(test)]
mod zip_cap_tests {
use super::add_member;
#[test]
fn zip_members_stop_at_the_limit_and_keep_what_fit() {
let mut members = Vec::new();
add_member(&mut members, "a.png", vec![0; 6], 10).unwrap();
add_member(&mut members, "b.png", vec![0; 4], 10).unwrap();
let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string();
assert!(err.contains("as one zip"), "{err}");
assert_eq!(members.iter().map(|(n, _)| *n).collect::<Vec<_>>(), ["a.png", "b.png"]);
}
}
+16 -9
View File
@@ -393,6 +393,18 @@ fn apply_order(k: &str) -> u8 {
}
}
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
/// into W or H.
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
let n = doc.get_mut(id)?;
if is_w {
n.child_layout.sizing_h = Sizing::Fixed;
} else {
n.child_layout.sizing_v = Sizing::Fixed;
}
Ok(())
}
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
let mut unknown = Vec::new();
@@ -404,8 +416,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
let k = k.as_str();
match k {
"path" => path_offset = set_path(cmd, doc, id, v, props)?,
// A new path takes the size asked for as its scale (`set_path`).
"width" | "height" | "w" | "h" if props.contains_key("path") => {}
// A new path takes the size asked for as its scale (`set_path`); the size still fixes
// its auto layout axis.
"width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?,
"x" | "y" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
let val = val + if k == "x" { path_offset.0 } else { path_offset.1 };
@@ -450,13 +463,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
TextAutoResize::None
};
}
// Explicit width/height makes hugging/filling fixed (like typing into W/H).
let n = doc.get_mut(id)?;
if is_w {
n.child_layout.sizing_h = Sizing::Fixed;
} else {
n.child_layout.sizing_v = Sizing::Fixed;
}
fix_axis(doc, id, is_w)?;
}
"rotation" => {
let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?;
+3 -2
View File
@@ -99,9 +99,10 @@ impl CodeLang {
CodeLang::Compose => "Compose",
}
}
/// Languages whose lengths are CSS pixels (they can be shown in rem).
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
/// are utility classes).
pub fn is_web(self) -> bool {
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Tailwind)
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
}
pub fn from_id(s: &str) -> Option<CodeLang> {
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
+3 -1
View File
@@ -224,9 +224,11 @@ pub struct DevItem {
/// Revert to saved, which isn't an undo step but throws the design's unsaved edits away.
pub fn dev_mode_allows(id: &str) -> bool {
const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."];
// Edits recorded as one grouped undo step, so their spec says no history of its own.
const GROUPED: [&str; 2] = ["command.batch", "data.fill"];
match id.strip_prefix("tool.") {
Some(t) => matches!(t, "move" | "hand" | "comment"),
None if id == "file.revert" => false,
None if id == "file.revert" || GROUPED.contains(&id) => false,
None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history),
}
}
@@ -124,6 +124,8 @@ fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts
"tool.place",
"file.import",
"file.revert",
"data.fill",
"command.batch",
] {
assert!(!dev_mode_allows(id), "{id} edits the design");
}
+14 -1
View File
@@ -105,9 +105,22 @@ fn arb(v: &str) -> String {
format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_"))
}
/// The custom property a `var(--name, fallback)` value reads.
/// The custom property a `var(--name, fallback)` value reads, when that one call is the whole
/// value (not `var(--a, 4px) 8px`, four corners of which only the first is a variable).
fn var_name(v: &str) -> Option<&str> {
let inner = v.trim().strip_prefix("var(")?;
let mut depth = 1usize;
let close = inner.char_indices().find_map(|(i, c)| {
match c {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
(depth == 0).then_some(i)
})?;
if close + 1 != inner.len() {
return None;
}
Some(inner.split([',', ')']).next()?.trim())
}
+7
View File
@@ -333,3 +333,10 @@ fn review_desktop_without_fill_clears_a_mobile_image_or_gradient() {
let got = merge(Some(&[("background".to_string(), "[background:url(image.png)_center_/_cover]".to_string())]), &[]);
assert!(got.contains(&"lg:bg-none".to_string()) && got.contains(&"lg:bg-transparent".to_string()), "{got:?}");
}
#[test]
fn per_corner_radii_with_a_variable_first_keep_every_corner() {
let got: Vec<String> =
classes("border-radius: var(--radius-a, 4px) 8px 8px 8px;\nborder-radius: var(--radius-card, 4px);\n").into_iter().map(|(_, c)| c).collect();
assert_eq!(got, ["rounded-[var(--radius-a,_4px)_8px_8px_8px]", "rounded-card"]);
}
+10
View File
@@ -136,3 +136,13 @@ fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() {
assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}");
assert!(linked_to(&doc, "Library main") >= 190, "{warnings:?}");
}
#[test]
fn a_library_main_larger_than_the_cap_leaves_the_pages_room() {
// A main of 300 layers with room for 100: mapped first, it took the whole budget and the page
// came out empty.
let (doc, warnings) = import_at(&library_message(5, 300), 100);
let page = doc.nodes().find(|n| n.name == "Screens").expect("the page");
let top = page.children.len();
assert!(top >= 1, "the page kept {top} layers: {warnings:?}");
}
+21 -8
View File
@@ -404,6 +404,9 @@ struct Mapper<'a> {
/// Most layers produced or scanned, instance expansion included; the one place the cap comes
/// from (see [`super::layer_budget`]).
max_nodes: usize,
/// Layers kept back for the visible pages' top level while library copies are mapped, so the
/// copies can't take the whole budget and leave the pages empty.
reserved: usize,
/// Vector networks, each built once: the layers that show one (every instance of a main
/// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's
/// networks took gigabytes.
@@ -481,6 +484,7 @@ impl<'a> Mapper<'a> {
referenced_mains: BTreeSet::new(),
mapped_mains: BTreeSet::new(),
max_nodes,
reserved: 0,
networks: Vec::new(),
network_ids: HashMap::new(),
}
@@ -565,6 +569,11 @@ impl<'a> Mapper<'a> {
scope
}
/// How many layers may be produced now: the cap, less what is kept for the visible pages.
fn budget(&self) -> usize {
self.max_nodes.saturating_sub(self.reserved)
}
/// The DOCUMENT with its pages.
fn document(&mut self) -> Value {
let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone());
@@ -591,11 +600,15 @@ impl<'a> Mapper<'a> {
// last layers are skipped rather than the mains every instance of them needs (#981).
let visible_pages: Vec<String> = visible.iter().map(|(p, _)| p.clone()).collect();
let required_mains = self.referenced_component_mains(&visible_pages);
// The pages and their top-level layers, up to half the cap, stay for the pages.
let top: usize = visible_pages.iter().map(|p| 1 + self.children(p).len()).sum();
self.reserved = top.min(self.max_nodes / 2);
for (id, canvas) in internal {
if let Some(context) = self.library_context(&id, canvas, &required_mains) {
self.library_contexts.push(context);
}
}
self.reserved = 0;
let out: Vec<Value> = visible.into_iter().map(|(p, n)| self.page(&p, n)).collect();
if out.is_empty() {
self.warn("the file has no pages");
@@ -840,7 +853,7 @@ impl<'a> Mapper<'a> {
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
return None;
}
if self.count >= self.max_nodes {
if self.count >= self.budget() {
let max = self.max_nodes;
self.warn(format!("the file has more than {max} layers; the rest were skipped"));
return None;
@@ -877,7 +890,7 @@ impl<'a> Mapper<'a> {
let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view);
// Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector.
let connector_terminal =
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.max_nodes);
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget());
// A supported connector snapshot has its own note below instead of the generic stand-in one.
let stand = stand_in(own).filter(|_| !connector_snapshot);
let ty = match own {
@@ -1025,7 +1038,7 @@ impl<'a> Mapper<'a> {
fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option<Value> {
let shape = v.str("shapeWithTextType").unwrap_or("SQUARE");
let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty();
if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes {
if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.budget() {
return None;
}
let mut s = Obj::new();
@@ -1057,7 +1070,7 @@ impl<'a> Mapper<'a> {
/// layer filling it, inset a little; centred unless it's a note.
fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option<Value> {
let chars = own_text(v)?;
if self.count >= self.max_nodes {
if self.count >= self.budget() {
return None;
}
self.count += 1;
@@ -1192,7 +1205,7 @@ impl<'a> Mapper<'a> {
out.insert("opacity".into(), json!(root_opacity * line_opacity));
}
if connector_has_saved_label(v) {
let reason = if self.count >= self.max_nodes { "node budget" } else { "depth limit" };
let reason = if self.count >= self.budget() { "node budget" } else { "depth limit" };
self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children"));
}
}
@@ -1222,7 +1235,7 @@ impl<'a> Mapper<'a> {
/// Snapshot the straight, manual connector contract carried by its generated children.
fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec<Value> {
if self.count >= self.max_nodes {
if self.count >= self.budget() {
self.warn(format!("the file has more than {} layers; connector snapshot children were skipped", self.max_nodes));
return Vec::new();
}
@@ -1285,7 +1298,7 @@ impl<'a> Mapper<'a> {
};
let background_view = View::of(background).with([background_style]);
if !background_view.list("fillPaints").is_empty() {
if self.count >= self.max_nodes {
if self.count >= self.budget() {
self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes));
} else {
let mut rect = Obj::new();
@@ -1313,7 +1326,7 @@ impl<'a> Mapper<'a> {
transform: Affine,
view: &View<'a>,
) {
if self.count >= self.max_nodes {
if self.count >= self.budget() {
self.warn(format!("the file has more than {} layers; connector label was skipped", self.max_nodes));
return;
}
+4 -3
View File
@@ -770,7 +770,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
"nextOffset": next,
})),
);
if window && next.is_none() && offset == 0 && used == depth {
if window && next.is_none() && offset == 0 && used == depth && used_wire + MARKER_RESERVE <= budget {
// Nothing was left out after all (an offset of zero that fitted whole).
out.as_object_mut().map(|o| o.remove("truncated"));
}
@@ -792,8 +792,9 @@ fn children_window(value: &mut Value, offset: usize, budget: usize) -> bool {
if fits(value, rest.len()) {
return rest.len() != all.len();
}
// The largest window that fits, between none and all of the rest.
let (mut lo, mut hi) = (0usize, rest.len());
// The largest window that fits, but at least one child, so paging moves on past a child too
// large to fit (the marker then says so).
let (mut lo, mut hi) = (usize::from(!rest.is_empty()), rest.len());
while lo < hi {
let mid = lo + (hi - lo).div_ceil(2);
if fits(value, mid) { lo = mid } else { hi = mid - 1 }
@@ -152,3 +152,26 @@ fn get_node(c: &mut Client, args: Value) -> (Value, usize) {
let wire = serde_json::to_string(&text).unwrap().len() + ENVELOPE;
(serde_json::from_str(text).expect("the block is JSON"), wire)
}
/// A child too large for the limit on its own is still shown, one per page, so paging moves on,
/// and the marker says the answer was cut.
#[test]
fn children_too_large_to_fit_page_one_at_a_time() {
let mut c = Client::new();
let frame = c.create(json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 400}));
for i in 0..3 {
c.create(json!({"type": "rectangle", "parent": frame, "x": i * 5, "y": 5, "width": 20, "height": 20, "name": "n".repeat(3000)}));
}
let mut offset = Some(0u64);
let mut pages = 0;
while let Some(at) = offset {
let (page, _) = get_node(&mut c, json!({"id": frame, "depth": 1, "maxBytes": 1024, "offset": at}));
assert_eq!(page["children"].as_array().map(Vec::len), Some(1), "{}", page["truncated"]);
assert!(!page["truncated"].is_null(), "the marker stays: page at {at}");
offset = page["truncated"]["children"]["nextOffset"].as_u64();
assert!(offset.is_none_or(|o| o > at), "paging moves on from {at}");
pages += 1;
assert!(pages <= 3);
}
assert_eq!(pages, 3);
}
+4 -4
View File
@@ -173,7 +173,7 @@ name = "Polski"
"Both directions" = "W obu kierunkach"
"Bottom" = "Dół"
"Bottom left radius" = "Promień lewego dolnego narożnika"
"Bottom padding" = "Margines dolny"
"Bottom padding" = "Margines wewnętrzny dolny"
"Bottom right radius" = "Promień prawego dolnego narożnika"
"Branch" = "Gałąź"
"Branch archived" = "Gałąź zarchiwizowana"
@@ -714,7 +714,7 @@ name = "Polski"
"Leaves" = "Opuszcza"
"Left" = "Do lewej"
"Left & right" = "Lewa i prawa"
"Left padding" = "Margines lewy"
"Left padding" = "Margines wewnętrzny lewy"
"Left side" = "Lewa strona"
"Left side width" = "Szerokość lewej strony"
"Letter case" = "Wielkość liter"
@@ -1156,7 +1156,7 @@ name = "Polski"
"Review and merge" = "Przejrzyj i scal"
"Ribbon" = "Wstążka"
"Right" = "Do prawej"
"Right padding" = "Margines prawy"
"Right padding" = "Margines wewnętrzny prawy"
"Right side" = "Prawa strona"
"Right side width" = "Szerokość prawej strony"
"Rotate 90°" = "Obróć o 90°"
@@ -1440,7 +1440,7 @@ name = "Polski"
"Top" = "Góra"
"Top & bottom" = "Góra i dół"
"Top left radius" = "Promień górnego lewego narożnika"
"Top padding" = "Margines górny"
"Top padding" = "Margines wewnętrzny górny"
"Top right radius" = "Promień górnego prawego narożnika"
"Transform" = "Przekształć"
"Triangle" = "Trójkąt"
+1 -1
View File
@@ -1417,7 +1417,7 @@ name = "Svenska"
"This condition is edited as an expression" = "Det här villkoret redigeras som ett uttryck"
"This file" = "Den här filen"
"This file has unsaved changes" = "Filen har osparade ändringar"
"This file is too large to join here (up to {mb} MB)" = "Den här filen är för stor för att läggas till här (högst {mb} MB)"
"This file is too large to join here (up to {mb} MB)" = "Den här filen är för stor för att ansluta till här (högst {mb} MB)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Den här filen använder typsnitt som inte är installerade på den här datorn. Deras text ritas med ett reservtypsnitt tills du installerar dem."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Filen ligger på {server} och är bara öppen för dig. Dela den för att släppa in andra med en ny kod."
"This frame is new on the branch." = "Den här framen är ny på grenen."
+2 -1
View File
@@ -1,6 +1,6 @@
# Localization parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; macOS finds its downloadable CJK system fonts (#406); Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; macOS finds its downloadable CJK system fonts (#406); Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
@@ -122,6 +122,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see
| Date | Change | Summary |
|---|---|---|
| 2026-10-24 | minor | Vietnamese (`vi`) draft catalog (#1026), assessed as a machine draft awaiting review like the others; string counts re-measured: 2,352 entries. |
| 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 |
+1 -1
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (M12 Boards; Adobe Fonts found on macOS (#298); 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-24 · **Change:** minor (M12 Boards; Adobe Fonts found on macOS (#298); community batch #753: Selection colors, smart-selection spacing, a Present tab; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
+1 -1
View File
@@ -17,7 +17,7 @@ Simplified.
The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay
`es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese
(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish
(`fi`), Polish (`pl`), Bengali (`bn`) and Italian (`it`), machine-drafted and waiting for native speakers' review. Georgian
(`fi`), Polish (`pl`), Bengali (`bn`), Italian (`it`) and Vietnamese (`vi`), machine-drafted and waiting for native speakers' review. Georgian
(`ka`) was written with AI assistance and reviewed by a native speaker, and stays a draft until a
maintainer changes its status. Russian (`ru`, Русский) was
reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar