Browse / github.com/storytold/figcraft / patch
M4: the Design panel's sections on the design's 240 pt grid
M4: the Design panel's sections on the design's 240 pt grid ## What The Design panel's sections now lay their rows out on one grid, taken from the design file (`Frame 29`, 240 pt wide): ``` 16 │ 88 │ 8 │ 88 │ 8 │ 24 │ 8 ``` - panel 240 pt, content **16 pt** in from the left and **8 pt** from the right (it was 16 / 16) - two field columns, 88 pt each at the default width, **8 pt gutters** and a **24 pt column** for the buttons at a row's end (flex tracks, so they share a wider panel) - rows **24 pt** in a 32 pt track, captions **9 pt** over them; section titles **11 pt regular** (were semibold) - a row's two buttons (eye, minus) are **one group on th
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- yelintong (imported from pull request #858, open)
- Base
43af145a12eacd1e87f5f5e6984c49b7da040288- Size
- +166 −85 in 14 files
- Review
- pending
- Shared
- 2026-10-28
- Id
9acca58b0a38e05f16c2ed0b
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/app.rs crates/ui-egui/src/field_tests.rs crates/ui-egui/src/panels/export_panel.rs crates/ui-egui/src/panels/layout_panel.rs crates/ui-egui/src/panels/right.rs crates/ui-egui/src/panels/right/effects.rs crates/ui-egui/src/panels/right/inspect.rs crates/ui-egui/src/panels/right/menu.rs crates/ui-egui/src/panels/right/paints.rs crates/ui-egui/src/panels/right/stroke.rs crates/ui-egui/src/panels/typography.rs crates/ui-egui/src/panels/typography/fields.rs crates/ui-egui/src/widgets.rs docs/ui-parity.md
@@ -307,7 +307,9 @@ impl FigApp { egui::Panel::right("right_panel") .resizable(false) .exact_size(right_w)- .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border)))+ // No stroke: a frame's stroke is part of its margin, which would take 1 pt off+ // each side of the panel (240 pt by default). The edge is drawn in `right_panel`.+ .frame(egui::Frame::NONE.fill(t.panel)) .show(ui, |ui| { let label = match self.ui.right_tab { RightTab::Design => i18n::tr("Design"),@@ -643,17 +643,19 @@ fn the_glass_light_intensity_field_has_a_label() { } /// A glass settings field's width in the Design panel at its default width.-const GLASS_FIELD_W: f32 = 98.5;+const GLASS_FIELD_W: f32 = 87.5; -/// The light intensity label this fix adds fits whole beside its value in every language (#256).-/// Other glass labels may end in "…" in some languages (es "Profundidad"); a longer-/// "Intensity" translation fails here.+/// The light intensity label this fix adds fits whole beside its value (#256): at the default+/// width the grid's column leaves 47.5 pt for it, and "Intensity" is 46.2. Longer translations+/// (de "Intensit\u00e4t", es "Intensidad") end in "…" — whole on hover — rather than run into the+/// value. #[test]-fn the_intensity_label_fits_whole_in_every_language() {+fn the_intensity_label_fits_whole_beside_its_value() { use crate::i18n::{self, Text}; let ctx = egui::Context::default(); let mut out = ctx.run_ui(Default::default(), |ui| { let width = |s: &str| ui.painter().layout_no_wrap(s.to_string(), crate::theme::regular(11.0), egui::Color32::WHITE).size().x;+ let mut english = 0.0; for lang in i18n::languages() { let label = lang.ui.iter().find(|(k, _)| *k == "Intensity").map_or("Intensity", |(_, t)| match t { Text::Plain(s) | Text::Plural { other: s, .. } => s,@@ -664,8 +666,14 @@ fn the_intensity_label_fits_whole_in_every_language() { continue; } let lead = crate::widgets::text_lead_width(ui, label, GLASS_FIELD_W);- assert!(lead >= 12.0 + width(label), "{}: {label:?} would end in \"…\"", lang.code);+ if lang.code == "en" {+ english = width(label);+ assert!(lead >= 8.0 + english, "en: {label:?} would end in \"…\"");+ }+ // The value keeps its room at the field's end whatever the label's length.+ assert!(lead <= GLASS_FIELD_W - 32.0, "{}: {label:?} leaves the value its room", lang.code); }+ assert!(english > 0.0, "the English label is in the table"); }); out.textures_delta.clear(); }@@ -317,7 +317,8 @@ fn preview(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId) { }; let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, PREVIEW_H + 8.0), Sense::hover());- let frame = Rect::from_min_max(pos2(r.min.x + widgets::PAD, r.min.y), pos2(r.max.x - widgets::PAD, r.max.y - 8.0));+ // The section's margins: 16 in from the left, 8 from the right — not 16 on both sides.+ let frame = Rect::from_min_max(pos2(r.min.x + widgets::PAD, r.min.y), pos2(r.max.x - widgets::PAD_RIGHT, r.max.y - 8.0)); widgets::checker(&ui.painter().with_clip_rect(frame), frame, 8.0); ui.painter().rect_stroke(frame, CornerRadius::same(4), Stroke::new(1.0, t.border), StrokeKind::Inside); if let Some(tex) = tex {@@ -85,7 +85,11 @@ fn direction(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { LayoutMode::Horizontal => 1, LayoutMode::Grid => 2, };- if let Some(i) = widgets::segmented(ui, &items, Some(active), 32.0) {+ // The switch fills the row: its three cells share what the wrap button leaves, so the last+ // cell and the button end at the panel's margin.+ let wrap = if l.mode != LayoutMode::Grid { 24.0 + widgets::GUTTER } else { 0.0 };+ let cell = (ui.available_width() - wrap) / items.len() as f32;+ if let Some(i) = widgets::segmented(ui, &items, Some(active), cell) { // Wrap carries over between the flows: rows of a horizontal one, columns of a vertical one. let mode = ["vertical", "horizontal", "grid"].get(i).copied().unwrap_or("grid"); app.set_props(ids, json!({"layout": mode}));@@ -333,6 +337,9 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } } ui.vertical(|ui| {+ // The alignment picker on the left, the gap and its mode stacked to its right, both+ // ending where the panel's fields do (the button column at a row's end stays free).+ let w = (ui.available_width() - 32.0).max(40.0); if grid_mode { // Grid: gap between columns and between rows. let e = crate::variable_ui::number_field(@@ -344,7 +351,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { Lead::Icon(Icon::Gap, tr("Column gap")), Some(l.gap), "",- 96.0,+ w, ); app.field(ids, "columnGap", e, |v| json!(v.max(0.0))); ui.add_space(8.0);@@ -357,7 +364,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { Lead::Icon(Icon::DistributeV, tr("Row gap")), Some(l.cross_gap), "",- 96.0,+ w, ); app.field(ids, "rowGap", e, |v| json!(v.max(0.0))); return;@@ -365,7 +372,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let auto = l.main_align.is_auto_gap(); let gap_icon = if l.mode == LayoutMode::Vertical { Icon::DistributeV } else { Icon::Gap }; let (shown, lead) = ((!auto).then_some(l.gap), Lead::Icon(gap_icon, tr("Gap between items")));- let e = crate::variable_ui::number_field_or(app, ui, ids, "gap", Id::unique("al_gap"), shown, 96.0, tr("Auto"), lead, "");+ let e = crate::variable_ui::number_field_or(app, ui, ids, "gap", Id::unique("al_gap"), shown, w, tr("Auto"), lead, ""); app.field(ids, "gap", e, |v| json!(v)); ui.add_space(8.0); let label = match l.main_align {@@ -374,7 +381,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { MainAlign::SpaceEvenly => tr("Space evenly"), _ => tr("Fixed gap"), };- let resp = widgets::dropdown(ui, label, 96.0, None);+ let resp = widgets::dropdown(ui, label, w, None); crate::a11y::combo(&resp, tr("Auto gap"), label, crate::a11y::menu_open(&resp)); menu_popup(&resp).show(|ui| { let modes = [@@ -99,6 +99,10 @@ impl FigApp { /// menu, then route the scroll area to comments, frame presets, design, prototype or inspect. pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx());+ // The panel's edge, drawn here: the frame has no stroke (a stroke counts as margin and would+ // take 1 pt off each side of the panel (240 pt by default)).+ let edge = ui.max_rect();+ ui.painter().line_segment([edge.left_top(), edge.left_bottom()], Stroke::new(1.0, t.border)); let w = ui.available_width(); // Top bar: people (you and the others in a live session), present, share. let (top, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover());@@ -32,7 +32,7 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node open = !open; ui.data_mut(|d| d.insert_temp(Id::unique((FX_OPEN, i)), open)); }- let w = ui.available_width() - 56.0;+ let w = ui.available_width() - 60.0; let resp = widgets::dropdown(ui, tr(e.label()), w, None); menu_popup(&resp).show(|ui| { for (l, ty) in Effect::TYPES {@@ -44,14 +44,18 @@ pub(super) fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node } } });- if icons::button(ui, if e.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle")).clicked() {+ let (toggle, remove) = widgets::tight_end(ui, |ui| {+ (+ icons::button(ui, if e.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle")).clicked(),+ icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked(),+ )+ });+ if toggle { let mut e2 = e.clone(); e2.visible = !e2.visible; app.run("effect.set", json!({"ids": ids, "index": i, "effect": e2})); }- if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked()- && app.run("effect.remove", json!({"ids": ids, "index": i})).is_some()- {+ if remove && app.run("effect.remove", json!({"ids": ids, "index": i})).is_some() { removed = Some(i); } });@@ -106,7 +110,7 @@ fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str, /// Draw type-specific effect fields for shadows, blurs, noise, texture and glass, using live /// scrub edits that stay one undo step and bindable colour fields. fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect) {- let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;+ let half = widgets::col_width(ui.available_width()); let set = |app: &mut FigApp, e2: Effect, scrub: Option<bool>| { let p = json!({"ids": ids, "index": i, "effect": e2}); match scrub {@@ -154,7 +158,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: & // The colour: a bound variable's chip, else swatch, hex and alpha with a variable button. let color_field = format!("effects/{i}/color"); row(ui, 32.0, |ui| {- crate::variable_ui::bindable(app, ui, ids, &color_field, VariableType::Color, half * 2.0 + 8.0, |app, ui| {+ crate::variable_ui::bindable(app, ui, ids, &color_field, VariableType::Color, ui.available_width(), |app, ui| { fx_color(app, ui, ids, i, e, false, &edit_color) }); });@@ -175,7 +179,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: & e2 }; row(ui, 32.0, |ui| {- let resp = widgets::dropdown(ui, tr(b.blur_type.label()), half * 2.0 + 8.0, None);+ let resp = widgets::dropdown(ui, tr(b.blur_type.label()), ui.available_width(), None); menu_popup(&resp).show(|ui| { for t in BlurType::ALL { if menu_item(ui, tr(t.label()), "") {@@ -193,7 +197,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: & let ed = fx_field(app, ui, ids, i, "radius", Lead::Text(tr("End")), b.radius, half); edit(app, ed, &|v| with(&|b2| b2.radius = v.clamp(0.0, MAX_BLUR_RADIUS))); } else {- let ed = fx_field(app, ui, ids, i, "radius", Lead::Text(tr("Blur")), b.radius, half * 2.0 + 8.0);+ let ed = fx_field(app, ui, ids, i, "radius", Lead::Text(tr("Blur")), b.radius, ui.available_width()); edit(app, ed, &|v| with(&|b2| b2.radius = v.clamp(0.0, MAX_BLUR_RADIUS))); } });@@ -207,7 +211,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: & }) }; row(ui, 32.0, |ui| {- let resp = widgets::dropdown(ui, tr(nz.mode.label()), half * 2.0 + 8.0, None);+ let resp = widgets::dropdown(ui, tr(nz.mode.label()), ui.available_width(), None); menu_popup(&resp).show(|ui| { for m in NoiseMode::ALL { if menu_item(ui, tr(m.label()), "") {@@ -367,7 +371,7 @@ pub(super) fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId open = !open; ui.data_mut(|d| d.insert_temp(open_id, open)); }- let w = ui.available_width() - 56.0;+ let w = ui.available_width() - 60.0; let resp = widgets::dropdown(ui, &grid_label(g), w, None); menu_popup(&resp).width(220.0).show(|ui| { for (l, kind) in [(tr("Grid"), GridKind::Grid), (tr("Columns"), GridKind::Columns), (tr("Rows"), GridKind::Rows)] {@@ -382,12 +386,16 @@ pub(super) fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId } } });- if icons::button(ui, if g.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle")).clicked() {+ let (toggle, remove) = widgets::tight_end(ui, |ui| {+ (+ icons::button(ui, if g.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle")).clicked(),+ icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked(),+ )+ });+ if toggle { app.set_props(&ids, json!({"grids": grids_with(&list, i, |x| x.visible = !x.visible)})); }- if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked()- && app.run("grid.remove", json!({"ids": ids, "index": i})).is_some()- {+ if remove && app.run("grid.remove", json!({"ids": ids, "index": i})).is_some() { removed = Some(i); } });@@ -35,7 +35,7 @@ pub(crate) fn prop_row(app: &mut FigApp, ui: &mut Ui, p: &figcraft_engine::devin t.text_secondary, ); // The style or variable, right-aligned as a chip.- let mut right = r.max.x - PAD;+ let mut right = r.max.x - widgets::PAD_RIGHT; if let Some(token) = &p.token { let galley = painter.layout_no_wrap(token.clone(), regular(10.0), t.text_secondary); let chip =@@ -103,7 +103,12 @@ fn inspect_assets(app: &mut FigApp, ui: &mut Ui, doc: &Document, assets: &[figcr let resp = resp.tip(trf("Export {name}", &[("name", &a.name)])); if resp.hovered() { ui.painter().rect_filled(r.shrink2(vec2(8.0, 1.0)), CornerRadius::same(6), t.hover);- icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - PAD - 6.0, r.center().y), vec2(12.0, 12.0)), Icon::Export, t.icon);+ icons::paint(+ ui.painter(),+ Rect::from_center_size(pos2(r.max.x - widgets::PAD_RIGHT - 6.0, r.center().y), vec2(12.0, 12.0)),+ Icon::Export,+ t.icon,+ ); } // The preview, as exporting it paints it (a slice shows the page under it). Rows scrolled // out of view draw nothing, so they don't pay for a thumbnail either.@@ -125,7 +130,7 @@ fn inspect_assets(app: &mut FigApp, ui: &mut Ui, doc: &Document, assets: &[figcr ui.painter().rect_stroke(thumb, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside); // The name, and the format to export it as (its first export setting's, until chosen). let x = thumb.max.x + 10.0;- let name_r = Rect::from_min_max(pos2(x, r.min.y + 5.0), pos2(r.max.x - PAD, r.min.y + 27.0));+ let name_r = Rect::from_min_max(pos2(x, r.min.y + 5.0), pos2(r.max.x - widgets::PAD_RIGHT, r.min.y + 27.0)); let name = widgets::elide(ui, &a.name, name_r.width()); ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x, name_r.center().y), Align2::LEFT_CENTER, &name, regular(11.0), t.text); let idx = state@@ -127,9 +127,10 @@ pub(crate) fn row_rect(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) -> Rect { pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) -> Rect { let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());- let mut child =- ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(PAD, inset_y))).layout(egui::Layout::left_to_right(egui::Align::Center)));- child.spacing_mut().item_spacing = vec2(8.0, 0.0);+ // The section's grid: 16 in from the left, 8 from the right (not 16 on both sides).+ let inner = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y + inset_y), pos2(r.max.x - widgets::PAD_RIGHT, r.max.y - inset_y));+ let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)));+ child.spacing_mut().item_spacing = vec2(widgets::GUTTER, 0.0); add(&mut child); r }@@ -344,8 +344,13 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI if !fills && !empty { let weight = common(doc, sel, |n| n.stroke.weight); let align = common(doc, sel, |n| n.stroke.align);- let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; row(ui, 32.0, |ui| {+ // The row is the panel's width: the two fields split what the buttons at its end leave,+ // so the align field and the weight field are the same column (76 at the default width+ // with two buttons, 88 with one). The buttons are 24 pt each, 4 apart on the icon grid.+ let sided = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle)));+ let group = if sided { 24.0 + 4.0 + 24.0 } else { 24.0 };+ let field_w = ((ui.available_width() - widgets::GUTTER * 2.0 - group) / 2.0).max(40.0); let resp = widgets::dropdown( ui, match align {@@ -354,7 +359,7 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI Some(StrokeAlign::Outside) => tr("Outside"), None => tr("Mixed"), },- half,+ field_w, None, ); menu_popup(&resp).show(|ui| {@@ -365,8 +370,6 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI } } });- let sided = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Rectangle)));- let weight_w = half - if sided { 60.0 } else { 32.0 }; let e = crate::variable_ui::number_field_mixed( app, ui,@@ -376,32 +379,40 @@ pub(super) fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeI Lead::Icon(Icon::Menu, tr("Stroke weight")), weight, "",- weight_w,+ field_w, ); app.field_of(&ids, "strokeWeight", e, |n| n.stroke.weight, |v| json!(v.max(0.0)));+ let adv_id = Id::unique("stroke_adv");+ let open = ui.data(|d| d.get_temp::<bool>(adv_id)).unwrap_or(false);+ let advanced = |ui: &mut Ui| {+ if icons::button(ui, Icon::Dots, 24.0, open, tr("Advanced stroke")).clicked() {+ ui.data_mut(|d| d.insert_temp(adv_id, !open));+ }+ }; if sided {- let sides_btn = icons::button(ui, Icon::IndependentCorners, 24.0, false, tr("Individual strokes"));- menu_popup(&sides_btn).show(|ui| {- let w = weight.unwrap_or(1.0);- for (l, v) in [- (tr("All"), Value::Null),- (tr("Top"), json!([w, 0.0, 0.0, 0.0])),- (tr("Right"), json!([0.0, w, 0.0, 0.0])),- (tr("Bottom"), json!([0.0, 0.0, w, 0.0])),- (tr("Left"), json!([0.0, 0.0, 0.0, w])),- (tr("Custom"), json!([w, w, w, w])),- ] {- if menu_item(ui, l, "") {- app.set_props(&ids, json!({"strokeSides": v}));- ui.close();+ // The two buttons are one group on the row's right edge: 24 each, 4 between them.+ widgets::tight_end(ui, |ui| {+ let sides_btn = icons::button(ui, Icon::IndependentCorners, 24.0, false, tr("Individual strokes"));+ menu_popup(&sides_btn).show(|ui| {+ let w = weight.unwrap_or(1.0);+ for (l, v) in [+ (tr("All"), Value::Null),+ (tr("Top"), json!([w, 0.0, 0.0, 0.0])),+ (tr("Right"), json!([0.0, w, 0.0, 0.0])),+ (tr("Bottom"), json!([0.0, 0.0, w, 0.0])),+ (tr("Left"), json!([0.0, 0.0, 0.0, w])),+ (tr("Custom"), json!([w, w, w, w])),+ ] {+ if menu_item(ui, l, "") {+ app.set_props(&ids, json!({"strokeSides": v}));+ ui.close();+ } }- }+ });+ advanced(ui); });- }- let adv_id = Id::unique("stroke_adv");- let open = ui.data(|d| d.get_temp::<bool>(adv_id)).unwrap_or(false);- if icons::button(ui, Icon::Dots, 24.0, open, tr("Advanced stroke")).clicked() {- ui.data_mut(|d| d.insert_temp(adv_id, !open));+ } else {+ advanced(ui); } }); // Per-side weights, two to a row (like the corner radii) so a bound variable's name fits:@@ -431,7 +442,7 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, let ids = ids.to_vec(); let cmd = if fills { "fill.set" } else { "stroke.set" }; let r = row_rect(ui, 32.0, |ui| {- let w = ui.available_width() - 56.0;+ let w = ui.available_width() - 60.0; let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover()); ui.painter().rect_filled(r, CornerRadius::same(5), t.field); let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));@@ -510,10 +521,17 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint, } None => {} }- if icons::button(ui, if p.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle visibility")).clicked() {+ // The row's two buttons are one group on the grid: 24 each, 4 between them.+ let (toggle, remove) = widgets::tight_end(ui, |ui| {+ (+ icons::button(ui, if p.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle visibility")).clicked(),+ icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked(),+ )+ });+ if toggle { app.run(cmd, json!({"ids": ids, "index": i, "visible": !p.visible})); }- if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked()+ if remove && app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some() && let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker && *f == fills@@ -36,7 +36,7 @@ pub(crate) fn profile_art(painter: &egui::Painter, rect: Rect, profile: Option<& } /// Stroke settings ▸ Width profile: the presets (each with its shape) and a flip.-fn width_profile_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], profile: Option<Option<WidthProfile>>, half: f32) {+fn width_profile_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], profile: Option<Option<WidthProfile>>) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Width profile"), None); row(ui, 32.0, |ui| {@@ -45,7 +45,7 @@ fn width_profile_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], profile: Opt Some(None) => tr(WidthPreset::Uniform.label()), Some(Some(p)) => p.preset().map_or(tr("Custom"), |w| tr(w.label())), };- let resp = widgets::dropdown(ui, label, half * 2.0 + 8.0 - 32.0, None);+ let resp = widgets::dropdown(ui, label, ui.available_width() - 32.0, None); if let Some(p) = &profile { let r = resp.rect; let art = Rect::from_min_max(pos2(r.max.x - 22.0 - 56.0, r.center().y - 5.0), pos2(r.max.x - 22.0, r.center().y + 5.0));@@ -98,7 +98,7 @@ pub(crate) fn brush_art(painter: &egui::Painter, rect: Rect, preset: &str, col: } /// Stroke settings ▸ Brush: a plain stroke, a stretch brush or a scatter brush, each with its look.-fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option<Option<Brush>>, width: f32) {+fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option<Option<Brush>>, panel_w: f32) { use figcraft_pathops::brush::{PRESETS, preset}; let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Brush"), None);@@ -107,7 +107,8 @@ fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option<Option None => tr("Mixed"), Some(b) => b.as_ref().and_then(|b| preset(&b.preset)).map_or(tr("None"), |p| tr(p.label)), };- let resp = widgets::dropdown(ui, label, width, None);+ // The row's own interior: the section's width less its two margins.+ let resp = widgets::dropdown(ui, label, panel_w - PAD - widgets::PAD_RIGHT, None); a11y::combo(&resp, tr("Brush"), label, a11y::menu_open(&resp)); if let Some(Some(b)) = &brush { let r = resp.rect;@@ -145,7 +146,7 @@ fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option<Option else { return; };- let half = (width - 8.0) / 2.0;+ let half = widgets::col_width(panel_w); let with = |f: &dyn Fn(&mut Brush)| { let mut nb = b.clone(); f(&mut nb);@@ -186,7 +187,7 @@ fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option<Option /// Advanced stroke settings: dash pattern, width profile, join and the end points of open paths. pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec();- let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;+ let half = widgets::col_width(ui.available_width()); let st = common(doc, sel, |n| n.stroke.clone()); let dashes = st.as_ref().map(|s| s.dashes.clone()); let weight = st.as_ref().map(|s| s.weight).unwrap_or(1.0).max(1.0);@@ -215,8 +216,8 @@ pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel app.field(&ids, "dashes", e, |v| json!([dash, v.max(0.0)])); }); }- width_profile_row(app, ui, &ids, st.as_ref().map(|s| s.profile().cloned()), half);- brush_row(app, ui, &ids, st.as_ref().map(|s| s.brush.clone()), half * 2.0 + 8.0);+ width_profile_row(app, ui, &ids, st.as_ref().map(|s| s.profile().cloned()));+ brush_row(app, ui, &ids, st.as_ref().map(|s| s.brush.clone()), ui.available_width()); let join = st.as_ref().map(|s| s.join); widgets::sublabels(ui, tr("Join|stroke"), (join == Some(StrokeJoin::Miter)).then_some(tr("Miter angle"))); row(ui, 32.0, |ui| {@@ -16,7 +16,7 @@ use crate::i18n::{tr, tr_noop, trf}; use crate::icons::{self, Icon}; use crate::right::{common, menu_item, menu_popup, row}; use crate::theme::{Tokens, regular, semibold};-use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD};+use crate::widgets::{self, FIELD_H, FieldEdit, Lead}; mod axes; mod fields;@@ -40,7 +40,8 @@ pub(super) fn font_rows( }); // Style + size.- let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;+ // One of the two field columns of the section's grid.+ let half = widgets::col_width(ui.available_width()); row(ui, 32.0, |ui| { let resp = font_dropdown(app, ui, ids, "fontStyle", fstyle.as_deref(), half, editing); let fam = family.clone();@@ -75,9 +76,9 @@ pub(super) fn font_rows( ui.spacing_mut().item_spacing.x = 0.0; // The whole layer's size can follow a variable; a selected range just takes a value. let e = if editing {- widgets::number_field(ui, Id::unique("ty_size"), Lead::None, size, "", half - 20.0, 1.0)+ widgets::number_field(ui, Id::unique("ty_size"), Lead::None, size, "", ui.available_width() - 20.0, 1.0) } else {- crate::variable_ui::number_field(app, ui, ids, "fontSize", Id::unique("ty_size"), Lead::None, size, "", half - 20.0)+ crate::variable_ui::number_field(app, ui, ids, "fontSize", Id::unique("ty_size"), Lead::None, size, "", ui.available_width() - 20.0) }; match e { Some(FieldEdit::Set(v)) => apply_style(app, "fontSize", json!(v.clamp(1.0, 5000.0))),@@ -132,7 +133,8 @@ pub(super) fn paragraph_rows( styles: &[TextStyle], apply_style: &dyn Fn(&mut FigApp, &str, Value), ) {- let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;+ // One of the two field columns of the section's grid.+ let half = widgets::col_width(ui.available_width()); // Paragraph spacing + resizing. let ps = common(doc, ids, |n| n.kind.text().map(|t| t.paragraph_spacing.to_bits())).flatten().map(f64::from_bits); let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();@@ -17,6 +17,13 @@ pub mod style_picker; pub const ROW_H: f32 = 32.0; pub const FIELD_H: f32 = 24.0; pub const PAD: f32 = 16.0;+/// A section's right margin: 8. The panel's grid is `16 | 88 | 8 | 88 | 8 | 24 | 8`: a 240 pt+/// panel, its content 16 in from the left and 8 from the right, two 88 pt columns, an 8 pt gutter+/// between them and 8 + 24 + 8 for the buttons at a row's end. FigCraft's own: the columns are+/// equal, and the buttons sit on the same 24 pt rhythm as the icon buttons the panel already has.+pub const PAD_RIGHT: f32 = 8.0;+/// Between the panel's grid columns.+pub const GUTTER: f32 = 8.0; /// What a numeric field's label shows: text ("X") or an icon with the field's name (for screen /// readers: an icon doesn't say which property it is).@@ -886,22 +893,22 @@ pub fn section_header_resp(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], d pos2(rect.min.x + PAD, rect.center().y), Align2::LEFT_CENTER, title,- semibold(11.0),+ regular(11.0), if dim { t.text_secondary } else { t.text }, ); let mut out = None;- let mut x = rect.max.x - 8.0;+ let mut x = rect.max.x - PAD_RIGHT; for (i, (icon, tip)) in buttons.iter().enumerate().rev() { let r = Rect::from_min_size(pos2(x - 24.0, rect.center().y - 12.0), vec2(24.0, 24.0)); let resp = ui.interact(r, ui.scope_id().with(("hdr", title, i)), Sense::click()).tip(*tip); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); }- icons::paint(ui.painter(), r.shrink(5.0), *icon, t.icon);+ icons::paint(ui.painter(), r.shrink(6.0), *icon, t.icon); if resp.clicked() { out = Some(i); }- x -= 24.0;+ x -= 28.0; } // An empty section's whole header adds its item, not only its `+` button. if out.is_none() && dim && resp.clicked() {@@ -910,6 +917,20 @@ pub fn section_header_resp(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], d (out, resp) } +/// Runs `add` as the group of buttons at a row's right end: two 24 pt buttons 4 pt apart, the last+/// of them ending at the section's 8 pt margin, whatever the row has before it. (The columns around+/// them keep the 8 pt gutter.)+pub fn tight_end<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {+ let group = 24.0 * 2.0 + 4.0;+ let avail = ui.available_rect_before_wrap();+ let rect = Rect::from_min_max(pos2((avail.max.x - group).max(avail.min.x), avail.min.y), avail.max);+ let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)));+ child.spacing_mut().item_spacing.x = 4.0;+ // The row already gave the group its height (`block` allocated it): the cursor moves on only+ // past the group's width, so the next row keeps the row's own height.+ add(&mut child)+}+ /// An icon button at `rect` (hover highlight, tooltip); true when clicked. pub fn icon_button_at(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool { let t = Tokens::get(ui.ctx());@@ -1215,13 +1236,14 @@ pub fn note(ui: &mut Ui, text: &str, color: Color32) { pub fn sublabels(ui: &mut Ui, a: &str, b: Option<&str>) { let t = Tokens::get(ui.ctx()); let w = ui.available_width();- let (r, _) = ui.allocate_exact_size(vec2(w, 18.0), Sense::hover());+ let (r, _) = ui.allocate_exact_size(vec2(w, 12.0), Sense::hover()); let col = col_width(w); // Each label fits its column on one line (long translations end with an ellipsis, in full on hover).- let a_w = if b.is_some() { col } else { w - PAD * 2.0 };- for (i, (text, x, max_w)) in [(a, PAD, a_w)].into_iter().chain(b.map(|b| (b, PAD + col + 8.0, w - col - 8.0 - PAD * 2.0))).enumerate() {+ let a_w = if b.is_some() { col } else { w - PAD - PAD_RIGHT };+ for (i, (text, x, max_w)) in [(a, PAD, a_w)].into_iter().chain(b.map(|b| (b, PAD + col + GUTTER, w - col - GUTTER - PAD - PAD_RIGHT))).enumerate()+ { let shown = elide_font(ui, text, max_w, regular(10.0));- let at = ui.painter().text(pos2(r.min.x + x, r.max.y - 2.0), Align2::LEFT_BOTTOM, &shown, regular(10.0), t.text_secondary);+ let at = ui.painter().text(pos2(r.min.x + x, r.max.y), Align2::LEFT_BOTTOM, &shown, regular(10.0), t.text_secondary); if shown != text { ui.interact(at, ui.scope_id().with(("sublabel", i)), Sense::hover()).on_hover_text(text); }@@ -1230,5 +1252,5 @@ pub fn sublabels(ui: &mut Ui, a: &str, b: Option<&str>) { /// Width of one of the two field columns in a panel `w` wide (leaving the icon column). pub fn col_width(w: f32) -> f32 {- ((w - PAD * 2.0 - 8.0 - 32.0) / 2.0).max(40.0)+ ((w - PAD - PAD_RIGHT - GUTTER * 2.0 - 24.0) / 2.0).max(40.0) }@@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons; colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:@@ -19,6 +19,7 @@ interaction feel not yet compared. | Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points |+| Design panel's sections on one grid | done | every section lays its rows out on the same grid: the panel is 240 pt, its content 16 pt in from the left and 8 pt from the right, two 88 pt columns at that width with 8 pt gutters and a 24 pt column for the buttons at a row's end (the columns are flex, so they share the panel when it is dragged wider); rows 24 pt in a 32 pt track, 10 pt captions over them; section titles 11 pt regular (they were semibold) and a row's values 11 pt, their buttons 24 pt and 4 pt apart. The panel's edge is drawn inside it: a frame stroke counts as margin and took 1 pt off each side, putting every right margin 1–2 pt out (checked in the app on Windows over the control channel) | | Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | | Context menus | partial | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key reaches egui as `Key::Menu` (egui-winit 0.37 maps the native key), but FigCraft's keymap has no binding for `Menu`, so it does nothing yet | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder |@@ -173,6 +174,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | The Design panel's sections sit on one grid: 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows, 11 pt regular section titles over 10 pt captions, 4 pt between the two buttons at a row's end; the panel's frame no longer carries a stroke (it counts as margin), so an icon's box ends 8 pt from the window edge. Checked in the app on Windows over the control channel | | 2026-10-25 | minor | Context menus: the Menu key is no longer an egui limitation at 0.37 (egui-winit maps the native Menu/ContextMenu key to egui's `Key::Menu`), but FigCraft normalizes no binding for `Menu`, so it does not open a context menu yet; Shift+F10 still does, with the same behaviour. The row moves from done to partial. Numbers unchanged | | 2026-10-24 | minor | Selection colors: a row's Styles menu also lists the document's color variables, and picking one binds it to every fill and stroke the row stands for, replacing a color style there (`selection.bindColorVariable`; a text range with fills of its own takes the variable's value); a row linked to a style or variable has a Detach button that unlinks it and keeps the color (`selection.detachColor`). Still partial: no library styles or variables, no new style or variable from a row | | 2026-10-24 | minor | The Text on a path tool (`tool.textOnPath`, in the Text flyout): a click on a shape's or vector's path puts new text on it, starting at the click, with the path's fill (when one shows) and effects, as the help center's Guide to text describes; the path layer is removed, and an edit left empty puts it back with no undo step (`draw_tools_tests`, `cursor_tests`) |