diff --git a/Cargo.lock b/Cargo.lock index 05b10e59..aead39d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1721,6 +1721,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 5bb20905..8cadb013 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -69,9 +69,9 @@ impl eframe::App for WebShell { } } -/// Browser services. File dialogs return nothing for now (the trait's defaults). Copies go to the -/// system clipboard through egui's output; pastes arrive as paste events with their text (a page -/// can't read the clipboard when it likes). +/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved +/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste +/// events with their text (a page can't read the clipboard when it likes). struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, @@ -79,6 +79,19 @@ struct WebServices { } 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 clipboard_set_text(&mut self, s: &str) { figcraft_ui_egui::egui_copy_text(&self.ctx, s); } @@ -116,3 +129,32 @@ impl Services for WebServices { held } } + +fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { + 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, &{ + 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(); + // 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; diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 3a94bd1b..9a6479e7 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -301,6 +301,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." "Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}" "Count" = "Anzahl" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 83fb0c19..a6de7862 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -310,6 +310,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." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" "Count" = "Count" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index c3b6c213..a77033fe 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 725a36a9..6fe0d7da 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -301,6 +301,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." "Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}" "Count" = "Nombre" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 175f74d8..61f80ab8 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -300,6 +300,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." "Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}" "Count" = "Quantidade" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4d75b4e4..28e594c8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -159,6 +159,8 @@ mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod variables_export_ui_tests; +#[cfg(test)] mod variables_rename_tests; #[cfg(test)] mod vector_tail_ui_tests; @@ -190,6 +192,15 @@ pub trait Services { fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture { Box::pin(std::future::ready(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) -> DialogFuture { Box::pin(std::future::ready(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 230e1f33..667b272e 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -204,9 +204,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 format = format.to_owned(); let ext = format.clone(); app.pick_save(tr("Export variables"), &name, &[&ext], move |app, p| { 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 |