diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 01cfe8e88..c1f9811a8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -198,6 +198,8 @@ mod open_issues_tests; #[cfg(test)] mod padding_individual_tests; #[cfg(test)] +mod page_variable_modes_tests; +#[cfg(test)] mod palette_tests; #[cfg(test)] mod panel_remove_tests; diff --git a/crates/ui-egui/src/page_variable_modes_tests.rs b/crates/ui-egui/src/page_variable_modes_tests.rs new file mode 100644 index 000000000..b3dc0be4d --- /dev/null +++ b/crates/ui-egui/src/page_variable_modes_tests.rs @@ -0,0 +1,52 @@ +//! The Design panel's Variable modes section for the page, driven headless: with nothing +//! selected a collection with several modes lists them, as Figma's Page section does. + +use egui::{Event, Modifiers, PointerButton, Pos2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; + +/// A click at `p`, and the frames that follow; what the last frame drew. +fn click(u: &mut Ui, p: Pos2) -> Vec { + for _ in 0..5 { + u.frame(vec![Event::PointerMoved(p)]); + } + let mut shapes = Vec::new(); + for pressed in [Some(true), Some(false)].into_iter().chain(std::iter::repeat_n(None, 20)) { + let events = pressed.map(|pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + shapes = flat(&u.frame_output(events.into_iter().collect()).shapes); + } + shapes +} + +fn scene(modes: &[&str]) -> Ui { + let mut s = Session::new(); + s.execute("variable.createCollection", &json!({"name": "Theme", "modes": modes})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + Ui::new(s) +} + +#[test] +fn with_nothing_selected_the_page_shows_its_variable_modes() { + let mut u = scene(&["Light", "Dark"]); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(crate::live_tests::texts(&shapes).iter().any(|(t, ..)| t == crate::i18n::tr("Variable modes")), "the section shows"); + let history = u.app.session.undo_depth().0; + let menu = click(&mut u, text_center(&shapes, "Auto (Light)")); + click(&mut u, text_center(&menu, " Dark")); + let page = u.app.session.page().unwrap(); + let doc = u.app.session.doc().unwrap(); + let coll = doc.variables.collections[0].clone(); + let dark = coll.modes.iter().find(|m| m.name == "Dark").unwrap().id.clone(); + assert_eq!(doc.node(page).unwrap().var_modes.get(&coll.id), Some(&dark)); + assert_eq!(u.app.session.undo_depth().0, history + 1, "one undo step"); +} + +#[test] +fn the_page_section_has_no_variable_modes_without_a_multi_mode_collection() { + let mut u = scene(&["Only"]); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(!crate::live_tests::texts(&shapes).iter().any(|(t, ..)| t == crate::i18n::tr("Variable modes"))); +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 3c64f665c..75aa10e0e 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -37,7 +37,7 @@ pub(crate) use menu::menu_row_width; pub(crate) use menu::preview_blend; pub use menu::{blend_menu, context_popup, menu_check, menu_icon_item, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row}; pub(crate) use menu::{block, menu_row_lit, menu_style, row, row_rect}; -use paints::{appearance, paints, variable_modes}; +use paints::{appearance, page_variable_modes, paints, variable_modes}; pub(crate) use stroke::{CAP_ART_W, cap_art, join_label}; /// Common value of `f` across `ids` (None when mixed or empty). @@ -374,6 +374,9 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { } } widgets::separator(ui); + if page_variable_modes(app, ui, doc, page) { + widgets::separator(ui); + } widgets::section_header(ui, tr("Color profile"), &[], false); row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, doc.color_profile.label(), 140.0, None); diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index df1ca5ee0..99c6ecb3c 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -158,6 +158,17 @@ pub(super) fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: if !crate::selection_cache::every(doc, sel, |n| n.kind.is_frame_like()) { return false; } + mode_section(app, ui, doc, sel, false) +} + +/// The same section for the current page, with nothing selected: Auto there is the collection's +/// default mode, and a pick sets the page's own (`variable.setMode` without layers). +pub(super) fn page_variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) -> bool { + mode_section(app, ui, doc, &[page], true) +} + +/// The Variable modes section of `sel` (the page when `on_page`): one row per multi-mode collection. +fn mode_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], on_page: bool) -> bool { let libs = crate::variable_ui::libraries(app, doc); let rows = crate::variable_ui::mode_rows(doc, &libs); if rows.is_empty() { @@ -217,7 +228,7 @@ pub(super) fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: } } if let Some(mode) = pick { - set_variable_mode(app, sel, r, mode); + set_variable_mode(app, if on_page { &[] } else { sel }, r, mode); ui.close(); } }); @@ -227,15 +238,23 @@ pub(super) fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: true } -/// Set `sel`'s mode of the collection of `row` (`mode` null: Auto). A library collection goes +/// Set `sel`'s mode of the collection of `row` (`mode` null: Auto; no `sel`: the page's). A library collection goes /// through `library.setVariableMode` (copying it in); Auto on one never used here is already so. fn set_variable_mode(app: &mut FigApp, sel: &[NodeId], row: &crate::variable_ui::ModeRow, mode: Value) { match (row.library, row.key, row.local) { (Some((lib, _)), Some(key), _) if !mode.is_null() => { - app.run("library.setVariableMode", json!({"library": lib, "collection": key, "ids": sel, "mode": mode})); + let mut p = json!({"library": lib, "collection": key, "mode": mode}); + if !sel.is_empty() { + p["ids"] = json!(sel); + } + app.run("library.setVariableMode", p); } (_, _, Some(local)) => { - app.run("variable.setMode", json!({"ids": sel, "collection": local, "mode": mode})); + let mut p = json!({"collection": local, "mode": mode}); + if !sel.is_empty() { + p["ids"] = json!(sel); + } + app.run("variable.setMode", p); } _ => {} } diff --git a/crates/ui-egui/src/variables_rename_tests.rs b/crates/ui-egui/src/variables_rename_tests.rs index 0c97a84ea..353be6594 100644 --- a/crates/ui-egui/src/variables_rename_tests.rs +++ b/crates/ui-egui/src/variables_rename_tests.rs @@ -100,7 +100,9 @@ fn a_collection_of_this_file_extends_from_its_context_menu() { u.frame(vec![]); } let shapes = flat(&u.frame_output(vec![]).shapes); - click_with(&mut u, text_center(&shapes, "Theme"), PointerButton::Secondary); + // The window's row: the Design panel's Page section names the collection too (its modes), under it. + let (_, at, size) = crate::live_tests::texts(&shapes).into_iter().rfind(|(t, ..)| t == "Theme").expect("the collection's row"); + click_with(&mut u, at + size / 2.0, PointerButton::Secondary); let menu = flat(&u.frame_output(vec![]).shapes); for item in ["Rename", "Extend collection", "Delete collection"] { assert!(has_text(&menu, item), "{item} in the menu"); diff --git a/docs/components-parity.md b/docs/components-parity.md index c228aa94d..3d4fa7ddf 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-11-07 · **Change:** minor (a text style binds a number variable to its weight (#2174); an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (pages take a variable mode: with nothing selected the Design panel's Page section shows a Variable modes section, Auto or a mode of each collection that has more than one, as the help center says ("Apply variable mode" in the Page section of the right sidebar) (#2215); a text style binds a number variable to its weight (#2174); an instance lists only the properties its variant's layers use and long property names are cut to one line (#2022); with several layers selected the Design panel header's component button offers Create multiple components (#2088); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -71,7 +71,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values and colour calculations reading it keep the colour they show | | Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name, SwiftUI the iOS one and Compose the Android one (a variable without one keeps its value there); in the variables JSON; ArkUI and Flutter have no platform of their own and write values | | Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field, an effect's only where its effect has the field (as in effect styles); glass fields too (light angle, intensity, refraction, depth, dispersion, frost and splay, percentages as the Design panel shows them, a light angle past ±180 turned round); number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | -| Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there | +| Explicit modes per frame and per page (Page section), library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | this file's collections (Extend collection on a collection's row; following its edits at once, chains included) and 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; `variableCollections` and `variables` read under `meta` or at the top level, a warning when no variables are found or fewer than the collections list); CSS custom properties and Dart constants (Flutter) export; a variables file named by path must be a regular file of at most 256 MiB; in the browser the export arrives as a download (import there still needs a file picker) | @@ -82,6 +82,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| | 2026-11-07 | minor | M3, #2082: Edit variables in the context menu of a selection of several local variables opens the reference's compact editor with two tabs: Details holds only Hide from publishing, Scope the scopes every selected variable's type has (All supported properties and one box per scope; a selection that shares none says so). Each box is checked when all selected variables have it, empty when none, and shows a dash when they differ; a click sets it on all (or clears it when checked on all), each change one `command.batch`, so one undo step. Names, descriptions and code syntax stay single-variable, as in the reference. The Scope: menu entries stay. A tri-state `widgets::checkbox_tri` draws the dash. Tests: `variables_select_tests` (headless UI). Grade unchanged | +| 2026-11-12 | minor | M7, #2215: pages take a variable mode: with nothing selected the Design panel's Page section shows a Variable modes section, Auto or a mode of each collection that has more than one, as the help center says ("Apply variable mode" in the Page section of the right sidebar) (#2215). The same section the frames' Variable modes use (`variable.setMode` without layers sets the page's mode), one undo step per pick. Binding the page background to a variable is not in the reference and is not offered | | 2026-11-10 | minor | M7, #2174: a text style binds a number variable to its weight (`style.bind {field: "fontWeight"}`): the style takes the face nearest the number and sets the `wght` axis, so static and variable fonts both follow; a face-name (string) binding and a weight binding replace each other, and the weight wins in a file carrying both. The style editor's weight menu offers number variables beside string ones. A number bound to a line height is applied in pixels, as the reference tool does, which a test now pins. Numbers unchanged. Tests: `style_vars_tests`, `style_picker::tests`. | | 2026-11-07 | patch | M3, #2088: with two or more layers selected the Design panel header's component button opens a menu with Create component, Create multiple components and Create component set (`component.createSet`, naming measured in Figma), as Figma's does; one layer keeps the plain button. Tests: `create_multiple_menu_tests`, `components_behaviour_tests::create_multiple_components_keeps_names_and_places_and_is_one_undo_step`, `components_behaviour_tests::create_set_*`. Grade unchanged. | | 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |