From 01a0f4f332092c4fdb16961c68e4f92295600a5b Mon Sep 17 00:00:00 2001 From: Mateus Villain Date: Wed, 14 Oct 2026 20:01:53 -0300 Subject: [PATCH 1/2] M3: variables export as a download in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web build has no save dialogs, so Export… ▸ Variables JSON / CSS custom properties returned silently. Services gains downloads() and download(name, bytes); the browser hands the file over through a Blob link, and the variables panel uses it where the platform saves by downloads. --- Cargo.lock | 1 + apps/figcraft-web/Cargo.toml | 3 +- apps/figcraft-web/src/web.rs | 36 ++++++- crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/src/lib.rs | 11 +++ .../ui-egui/src/variables_export_ui_tests.rs | 93 +++++++++++++++++++ crates/ui-egui/src/variables_panel.rs | 15 ++- docs/components-parity.md | 5 +- 12 files changed, 162 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/variables_export_ui_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 5e9c6f68..02c221f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1718,6 +1718,7 @@ dependencies = [ "figcraft-collab-net", "figcraft-engine", "figcraft-ui-egui", + "js-sys", "log", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 93f9eb41..2f1a8190 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -24,5 +24,6 @@ log = { workspace = true } # `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback. eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" +js-sys = "0.3" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 63689038..bb563422 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -69,14 +69,27 @@ impl eframe::App for WebShell { } } -/// Browser services. File dialogs return nothing for now (the trait's defaults); the clipboard -/// goes through egui's own web clipboard handling. +/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved +/// as downloads; the clipboard goes through egui's own web clipboard handling. struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, } impl Services for WebServices { + fn downloads(&self) -> bool { + true + } + /// A Blob behind a temporary `` link, clicked and removed. + fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + match download(name, bytes) { + Ok(()) => true, + Err(e) => { + log::error!("couldn't download {name}: {e:?}"); + false + } + } + } fn open_url(&mut self, url: &str) { let Some(window) = web_sys::window() else { return }; if let Err(e) = window.open_with_url_and_target(url, "_blank") { @@ -108,3 +121,22 @@ impl Services for WebServices { held } } + +fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { + let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let body = document.body().ok_or("no body")?; + let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{ + let o = web_sys::BlobPropertyBag::new(); + o.set_type("application/octet-stream"); + o + })?; + let url = web_sys::Url::create_object_url_with_blob(&blob)?; + let a: web_sys::HtmlAnchorElement = document.create_element("a")?.dyn_into()?; + a.set_href(&url); + a.set_download(name); + body.append_child(&a)?; + a.click(); + a.remove(); + web_sys::Url::revoke_object_url(&url) +} diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 7552cca3..4f38f7cb 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -300,6 +300,7 @@ status = "draft — needs native review" "Copy {value}" = "{value} kopieren" "Corner radius" = "Eckenradius" "Corner smoothing" = "Eckenglättung" +"Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." "Count" = "Anzahl" "Create" = "Erstellen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c35b1677..61fd1528 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -309,6 +309,7 @@ name = "English" "Copy {value}" = "Copy {value}" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" +"Couldn't download the variables" = "Couldn't download the variables" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." "Count" = "Count" "Create" = "Create" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 6514ac7b..a725bca5 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,7 @@ name = "Español" status = "draft — needs native review" [ui] +"Couldn't download the variables" = "No se pudieron descargar las variables" "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index c8665934..d5110c1b 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -300,6 +300,7 @@ status = "draft — needs native review" "Copy {value}" = "Copier {value}" "Corner radius" = "Rayon d’angle" "Corner smoothing" = "Lissage des angles" +"Couldn't download the variables" = "Impossible de télécharger les variables" "Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." "Count" = "Nombre" "Create" = "Créer" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index a692a8ce..4f6760e7 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -299,6 +299,7 @@ status = "draft — needs native review" "Copy {value}" = "Copiar {value}" "Corner radius" = "Raio do canto" "Corner smoothing" = "Suavização de cantos" +"Couldn't download the variables" = "Não foi possível baixar as variáveis" "Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra." "Count" = "Quantidade" "Create" = "Criar" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..b68279de 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -148,6 +148,8 @@ mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod variables_export_ui_tests; +#[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] mod windows_qa_tests; @@ -171,6 +173,15 @@ pub trait Services { fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option { None } + /// The platform saves files by handing them over as downloads (the browser), not through + /// save dialogs. + fn downloads(&self) -> bool { + false + } + /// Hand `bytes` over as a download named `name`; false when nothing was delivered. + fn download(&mut self, _name: &str, _bytes: &[u8]) -> bool { + false + } /// A folder to write several files into. fn pick_folder(&mut self, _title: &str) -> Option { None diff --git a/crates/ui-egui/src/variables_export_ui_tests.rs b/crates/ui-egui/src/variables_export_ui_tests.rs new file mode 100644 index 00000000..40a84393 --- /dev/null +++ b/crates/ui-egui/src/variables_export_ui_tests.rs @@ -0,0 +1,93 @@ +//! Variables ▸ Export… where the platform saves by downloads (the browser, which has no save +//! dialogs): the export arrives as a download instead of doing nothing. + +use std::sync::{Arc, Mutex}; + +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::variables_panel::export_dialog; +use crate::{FigApp, NoServices, Services}; + +type Downloads = Arc)>>>; + +/// Services that save by downloads and keep them (`deliver` false: the browser refused). +struct Browser { + got: Downloads, + deliver: bool, +} + +impl Services for Browser { + fn downloads(&self) -> bool { + true + } + fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + self.got.lock().unwrap().push((name.to_string(), bytes.to_vec())); + self.deliver + } +} + +fn session() -> Session { + let mut s = Session::new(); + for (cmd, p) in [ + ("variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})), + ( + "variable.create", + json!({"collection": "Theme", "name": "color/primary", "type": "color", "values": {"Light": "#0000ff", "Dark": "#ffffff"}}), + ), + ("variable.create", json!({"collection": "Theme", "name": "radius", "type": "number", "value": 4})), + ] { + s.execute(cmd, &p).unwrap(); + } + s +} + +fn browser(deliver: bool) -> (FigApp, Downloads) { + let got = Downloads::default(); + (FigApp::new(session(), Box::new(Browser { got: got.clone(), deliver })), got) +} + +fn toast(app: &FigApp) -> String { + app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +#[test] +fn json_export_downloads_what_variable_export_writes() { + let (mut app, got) = browser(true); + let expected = app.session.execute("variable.export", &json!({"format": "json"})).unwrap()["text"].as_str().unwrap().to_string(); + export_dialog(&mut app, "json"); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert!(name.ends_with(".variables.json"), "{name}"); + assert_eq!(String::from_utf8(bytes.clone()).unwrap(), expected); + let parsed: Value = serde_json::from_slice(bytes).unwrap(); + assert!(parsed.is_object()); + assert!(toast(&app).contains(name.as_str()), "{}", toast(&app)); +} + +#[test] +fn css_export_downloads_custom_properties() { + let (mut app, got) = browser(true); + export_dialog(&mut app, "css"); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert!(name.ends_with(".variables.css"), "{name}"); + let css = String::from_utf8(bytes.clone()).unwrap(); + assert!(css.contains(":root") && css.contains("--"), "{css}"); +} + +#[test] +fn a_refused_download_says_so() { + let (mut app, got) = browser(false); + export_dialog(&mut app, "json"); + assert_eq!(got.lock().unwrap().len(), 1); + assert_eq!(toast(&app), "Couldn't download the variables"); +} + +#[test] +fn platforms_with_save_dialogs_keep_them() { + // No dialog answer (cancelled) and no downloads: nothing happens, as before. + let mut app = FigApp::new(session(), Box::new(NoServices)); + export_dialog(&mut app, "json"); + assert!(app.ui.toast.is_none()); +} diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 240a51a5..7f0dc435 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -202,9 +202,20 @@ fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); } -/// Export this file's collections through a save dialog. -fn export_dialog(app: &mut FigApp, format: &str) { +/// Export this file's collections through a save dialog, or as a download where the platform has +/// no save dialogs (the browser). +pub(crate) fn export_dialog(app: &mut FigApp, format: &str) { let name = app.session.doc().map(|d| format!("{}.variables.{format}", d.name)).unwrap_or_else(|_| format!("variables.{format}")); + if app.services.downloads() { + let Some(r) = app.run("variable.export", json!({"format": format})) else { return }; + let text = r["text"].as_str().unwrap_or_default(); + if app.services.download(&name, text.as_bytes()) { + app.toast(trf("Exported {file}", &[("file", &name)])); + } else { + app.toast(tr("Couldn't download the variables")); + } + return; + } let Some(p) = app.services.pick_save(tr("Export variables"), &name, &[format]) else { return }; if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() { let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); diff --git a/docs/components-parity.md b/docs/components-parity.md index 46a012fe..659676fd 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-13 · **Change:** minor (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-14 · **Change:** minor (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 @@ -69,13 +69,14 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Explicit modes per frame, library collections' modes | done | | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | -| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export | +| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) | | Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. | | 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. | | 2026-10-13 | minor | Variables inside styles (M3): a shared style can bind variables to fields of its value (`style.bind {style, field, variable?}`: a paint style's colours and gradient stops, an effect style's colour, radius, spread and offsets, a grid style's size, count, gutter and offset, a text style's font size, line height, letter spacing, family and style). Every layer using the style shows those fields resolved in its own modes and stays linked when a variable or a mode changes; the style keeps them resolved in the default modes. A style made from a layer keeps that layer's bindings; a new value drops bindings to what is gone. The style editor binds from its property rows (paint colour, shadow colour, blur radius, grid size) and detaches from the variable's chip; the style picker marks styles that use variables. `.fig` export and import carry the bindings on the style, REST layers hand theirs to the style they use. Left: binding text style fields and the other effect and grid fields from the editor (commands only), library variables in the picker, a style's library copy. Numbers unchanged. | | 2026-10-12 | minor | Layers and Assets panels behaviour suite: Assets folders by page, frame and slash names; a leading `.` or `_` keeps a component out of the library even after it was published, and removing it brings the component back | From dd84097b6d3611f6aeefbf01f5156f68e8f71021 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 15 Oct 2026 19:04:16 +0700 Subject: [PATCH 2/2] #368: revoke a download's object URL after a delay Revoking it right after the click can cancel the download in some browsers; it now goes 40 s later on a timer. --- apps/figcraft-web/src/web.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 43ec2cf2..8cadb013 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -131,7 +131,8 @@ impl Services for WebServices { } fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { - let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?; + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; let body = document.body().ok_or("no body")?; let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes)); let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &{ @@ -146,5 +147,14 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { body.append_child(&a)?; a.click(); a.remove(); - web_sys::Url::revoke_object_url(&url) + // Revoked later, not at once: some browsers cancel a download whose URL goes before it starts. + let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = web_sys::Url::revoke_object_url(&url); + }); + // Without the timer the URL just stays until the page closes. + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), REVOKE_DOWNLOAD_MS); + Ok(()) } + +/// How long a download's object URL is kept (milliseconds). +const REVOKE_DOWNLOAD_MS: i32 = 40_000;