mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
Merge #1281
This commit is contained in:
@@ -704,9 +704,24 @@ fn grid_label(g: &figcraft_doc::LayoutGrid) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The chip in `rect` of the variable `name` bound to `field` of text style `style`. A click on it
|
||||
/// opens the variable picker to change it, and its detach button unbinds it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn text_binding(ui: &mut Ui, style: &str, field: &str, kind: VariableType, rect: Rect, name: &str, lead: widgets::Lead, out: &mut Frame) {
|
||||
match widgets::bound_field(ui, Id::new(("ts_bound", style, field)), lead, rect, name) {
|
||||
Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, style, field, kind, at),
|
||||
Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(style.to_string(), field.to_string())),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A text style's typography in the editor: a sample, then font, style, size, line height and
|
||||
/// letter spacing; a change redefines the style (every linked char follows).
|
||||
fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) {
|
||||
/// letter spacing; a change redefines the style (every linked char follows). Each field takes a
|
||||
/// variable: the font and style menus start with Apply variable, the other fields show a variable
|
||||
/// button on hover, and a bound field shows its variable instead of its value.
|
||||
fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, out: &mut Frame) {
|
||||
let id = style.id.as_str();
|
||||
let bound = |field: &str| style.bound.get(field).map(|v| doc.variables.get(v).map_or_else(|| v.clone(), |x| x.name.clone()));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(W, 40.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), Color32::from_white_alpha(14));
|
||||
@@ -726,45 +741,112 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) {
|
||||
// The dropdowns' menus open outside the dark scope (they're regular popups).
|
||||
let (family_resp, style_resp) = crate::theme::on_dark(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(8.0, 6.0);
|
||||
let family_resp = widgets::dropdown(ui, &def.font_family, W, None).on_hover_text(tr("Font"));
|
||||
let family_resp = match bound("fontFamily") {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(W, widgets::FIELD_H), Sense::hover());
|
||||
text_binding(ui, id, "fontFamily", VariableType::String, r, &name, widgets::Lead::Named(tr("Font")), out);
|
||||
None
|
||||
}
|
||||
None => Some(widgets::dropdown(ui, &def.font_family, W, None).on_hover_text(tr("Font"))),
|
||||
};
|
||||
let mut style_resp = None;
|
||||
ui.horizontal(|ui| {
|
||||
style_resp = Some(widgets::dropdown(ui, &def.font_style, half, None).on_hover_text(tr("Font style")));
|
||||
if let Some(widgets::FieldEdit::Set(v) | widgets::FieldEdit::Scrub(v, true)) =
|
||||
widgets::number_field(ui, Id::new(("ts_size", id)), widgets::Lead::Text(tr("Size")), Some(def.font_size), "", half, 1.0)
|
||||
{
|
||||
match bound("fontStyle") {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(half, widgets::FIELD_H), Sense::hover());
|
||||
text_binding(ui, id, "fontStyle", VariableType::String, r, &name, widgets::Lead::Named(tr("Font style")), out);
|
||||
}
|
||||
None => style_resp = Some(widgets::dropdown(ui, &def.font_style, half, None).on_hover_text(tr("Font style"))),
|
||||
}
|
||||
let size = bound("fontSize");
|
||||
let (edit, ev) = widgets::number_field_bindable(
|
||||
ui,
|
||||
Id::new(("ts_size", id)),
|
||||
widgets::Lead::Text(tr("Size")),
|
||||
Some(def.font_size),
|
||||
"",
|
||||
half,
|
||||
1.0,
|
||||
Some(size.as_deref()),
|
||||
widgets::Empty::Text(tr("Mixed")),
|
||||
);
|
||||
if let Some(widgets::FieldEdit::Set(v) | widgets::FieldEdit::Scrub(v, true)) = edit.and_then(|e| e.settled()) {
|
||||
set(out, &|d| d.font_size = v.clamp(1.0, 5000.0));
|
||||
}
|
||||
match ev {
|
||||
Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, id, "fontSize", VariableType::Number, at),
|
||||
Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(id.to_string(), "fontSize".to_string())),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("ts_lh", id)), &def.line_height.label(), half, tr("Line height"))
|
||||
&& let Some(lh) = crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| crate::typography::line_height_of(&v))
|
||||
{
|
||||
set(out, &|d| d.line_height = lh);
|
||||
let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H));
|
||||
match bound("lineHeight") {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
|
||||
text_binding(ui, id, "lineHeight", VariableType::Number, r, &name, widgets::Lead::Named(tr("Line height")), out);
|
||||
}
|
||||
None => {
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("ts_lh", id)), &def.line_height.label(), half, tr("Line height"))
|
||||
&& let Some(lh) =
|
||||
crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| crate::typography::line_height_of(&v))
|
||||
{
|
||||
set(out, &|d| d.line_height = lh);
|
||||
}
|
||||
if crate::variable_ui::variable_button(ui, area, Id::new(("ts_bind", id, "lineHeight"))) {
|
||||
crate::variable_ui::open_style(ui, id, "lineHeight", VariableType::Number, area.left_bottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing"))
|
||||
&& let Some(ls) =
|
||||
crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing)).and_then(|v| crate::typography::letter_spacing_of(&v))
|
||||
{
|
||||
set(out, &|d| d.letter_spacing = ls);
|
||||
let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H));
|
||||
match bound("letterSpacing") {
|
||||
Some(name) => {
|
||||
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
|
||||
text_binding(ui, id, "letterSpacing", VariableType::Number, r, &name, widgets::Lead::Named(tr("Letter spacing")), out);
|
||||
}
|
||||
None => {
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing"))
|
||||
&& let Some(ls) = crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing))
|
||||
.and_then(|v| crate::typography::letter_spacing_of(&v))
|
||||
{
|
||||
set(out, &|d| d.letter_spacing = ls);
|
||||
}
|
||||
if crate::variable_ui::variable_button(ui, area, Id::new(("ts_bind", id, "letterSpacing"))) {
|
||||
crate::variable_ui::open_style(ui, id, "letterSpacing", VariableType::Number, area.left_bottom());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
(family_resp, style_resp)
|
||||
});
|
||||
crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked {
|
||||
set(out, &|d| {
|
||||
d.font_family = f.clone();
|
||||
let styles = figcraft_text::family_styles(&f);
|
||||
if !styles.is_empty() && !styles.contains(&d.font_style) {
|
||||
d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default());
|
||||
}
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if let Some(family_resp) = family_resp {
|
||||
crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if crate::right::menu_item(ui, tr("Apply variable"), "") {
|
||||
crate::variable_ui::open_style(ui, id, "fontFamily", VariableType::String, family_resp.rect.left_bottom());
|
||||
ui.close();
|
||||
return;
|
||||
}
|
||||
ui.separator();
|
||||
if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked {
|
||||
set(out, &|d| {
|
||||
d.font_family = f.clone();
|
||||
let styles = figcraft_text::family_styles(&f);
|
||||
if !styles.is_empty() && !styles.contains(&d.font_style) {
|
||||
d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default());
|
||||
}
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(sr) = style_resp {
|
||||
crate::right::menu_popup(&sr).width(200.0).show(|ui| {
|
||||
if crate::right::menu_item(ui, tr("Apply variable"), "") {
|
||||
crate::variable_ui::open_style(ui, id, "fontStyle", VariableType::String, sr.rect.left_bottom());
|
||||
ui.close();
|
||||
return;
|
||||
}
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
for s in figcraft_text::family_styles(&def.font_family) {
|
||||
let mark = if def.font_style == s { "✓ " } else { " " };
|
||||
@@ -842,7 +924,7 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {
|
||||
binding_cell(ui, doc, &style, &format!("layoutGrids/{i}/size"), figcraft_doc::VariableType::Number, out);
|
||||
}
|
||||
}
|
||||
StyleKind::Text { style: def } => text_properties(ui, &style.id, def, out),
|
||||
StyleKind::Text { style: def } => text_properties(ui, doc, &style, def, out),
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
|
||||
@@ -248,7 +248,15 @@ fn commit_text_field(def: &TextStyle, field: &str, typed: &str) -> Option<TextSt
|
||||
let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 400.0))), events: vec![enter], ..Default::default() };
|
||||
let mut f = Frame { act: None, hover: None, budget: 0 };
|
||||
let mut out = ctx.run_ui(input, |ui| text_properties(ui, "S", def, &mut f));
|
||||
let doc = Document::new("d");
|
||||
let style = Style {
|
||||
id: "S".into(),
|
||||
name: "S".into(),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Text { style: def.clone() },
|
||||
bound: Default::default(),
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| text_properties(ui, &doc, &style, def, &mut f));
|
||||
out.textures_delta.clear();
|
||||
match f.act {
|
||||
Some(Act::SetText(id, d)) if id == "S" => Some(d),
|
||||
@@ -276,3 +284,162 @@ fn text_style_editor_commits_line_height_and_letter_spacing() {
|
||||
assert_eq!(TextStyle { line_height: def.line_height, ..d }, def);
|
||||
assert_eq!(lh(&def, "abc"), None);
|
||||
}
|
||||
|
||||
/// A session with the text style "Body" (Inter Regular 14) and number and string variables to
|
||||
/// bind to its fields; `bind` binds them all first.
|
||||
fn text_variables_session(bind: bool) -> crate::layers_drag_tests::Ui {
|
||||
let mut s = Session::new();
|
||||
s.execute("style.create", &json!({"type": "text", "name": "Body", "value": {"fontFamily": "Inter", "fontStyle": "Regular", "fontSize": 14}}))
|
||||
.unwrap();
|
||||
s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Base"]})).unwrap();
|
||||
for (name, ty, value) in [
|
||||
("Body size", "number", json!(18)),
|
||||
("Body leading", "number", json!(24)),
|
||||
("Body tracking", "number", json!(1)),
|
||||
("Body font", "string", json!("Inter")),
|
||||
("Body weight", "string", json!("Bold")),
|
||||
] {
|
||||
s.execute("variable.create", &json!({"collection": "Type", "name": name, "type": ty, "values": {"Base": value}})).unwrap();
|
||||
}
|
||||
if bind {
|
||||
for (field, var) in [
|
||||
("fontSize", "Body size"),
|
||||
("lineHeight", "Body leading"),
|
||||
("letterSpacing", "Body tracking"),
|
||||
("fontFamily", "Body font"),
|
||||
("fontStyle", "Body weight"),
|
||||
] {
|
||||
s.execute("style.bind", &json!({"style": "Body", "field": field, "variable": var})).unwrap();
|
||||
}
|
||||
}
|
||||
let mut u = crate::layers_drag_tests::Ui::new(s);
|
||||
open_for_selection(&mut u.app, StyleSlot::Text, Some("Body"));
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert!(u.app.ui.style_picker.as_ref().is_some_and(|p| p.edit.is_some()), "the text style editor is open");
|
||||
u
|
||||
}
|
||||
|
||||
/// The left edge of the style editor's content (its "Edit style" title starts 30 px in).
|
||||
fn editor_left(u: &mut crate::layers_drag_tests::Ui) -> f32 {
|
||||
let mut left = None;
|
||||
for c in u.frame_output(vec![]).shapes {
|
||||
if let egui::Shape::Text(t) = &c.shape
|
||||
&& t.galley.text() == "Edit style"
|
||||
{
|
||||
left = Some(t.pos.x - 30.0);
|
||||
}
|
||||
}
|
||||
left.expect("the editor's title")
|
||||
}
|
||||
|
||||
/// The texts drawn in one frame, with their centers.
|
||||
fn drawn_texts(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, Pos2)> {
|
||||
fn walk(shape: &egui::Shape, out: &mut Vec<(String, Pos2)>) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.size() / 2.0)),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for c in u.frame_output(vec![]).shapes {
|
||||
walk(&c.shape, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn text_at(texts: &[(String, Pos2)], s: &str) -> Pos2 {
|
||||
texts
|
||||
.iter()
|
||||
.find(|(t, _)| t == s)
|
||||
.map(|(_, p)| *p)
|
||||
.unwrap_or_else(|| panic!("no text {s:?} in {:?}", texts.iter().map(|t| &t.0).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
/// Hover `p` (variable buttons show on hover), then press and release there.
|
||||
fn click(u: &mut crate::layers_drag_tests::Ui, p: Pos2) {
|
||||
u.frame(vec![egui::Event::PointerMoved(p)]);
|
||||
u.button(p, true);
|
||||
u.button(p, false);
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
||||
/// The name of the variable on `field` of the style "Body".
|
||||
fn bound_to(u: &crate::layers_drag_tests::Ui, field: &str) -> Option<String> {
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
doc.style("Body").unwrap().bound.get(field).map(|v| doc.variables.get(v).map_or_else(|| v.clone(), |x| x.name.clone()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_editor_shows_bound_fields_as_variables_and_detaches_them() {
|
||||
let mut u = text_variables_session(true);
|
||||
let left = editor_left(&mut u);
|
||||
let texts = drawn_texts(&mut u);
|
||||
let half = (W - 8.0) / 2.0;
|
||||
// (field, variable shown in its place, right edge of the field: full width or half)
|
||||
let fields = [
|
||||
("fontFamily", "Body font", W),
|
||||
("fontStyle", "Body weight", half),
|
||||
("fontSize", "Body size", W),
|
||||
("lineHeight", "Body leading", half),
|
||||
("letterSpacing", "Body tracking", W),
|
||||
];
|
||||
let at: Vec<f32> = fields.iter().map(|(_, name, _)| text_at(&texts, name).y).collect();
|
||||
for ((field, name, right), y) in fields.iter().zip(at) {
|
||||
assert_eq!(bound_to(&u, field).as_deref(), Some(*name));
|
||||
// The chip's detach button sits at its right end.
|
||||
click(&mut u, Pos2::new(left + right - 11.0, y));
|
||||
assert_eq!(bound_to(&u, field), None, "{field} is detached");
|
||||
}
|
||||
assert!(u.app.ui.style_picker.is_some(), "the editor stays open");
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_editor_binds_variables_from_its_fields() {
|
||||
let mut u = text_variables_session(false);
|
||||
let left = editor_left(&mut u);
|
||||
let texts = drawn_texts(&mut u);
|
||||
let half = (W - 8.0) / 2.0;
|
||||
let size_row = text_at(&texts, "Size").y;
|
||||
let spacing = {
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let Some(StyleKind::Text { style: def }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") };
|
||||
def.letter_spacing.label()
|
||||
};
|
||||
let in_editor: Vec<_> = texts.iter().filter(|(_, p)| p.x > left && p.x < left + W).cloned().collect();
|
||||
let spacing_row = text_at(&in_editor, &spacing).y;
|
||||
// Size, line height and letter spacing: the variable button at the field's right end.
|
||||
for (field, name, x, y) in [
|
||||
("fontSize", "Body size", left + W - 11.0, size_row),
|
||||
("lineHeight", "Body leading", left + half - 11.0, spacing_row),
|
||||
("letterSpacing", "Body tracking", left + W - 11.0, spacing_row),
|
||||
] {
|
||||
click(&mut u, Pos2::new(x, y));
|
||||
assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens");
|
||||
let texts = drawn_texts(&mut u);
|
||||
assert!(!texts.iter().any(|(t, _)| t == "Body font"), "{field} offers number variables only");
|
||||
click(&mut u, text_at(&texts, name));
|
||||
assert_eq!(bound_to(&u, field).as_deref(), Some(name));
|
||||
}
|
||||
// Font and font style: Apply variable at the top of their menus.
|
||||
for (field, shown, name) in [("fontFamily", "Inter", "Body font"), ("fontStyle", "Regular", "Body weight")] {
|
||||
// The menu's label, not the same name in the Local styles list behind the editor.
|
||||
let texts: Vec<_> = drawn_texts(&mut u).into_iter().filter(|(_, p)| p.x > left && p.x < left + W).collect();
|
||||
click(&mut u, text_at(&texts, shown));
|
||||
let texts = drawn_texts(&mut u);
|
||||
click(&mut u, text_at(&texts, "Apply variable"));
|
||||
assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens");
|
||||
let texts = drawn_texts(&mut u);
|
||||
// "Body size" shows once, on the size field's chip: the picker doesn't list it.
|
||||
assert_eq!(texts.iter().filter(|(t, _)| t == "Body size").count(), 1, "{field} offers string variables only");
|
||||
click(&mut u, text_at(&texts, name));
|
||||
assert_eq!(bound_to(&u, field).as_deref(), Some(name));
|
||||
}
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let Some(StyleKind::Text { style: def }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") };
|
||||
assert_eq!((def.font_size, def.font_style.as_str()), (18.0, "Bold"), "the style takes the variables' values");
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (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-10-24 · **Change:** minor (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
|
||||
@@ -61,7 +61,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs` |
|
||||
| Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only |
|
||||
| Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) |
|
||||
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii and grid sizes, the rest through the command |
|
||||
| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command |
|
||||
| Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); scopes filter pickers only |
|
||||
| Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay |
|
||||
| Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values |
|
||||
@@ -77,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | Variables on a text style's fields from the style editor: the font and font style menus start with Apply variable (string variables), and the size, line height and letter spacing fields show a variable button on hover (number variables). A bound field shows its variable in place of the value; a click on it picks another variable and its detach button unbinds it (`style_picker_tests`). Left: the other effect and grid fields (commands only), library variables in the picker. Numbers unchanged. |
|
||||
| 2026-10-23 | minor | Instance-swap properties in the component playground: the Inspect tab's Playground shows a dropdown for each, with the same menu as the Design panel's (the property's preferred components first, then every component in the file). The component picked changes the preview and the code only, never the file. Left: slots in the playground. Numbers unchanged. |
|
||||
| 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. |
|
||||
| 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged |
|
||||
|
||||
Reference in New Issue
Block a user