diff --git a/crates/engine/src/al_behaviour_tests.rs b/crates/engine/src/al_behaviour_tests.rs index 4659a597..a7a4d1d7 100644 --- a/crates/engine/src/al_behaviour_tests.rs +++ b/crates/engine/src/al_behaviour_tests.rs @@ -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)); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 53f51cc2..d74b8dae 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1166,6 +1166,20 @@ fn size_param(cmd: &str, p: &Value) -> Result> { /// 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)>, name: &'a str, bytes: Vec, limit: usize) -> Result<()> { + let total = members.iter().map(|(_, b)| b.len()).sum::().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 { 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::>(), ["a.png", "b.png"]); + } +} diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index d0e95f80..b5889d8c 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -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) -> Result<()> { let mut unknown = Vec::new(); @@ -404,8 +416,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map 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 { let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?; diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index c0b8e891..18db85c8 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -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 { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index 16f4bbdc..fd35e440 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -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), } } diff --git a/crates/engine/src/devmode_behaviour_tests.rs b/crates/engine/src/devmode_behaviour_tests.rs index 3d85fdb1..c00c6e7e 100644 --- a/crates/engine/src/devmode_behaviour_tests.rs +++ b/crates/engine/src/devmode_behaviour_tests.rs @@ -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"); } diff --git a/crates/engine/src/tailwind.rs b/crates/engine/src/tailwind.rs index d52aa29c..70a562a9 100644 --- a/crates/engine/src/tailwind.rs +++ b/crates/engine/src/tailwind.rs @@ -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()) } diff --git a/crates/engine/src/tailwind_tests.rs b/crates/engine/src/tailwind_tests.rs index 224c4928..9e41fecc 100644 --- a/crates/engine/src/tailwind_tests.rs +++ b/crates/engine/src/tailwind_tests.rs @@ -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 = + 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"]); +} diff --git a/crates/format/src/fig/layer_cap_tests.rs b/crates/format/src/fig/layer_cap_tests.rs index e7bb769c..1b869ea2 100644 --- a/crates/format/src/fig/layer_cap_tests.rs +++ b/crates/format/src/fig/layer_cap_tests.rs @@ -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:?}"); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 91e01fbc..3ad61edb 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -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 = 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 = 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 { 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 { 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 { - 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; } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index e3014182..8cceb910 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -770,7 +770,7 @@ fn get_node(b: &mut dyn Backend, a: &Map) -> Result 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 } diff --git a/crates/mcp/tests/it/inspect_response_limit.rs b/crates/mcp/tests/it/inspect_response_limit.rs index 8e52514b..d3bd70a0 100644 --- a/crates/mcp/tests/it/inspect_response_limit.rs +++ b/crates/mcp/tests/it/inspect_response_limit.rs @@ -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); +} diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index ed5deb90..672e017a 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -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" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 37bbfe34..8a8010c4 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -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." diff --git a/docs/localization-parity.md b/docs/localization-parity.md index b9e869e1..82a084b6 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-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 | diff --git a/docs/roadmap.md b/docs/roadmap.md index e0920e1f..3425cbf9 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 (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 diff --git a/docs/translating.md b/docs/translating.md index 09d01038..2b382ce5 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -17,7 +17,7 @@ Simplified. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish -(`fi`), Polish (`pl`), 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