collective

Browse / github.com/storytold/figcraft / patch

ui: rebuild the toolbar pill on a 32 pt grid, without the Resources button

ui: rebuild the toolbar pill on a 32 pt grid, without the Resources button ## Toolbar pill on a 32 pt grid - 32 pt cells, 8 pt left and right padding, 8 pt between items (the divider included), on a 48 pt tall pill. - Flyout carets get a 16 pt hit box, 1 pt off their button. - The mode switcher (Draw · Design · Prototype · Inspect) is a 32 pt strip; the current mode is a plain white inset, no outline, and its marks are 14 pt. - The divider is 1 pt wide and as tall as the content. - **The Resources button is removed.** The left rail's Assets tab, Shift+I, Alt+2 and View ▸ Panels ▸ Assets remain the ways into the Assets panel; `docs/ui-parity.md` says so. - The pill's

Status
open
Score
0.00
Adopters
0 other operators
Author
yelintong (imported from pull request #1210, open)
Base
43af145a12eacd1e87f5f5e6984c49b7da040288
Size
+132 −136 in 29 files
Review
pending
Shared
2026-10-28
Id
de686083353d13bcfac6d7b9

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/locales/bn.toml crates/ui-egui/locales/cs.toml crates/ui-egui/locales/de.toml crates/ui-egui/locales/en.toml crates/ui-egui/locales/es.toml crates/ui-egui/locales/fi.toml crates/ui-egui/locales/fr.toml crates/ui-egui/locales/id.toml crates/ui-egui/locales/it.toml crates/ui-egui/locales/ja.toml crates/ui-egui/locales/ka.toml crates/ui-egui/locales/ko.toml crates/ui-egui/locales/nl.toml crates/ui-egui/locales/pl.toml crates/ui-egui/locales/pt-BR.toml crates/ui-egui/locales/ru.toml crates/ui-egui/locales/sv.toml crates/ui-egui/locales/th.toml crates/ui-egui/locales/tr.toml crates/ui-egui/locales/vi.toml crates/ui-egui/locales/zh-Hans.toml crates/ui-egui/src/board_picker_tests.rs crates/ui-egui/src/board_ui_tests.rs crates/ui-egui/src/resources_tests.rs crates/ui-egui/src/shortcuts.rs crates/ui-egui/src/toolbar.rs crates/ui-egui/src/widgets/board_picker.rs docs/boards-parity.md docs/ui-parity.md

diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml--- a/crates/ui-egui/locales/bn.toml+++ b/crates/ui-egui/locales/bn.toml@@ -1163,7 +1163,6 @@ status = "draft — needs native review" "Resolve" = "সমাধান করুন" "Resolve change" = "পরিবর্তন সমাধান করুন" "Resolved" = "সমাধান করা হয়েছে"-"Resources" = "রিসোর্সসমূহ" "Responsive" = "রেসপনসিভ" "Restart" = "পুনরায় শুরু করুন" "Restore" = "পুনরুদ্ধার করুন"diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml--- a/crates/ui-egui/locales/cs.toml+++ b/crates/ui-egui/locales/cs.toml@@ -1141,7 +1141,6 @@ status = "draft — needs native review" "Resolve" = "Vyřešit" "Resolve change" = "Vyřešit změnu" "Resolved" = "Vyřešené"-"Resources" = "Zdroje" "Responsive" = "Responzivní" "Restart" = "Znovu spustit" "Restore" = "Obnovit"diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml--- a/crates/ui-egui/locales/de.toml+++ b/crates/ui-egui/locales/de.toml@@ -1111,7 +1111,6 @@ status = "draft — needs native review" "Resolve" = "Erledigen" "Resolve change" = "Änderung erledigen" "Resolved" = "Erledigt"-"Resources" = "Ressourcen" "Responsive" = "Responsiv" "Restart" = "Neu starten" "Restore" = "Wiederherstellen"diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml--- a/crates/ui-egui/locales/en.toml+++ b/crates/ui-egui/locales/en.toml@@ -1198,7 +1198,6 @@ name = "English" "Resolve" = "Resolve" "Resolve change" = "Resolve change" "Resolved" = "Resolved"-"Resources" = "Resources" "Responsive" = "Responsive" "Restart" = "Restart" "Restore" = "Restore"diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml--- a/crates/ui-egui/locales/es.toml+++ b/crates/ui-egui/locales/es.toml@@ -1194,7 +1194,6 @@ status = "draft — needs native review" "Resolve" = "Resolver" "Resolve change" = "Resolver cambio" "Resolved" = "Resuelto"-"Resources" = "Recursos" "Responsive" = "Adaptable" "Restart" = "Reiniciar" "Restore" = "Restaurar"diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml--- a/crates/ui-egui/locales/fi.toml+++ b/crates/ui-egui/locales/fi.toml@@ -1167,7 +1167,6 @@ status = "draft — needs native review" "Resolve" = "Ratkaise" "Resolve change" = "Ratkaise muutos" "Resolved" = "Ratkaistu"-"Resources" = "Resurssit" "Responsive" = "Mukautuva" "Restart" = "Aloita uudelleen" "Restore" = "Palauta"diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml--- a/crates/ui-egui/locales/fr.toml+++ b/crates/ui-egui/locales/fr.toml@@ -1168,7 +1168,6 @@ status = "draft — needs native review" "Resolve" = "Résoudre" "Resolve change" = "Résoudre la modification" "Resolved" = "Résolu"-"Resources" = "Ressources" "Responsive" = "Adaptatif" "Restart" = "Recommencer" "Restore" = "Restaurer"diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml--- a/crates/ui-egui/locales/id.toml+++ b/crates/ui-egui/locales/id.toml@@ -1147,7 +1147,6 @@ status = "draft — needs native review" "Resolve" = "Selesaikan" "Resolve change" = "Selesaikan perubahan" "Resolved" = "Terselesaikan"-"Resources" = "Sumber daya" "Responsive" = "Responsif" "Restart" = "Mulai ulang" "Restore" = "Pulihkan"diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml--- a/crates/ui-egui/locales/it.toml+++ b/crates/ui-egui/locales/it.toml@@ -1128,7 +1128,6 @@ status = "draft — needs native review" "Resolve" = "Risolvi" "Resolve change" = "Risolvi la modifica" "Resolved" = "Risolto"-"Resources" = "Risorse" "Responsive" = "Responsive" "Restart" = "Riavvia" "Restore" = "Ripristina"diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml--- a/crates/ui-egui/locales/ja.toml+++ b/crates/ui-egui/locales/ja.toml@@ -1155,7 +1155,6 @@ status = "draft — needs native review" "Resolve" = "解決" "Resolve change" = "変更を解決" "Resolved" = "解決済み"-"Resources" = "リソース" "Responsive" = "レスポンシブ" "Restart" = "再起動" "Restore" = "復元"diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml--- a/crates/ui-egui/locales/ka.toml+++ b/crates/ui-egui/locales/ka.toml@@ -1165,7 +1165,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Resolve" = "გადაწყვეტა" "Resolve change" = "ცვლილების გადაწყვეტა" "Resolved" = "გადაწყვეტილია"-"Resources" = "რესურსები" "Responsive" = "ადაპტური" "Restart" = "გადატვირთვა" "Restore" = "აღდგენა"diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml--- a/crates/ui-egui/locales/ko.toml+++ b/crates/ui-egui/locales/ko.toml@@ -1105,7 +1105,6 @@ status = "draft — needs native review" "Resolve" = "해결" "Resolve change" = "변경 사항 해결" "Resolved" = "해결됨"-"Resources" = "리소스" "Responsive" = "반응형" "Restart" = "다시 시작" "Restore" = "복원"diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml--- a/crates/ui-egui/locales/nl.toml+++ b/crates/ui-egui/locales/nl.toml@@ -1167,7 +1167,6 @@ status = "draft — needs native review" "Resolve" = "Oplossen" "Resolve change" = "Wijziging oplossen" "Resolved" = "Opgelost"-"Resources" = "Bronnen" "Responsive" = "Responsive" "Restart" = "Opnieuw starten" "Restore" = "Terugzetten"diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml--- a/crates/ui-egui/locales/pl.toml+++ b/crates/ui-egui/locales/pl.toml@@ -1171,7 +1171,6 @@ name = "Polski" "Resolve" = "Rozwiąż" "Resolve change" = "Rozwiąż zmianę" "Resolved" = "Rozwiązany"-"Resources" = "Zasoby" "Responsive" = "Responsywny" "Restart" = "Uruchom ponownie" "Restore" = "Przywróć"diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml--- a/crates/ui-egui/locales/pt-BR.toml+++ b/crates/ui-egui/locales/pt-BR.toml@@ -1104,7 +1104,6 @@ status = "draft — needs native review" "Resolve" = "Resolver" "Resolve change" = "Resolver alteração" "Resolved" = "Resolvido"-"Resources" = "Recursos" "Responsive" = "Responsivo" "Restart" = "Reiniciar" "Restore" = "Restaurar"diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml--- a/crates/ui-egui/locales/ru.toml+++ b/crates/ui-egui/locales/ru.toml@@ -1171,7 +1171,6 @@ name = "Русский" "Resolve" = "Решить" "Resolve change" = "Разрешить изменение" "Resolved" = "Решённые"-"Resources" = "Ресурсы" "Responsive" = "Адаптивно" "Restart" = "Начать заново" "Restore" = "Восстановить"diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml--- a/crates/ui-egui/locales/sv.toml+++ b/crates/ui-egui/locales/sv.toml@@ -1173,7 +1173,6 @@ name = "Svenska" "Resolve" = "Lös" "Resolve change" = "Lös ändring" "Resolved" = "Löst"-"Resources" = "Resurser" "Responsive" = "Responsiv" "Restart" = "Starta om" "Restore" = "Återställ"diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml--- a/crates/ui-egui/locales/th.toml+++ b/crates/ui-egui/locales/th.toml@@ -1161,7 +1161,6 @@ status = "draft — needs native review" "Resolve" = "แก้ไขแล้ว" "Resolve change" = "แก้ไขการเปลี่ยนแปลง" "Resolved" = "แก้ไขแล้ว"-"Resources" = "แหล่งข้อมูล" "Responsive" = "รีสปอนซีฟ" "Restart" = "เริ่มใหม่" "Restore" = "กู้คืน"diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml--- a/crates/ui-egui/locales/tr.toml+++ b/crates/ui-egui/locales/tr.toml@@ -1145,7 +1145,6 @@ name = "Türkçe" "Resolve" = "Çöz" "Resolve change" = "Değişikliği çöz" "Resolved" = "Çözüldü"-"Resources" = "Kaynaklar" "Responsive" = "Duyarlı" "Restart" = "Yeniden başlat" "Restore" = "Geri yükle"diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml--- a/crates/ui-egui/locales/vi.toml+++ b/crates/ui-egui/locales/vi.toml@@ -1129,7 +1129,6 @@ status = "draft — needs native review" "Resolve" = "Giải quyết" "Resolve change" = "Giải quyết thay đổi" "Resolved" = "Đã giải quyết"-"Resources" = "Tài nguyên" "Responsive" = "Đáp ứng" "Restart" = "Bắt đầu lại" "Restore" = "Khôi phục"diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml--- a/crates/ui-egui/locales/zh-Hans.toml+++ b/crates/ui-egui/locales/zh-Hans.toml@@ -1113,7 +1113,6 @@ status = "draft — needs native review" "Resolve" = "解决" "Resolve change" = "解决更改" "Resolved" = "已解决"-"Resources" = "资源" "Responsive" = "响应式" "Restart" = "重新开始" "Restore" = "恢复"diff --git a/crates/ui-egui/src/board_picker_tests.rs b/crates/ui-egui/src/board_picker_tests.rs--- a/crates/ui-egui/src/board_picker_tests.rs+++ b/crates/ui-egui/src/board_picker_tests.rs@@ -27,7 +27,7 @@ fn board() -> Ui { /// The templates button: left of the Design panel's button at the board toolbar's right end. fn templates_button(u: &Ui) -> Pos2 {     let p = crate::toolbar::pill_rect(u.app.canvas.rect, crate::toolbar::bar_width(&u.app));-    pos2(p.max.x - 8.0 - 32.0 - 4.0 - 16.0, p.center().y)+    pos2(p.max.x - 8.0 - 32.0 - 8.0 - 16.0, p.center().y) }  fn count(u: &Ui, f: impl Fn(&BoardItem) -> bool) -> usize {diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs--- a/crates/ui-egui/src/board_ui_tests.rs+++ b/crates/ui-egui/src/board_ui_tests.rs@@ -39,10 +39,10 @@ fn pill(u: &Ui) -> Rect { }  /// The left edge of board toolbar group `i` (button 32 wide, a 16-wide caret 2 after it on the-/// groups with flyouts, 4 between): move, ink, sticky, shapes, connector, text, section, stamp,+/// groups with flyouts, 8 between): move, ink, sticky, shapes, connector, text, section, stamp, /// comment. fn group_x(u: &Ui, i: usize) -> f32 {-    const WIDTHS: [f32; 9] = [54.0, 54.0, 54.0, 54.0, 36.0, 36.0, 36.0, 54.0, 36.0];+    const WIDTHS: [f32; 9] = [58.0, 58.0, 58.0, 58.0, 40.0, 40.0, 40.0, 58.0, 40.0];     pill(u).min.x + 8.0 + WIDTHS.iter().take(i).sum::<f32>() } diff --git a/crates/ui-egui/src/resources_tests.rs b/crates/ui-egui/src/resources_tests.rs--- a/crates/ui-egui/src/resources_tests.rs+++ b/crates/ui-egui/src/resources_tests.rs@@ -1,19 +1,14 @@-//! The toolbar's Resources button and Shift+I open the Assets panel (#710), from a collapsed-//! sidebar and from the version history too.+//! Shift+I opens the Assets panel (#710), from a collapsed sidebar and from the version history+//! too — and the toolbar has no Resources button for it any more (the left rail's Assets tab,+//! Shift+I, Alt+2 and View ▸ Panels ▸ Assets are the ways in).  use egui::accesskit::Role;-use egui::{Event, Key, Modifiers, Pos2, pos2};+use egui::{Event, Key, Modifiers}; use figcraft_engine::Session;  use crate::LeftTab; use crate::layers_drag_tests::Ui; -fn click(u: &mut Ui, p: Pos2) {-    u.button(p, true);-    u.button(p, false);-    u.frame(vec![]);-}- fn shift_i(u: &mut Ui) {     let modifiers = Modifiers::SHIFT;     u.frame(vec![@@ -23,22 +18,11 @@ fn shift_i(u: &mut Ui) {     u.frame(vec![]); } -/// The middle of the toolbar's Resources button, found in the AccessKit tree.-fn resources_button(u: &mut Ui) -> Pos2 {-    u.ctx.enable_accesskit();-    u.frame(vec![]);-    let out = u.frame_output(vec![]);-    let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;-    let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some("Resources")).expect("Resources");-    let b = n.bounds().expect("bounds");-    pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)-}- fn assets_shown(u: &Ui) -> bool {     u.app.ui.left_tab == LeftTab::Assets && !u.app.ui.left_collapsed && !u.app.ui.versions.open } -/// The three states the sidebar can be in when Resources is asked for.+/// The three states the sidebar can be in when Shift+I is pressed. fn starts() -> Vec<(&'static str, Ui)> {     let shown = Ui::new(Session::new());     let mut collapsed = Ui::new(Session::new());@@ -48,15 +32,6 @@ fn starts() -> Vec<(&'static str, Ui)> {     vec![("layers", shown), ("collapsed", collapsed), ("version history", versions)] } -#[test]-fn the_resources_button_opens_the_assets_panel() {-    for (name, mut u) in starts() {-        let at = resources_button(&mut u);-        click(&mut u, at);-        assert!(assets_shown(&u), "{name}: the Assets panel is open");-    }-}- #[test] fn shift_i_opens_the_assets_panel() {     for (name, mut u) in starts() {@@ -65,3 +40,27 @@ fn shift_i_opens_the_assets_panel() {         assert!(assets_shown(&u), "{name}: the Assets panel is open");     } }++/// The toolbar's buttons, as screen readers get them.+fn toolbar_labels(u: &mut Ui) -> Vec<String> {+    u.ctx.enable_accesskit();+    u.frame(vec![]);+    let out = u.frame_output(vec![]);+    out.platform_output+        .accesskit_update+        .expect("an AccessKit update")+        .nodes+        .iter()+        .filter(|(_, n)| n.role() == Role::Button)+        .filter_map(|(_, n)| n.label().map(str::to_string))+        .collect()+}++#[test]+fn the_toolbar_has_no_resources_button() {+    let mut u = Ui::new(Session::new());+    let labels = toolbar_labels(&mut u);+    // Actions stays (the palette's button); Resources went (#1210).+    assert!(labels.iter().any(|l| l == "Actions"), "Actions is in the toolbar: {labels:?}");+    assert!(!labels.iter().any(|l| l == "Resources"), "no Resources button: {labels:?}");+}diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs--- a/crates/ui-egui/src/shortcuts.rs+++ b/crates/ui-egui/src/shortcuts.rs@@ -73,11 +73,6 @@ pub fn tip(label: &'static str, id: &str) -> &'static str {     cached_tip(label, shortcut(id)) } -/// A tooltip naming a key ("Resources  ⇧I").-pub fn key_tip(label: &'static str, key: &'static str) -> &'static str {-    cached_tip(label, Some(key))-}- /// Panels ask for their tooltips every frame: each (label, key, platform) is built once. fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str {     type Entry = (Platform, &'static str, &'static str, &'static str);diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs--- a/crates/ui-egui/src/toolbar.rs+++ b/crates/ui-egui/src/toolbar.rs@@ -21,6 +21,19 @@ const CARET: f32 = 14.0; const FLYOUT_W: f32 = 16.0; const FLYOUT_GAP: f32 = 2.0; +// The pill's grid: every state of it (the tools, the vector and shape builder ones) is laid out on+// one 32 pt cell, PAD in from its ends and GAP between its top-level items, the divider included.+/// A toolbar button's size: the cell everything in the pill is placed on.+const TOOL_CELL: f32 = 32.0;+/// The pill's left and right padding.+const PAD: f32 = 8.0;+/// Between the pill's top-level items — the divider included.+const GAP: f32 = 8.0;+/// The divider's width.+const DIVIDER: f32 = 1.0;+/// The mode switcher's marks: smaller than the tools' (13–14 pt).+const MODE_GLYPH: f32 = 14.0;+ /// `GLYPH`'s box centred in `r`. fn glyph_rect(r: Rect) -> Rect {     Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH))@@ -129,17 +142,18 @@ pub(crate) fn bar_width(app: &FigApp) -> f32 {     main_width(if board { BOARD_GROUPS } else { GROUPS }, board) } -/// The main toolbar pill's width: the tool groups with their flyout carets, Resources and-/// Actions, and the mode switcher (boards: the Design panel's button instead).+/// The main toolbar pill's width: the tool groups with their flyout carets, Actions, the divider+/// and the mode switcher (boards: the templates and the Design panel's buttons instead). fn main_width(groups: &[Group], board: bool) -> f32 {-    let (cell, gap) = (TOOL_CELL, 4.0);-    let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * (FLYOUT_GAP + FLYOUT_W);-    let end = if board { 2.0 * cell + gap + 16.0 } else { 4.0 * 34.0 + 24.0 };-    groups.len() as f32 * (cell + gap) + carets + 2.0 * (cell + gap) + 16.0 + end+    let tools: f32 = groups.iter().map(|g| TOOL_CELL + if g.has_flyout() { FLYOUT_GAP + FLYOUT_W } else { 0.0 }).sum();+    // The end, and the gaps that aren't a group's own: after Actions and after the divider (and+    // between a board's two buttons, which the end counts as one item).+    let (end, gaps) = if board { (2.0 * TOOL_CELL, 3.0) } else { (4.0 * TOOL_CELL, 2.0) };+    PAD * 2.0 + tools + (groups.len() as f32 + gaps) * GAP + TOOL_CELL + DIVIDER + end } -/// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the-/// Draw · Design · Prototype · Inspect mode switcher (on a board, the Design panel's button).+/// The main toolbar pill: the tool groups with their flyouts, Actions, then the+/// Draw · Design · Prototype · Inspect mode switcher (on a board, its two buttons). fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: bool) {     let groups = if board { BOARD_GROUPS } else { GROUPS };     let width = main_width(groups, board);@@ -148,16 +162,16 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board:     let scroll = pill_scroll(ui, rect, width);     let outer_clip = ui.clip_rect();     ui.set_clip_rect(rect.intersect(outer_clip));-    let mut x = rect.min.x + 8.0 - scroll;+    let mut x = rect.min.x + PAD - scroll;     for g in groups {         x = group_cell(app, ui, g, pos2(x, cy), rect.min.y, t);     }-    x = side_buttons(app, ui, x, cy, t);+    x = actions_button(app, ui, x, cy, t);     divider(ui, t, x, cy);-    x += 8.0;+    x += DIVIDER + GAP;     if board {         // Boards have no prototypes or Dev Mode; the Design panel is hidden until asked for.-        x = crate::board_picker::button(app, ui, pos2(x, cy), rect.min.y, t);+        x = crate::board_picker::button(app, ui, pos2(x, cy), rect.min.y, t) + GAP;         let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));         let tip = crate::shortcuts::tip(tr("Design panel"), "view.design");         if tool_cell(ui, t, r, "board_panel", Icon::Panels, app.ui.board_panel, tip).clicked() {@@ -185,9 +199,6 @@ fn pill_scroll(ui: &Ui, rect: Rect, width: f32) -> f32 {     scroll } -/// A toolbar button's size.-const TOOL_CELL: f32 = 32.0;- /// Tool group `g` at `at` (its left edge, the pill's middle): the shown tool's button, its flyout /// caret and, while open, its flyout above the pill (whose top is `top`). Where the next starts. fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32, t: &Tokens) -> f32 {@@ -223,7 +234,7 @@ fn group_cell(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, top: f32             options_flyout(app, ui, g, pos2(r.min.x, top - 8.0));         }     }-    x + 4.0+    x + GAP }  /// The caret after group `g`'s button (at `at`, its left edge and middle): opens or closes its@@ -245,36 +256,23 @@ fn flyout_caret(app: &mut FigApp, ui: &mut Ui, g: &Group, at: egui::Pos2, t: &To     } } -/// Resources (components, plugins: the Assets panel) and the Actions palette's button, from `x`.-/// Where the next part starts.-fn side_buttons(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 {-    for (icon, tip, id) in [-        (Icon::Assets, crate::shortcuts::key_tip(tr("Resources"), "Shift+I"), "resources"),-        (Icon::Actions, crate::shortcuts::tip(tr("Actions"), "app.actions"), "actions"),-    ] {-        let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));-        let resp = ui.interact(r, ui.scope_id().with(id), Sense::click()).tip(tip);-        if id == "actions" {-            app.ui.actions_rect = Some(r);-        }-        // The Actions button reads on while its panel is open; clicking it again closes it.-        let actions_open = id == "actions" && app.ui.palette.is_some();-        if actions_open {-            ui.painter().rect_filled(r, CornerRadius::same(5), t.accent);-        } else if resp.hovered() {-            ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);-        }-        icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { t.accent_on } else { t.icon });-        if resp.clicked() {-            if id == "actions" {-                crate::menus::toggle_palette(&mut app.ui);-            } else {-                app.run("view.assets", json!({}));-            }-        }-        x += TOOL_CELL + 4.0;+/// The Actions palette's button, from `x`. Where the next part starts.+fn actions_button(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) -> f32 {+    let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));+    let resp = ui.interact(r, ui.scope_id().with("actions"), Sense::click()).tip(crate::shortcuts::tip(tr("Actions"), "app.actions"));+    app.ui.actions_rect = Some(r);+    // The Actions button reads on while its panel is open; clicking it again closes it.+    let open = app.ui.palette.is_some();+    if open {+        ui.painter().rect_filled(r, CornerRadius::same(5), t.accent);+    } else if resp.hovered() {+        ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);+    }+    icons::paint(ui.painter(), glyph_rect(r), Icon::Actions, if open { t.accent_on } else { t.icon });+    if resp.clicked() {+        crate::menus::toggle_palette(&mut app.ui);     }-    x+    x + TOOL_CELL + GAP }  /// The mode switcher from `x`: Draw · Design · Prototype · Inspect (code).@@ -286,19 +284,24 @@ fn mode_switcher(app: &mut FigApp, ui: &mut Ui, x: f32, cy: f32, t: &Tokens) {         crate::RightTab::Prototype => 2,         crate::RightTab::Inspect => 3,     };-    let seg = Rect::from_min_size(pos2(x, cy - 18.0), vec2(4.0 * 34.0 + 4.0, 36.0));-    ui.painter().rect_filled(seg, CornerRadius::same(8), t.field);+    let seg = Rect::from_min_size(pos2(x, cy - TOOL_CELL / 2.0), vec2(4.0 * TOOL_CELL, TOOL_CELL));+    ui.painter().rect_filled(seg, CornerRadius::same(5), t.field);     for (i, (icon, tip)) in modes.iter().enumerate() {-        let r = Rect::from_min_size(pos2(seg.min.x + 2.0 + i as f32 * 34.0, seg.min.y + 2.0), vec2(34.0, 32.0));+        let r = Rect::from_min_size(pos2(seg.min.x + i as f32 * TOOL_CELL, seg.min.y), vec2(TOOL_CELL, TOOL_CELL));         let resp = ui.interact(r, ui.scope_id().with(("mode", i)), Sense::click()).on_hover_text(*tip);         a11y::toggle(&resp, tip, i == active);         if i == active {-            ui.painter().rect_filled(r, CornerRadius::same(6), t.panel);-            ui.painter().rect_stroke(r, CornerRadius::same(6), Stroke::new(1.0, t.border), StrokeKind::Inside);+            // The current mode lifts with a plain inset, no outline.+            ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(3), t.panel);         } else if resp.hovered() {-            ui.painter().rect_filled(r, CornerRadius::same(6), t.hover);+            ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);         }-        icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary });+        icons::paint(+            ui.painter(),+            Rect::from_center_size(r.center(), vec2(MODE_GLYPH, MODE_GLYPH)),+            *icon,+            if i == active { t.accent_text } else { t.icon_secondary },+        );         if resp.clicked() {             pick_mode(app, i);         }@@ -359,15 +362,14 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {         app.run("tool.set", json!({"tool": "move"}));     }     let label = if why == figcraft_engine::live::PUBLISHING { tr("Publishing…") } else { tr("View only") };-    let cell = 32.0;     let text = ui.painter().layout_no_wrap(label.to_string(), crate::theme::semibold(11.5), t.text);     let badge_w = 12.0 + 14.0 + 6.0 + text.size().x + 12.0;-    let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + badge_w + 8.0;+    let width = PAD * 2.0 + 2.0 * (TOOL_CELL + GAP) + DIVIDER + GAP + badge_w;     let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);     let cy = rect.center().y;-    let mut x = rect.min.x + 8.0;+    let mut x = rect.min.x + PAD;     for (tool, icon) in [(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)] {-        let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));+        let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));         let active = app.session.tool.tool == tool;         let resp = tool_cell(ui, &t, r, ("tool", tool.id()), icon, active, &format!("{}  {}", tr(tool.label()), pretty_shortcut(tool.shortcut())));         if active {@@ -376,10 +378,10 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {         if resp.clicked() {             app.run("tool.set", json!({"tool": tool.id()}));         }-        x += cell + 4.0;+        x += TOOL_CELL + GAP;     }     divider(ui, &t, x, cy);-    let badge = Rect::from_min_size(pos2(x + 12.0, cy - 13.0), vec2(badge_w, 26.0));+    let badge = Rect::from_min_size(pos2(x + DIVIDER + GAP, cy - 13.0), vec2(badge_w, 26.0));     let resp = ui.interact(badge, ui.scope_id().with("view_only"), Sense::hover()).on_hover_text(why);     a11y::status(&resp, label, why);     ui.painter().rect_filled(badge, CornerRadius::same(13), t.field);@@ -391,15 +393,14 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) { fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {     let t = Tokens::get(ui.ctx());     let canvas = app.canvas.rect;-    let cell = 32.0;-    let width = VTool::ALL.len() as f32 * (cell + 4.0) + 8.0 + 12.0 + DONE_W + 8.0;+    let width = VTool::ALL.len() as f32 * (TOOL_CELL + GAP) + PAD * 2.0 + DIVIDER + GAP + DONE_W;     let rect = pill(ui, &mut app.ui.nav, canvas, width, &t);     let cy = rect.center().y;-    let mut x = rect.min.x + 8.0;+    let mut x = rect.min.x + PAD;     // Holding Cmd with the Move tool bends: show it.     let held = current == VTool::Move && ui.input(|i| i.modifiers.command);     for tool in VTool::ALL {-        let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));+        let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));         let tip = if tool == VTool::Bend {             trf("{tool}  hold {key}", &[("tool", &tr(tool.label())), ("key", &pretty_shortcut(tool.shortcut()))])         } else {@@ -423,7 +424,7 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {         if resp.clicked() {             app.run("vector.edit.tool", json!({"tool": tool.id()}));         }-        x += cell + 4.0;+        x += TOOL_CELL + GAP;     }     if done_button(ui, &t, x, cy, &format!("{}  Esc", tr("Done editing"))) {         app.run("vector.edit.end", json!({}));@@ -433,7 +434,6 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) { /// The pill shown while the shape builder is active: Merge and Remove modes, a hint and Done. fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {     let t = Tokens::get(ui.ctx());-    let cell = 32.0;     let remove = app.session.tool.builder_remove;     let has_regions = figcraft_engine::shapebuilder::current(&app.session).is_some_and(|a| !a.regions.is_empty());     let alt = pretty_shortcut("Alt");@@ -445,12 +445,12 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {         trf("Click or drag to merge · {alt} removes", &[("alt", &alt)])     };     let galley = ui.painter().layout_no_wrap(hint, regular(12.0), t.text_secondary);-    let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + galley.size().x + 12.0 + 12.0 + DONE_W + 8.0;+    let width = PAD * 2.0 + 2.0 * (TOOL_CELL + GAP) + 2.0 * (DIVIDER + GAP) + galley.size().x + GAP + DONE_W;     let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);     let cy = rect.center().y;-    let mut x = rect.min.x + 8.0;+    let mut x = rect.min.x + PAD;     for (mode, icon, tip) in [("merge", Icon::ShapeBuilder, tr("Merge regions")), ("remove", Icon::ShapeBuilderRemove, tr("Remove regions"))] {-        let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));+        let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL));         let active = (mode == "remove") == remove;         let resp = tool_cell(ui, &t, r, ("sbmode", mode), icon, active, tip);         if active {@@ -459,13 +459,13 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {         if resp.clicked() {             app.run("shapeBuilder.mode", json!({"mode": mode}));         }-        x += cell + 4.0;+        x += TOOL_CELL + GAP;     }     divider(ui, &t, x, cy);-    x += 12.0;+    x += DIVIDER + GAP;     let w = galley.size().x;     ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, t.text_secondary);-    x += w + 12.0;+    x += w + GAP;     if done_button(ui, &t, x, cy, &format!("{}  {}", tr("Done"), pretty_shortcut("Enter"))) {         app.run("tool.set", json!({"tool": "move"}));     }@@ -515,9 +515,10 @@ pub(crate) fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSal     resp } -/// A vertical separator just after `x`.+/// A vertical separator just after `x`: DIVIDER wide, as tall as the pill's content. fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) {-    ui.painter().line_segment([pos2(x + 2.0, cy - 14.0), pos2(x + 2.0, cy + 14.0)], Stroke::new(1.0, t.border));+    let half = TOOL_CELL / 2.0;+    ui.painter().line_segment([pos2(x + DIVIDER / 2.0, cy - half), pos2(x + DIVIDER / 2.0, cy + half)], Stroke::new(DIVIDER, t.border)); }  const DONE_W: f32 = 56.0;@@ -525,7 +526,7 @@ const DONE_W: f32 = 56.0; /// A divider at `x` and the accent Done button after it; true when clicked. fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool {     divider(ui, t, x, cy);-    let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0));+    let done = Rect::from_min_size(pos2(x + DIVIDER + GAP, cy - 14.0), vec2(DONE_W, 28.0));     let resp = ui.interact(done, ui.scope_id().with("tool_done"), Sense::click()).tip(tip);     ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { t.accent });     ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), t.accent_on);@@ -642,12 +643,33 @@ mod tests {         let phone = Rect::from_min_size(pos2(0.0, 0.0), vec2(375.0, 700.0));         let pill = pill_rect(phone, full);         assert!(pill.width() <= phone.width() - 16.0 && pill.min.x >= 8.0 && pill.max.x <= phone.max.x - 8.0, "{pill:?}");+        // Narrower than its items, so it scrolls under a drag or the wheel (#1209).+        assert!(full > pill.width(), "{full} in {}", pill.width());         // A desktop canvas keeps the full width, and a degenerate one doesn't go negative.         let wide = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 800.0));         assert_eq!(pill_rect(wide, full).width(), full);         assert_eq!(pill_rect(Rect::from_min_size(pos2(0.0, 0.0), vec2(4.0, 4.0)), full).width(), 0.0);     } +    /// The width the pill asks for is the width its items take — the groups, Actions, the divider+    /// and the end (the mode switcher, or a board's two buttons), PAD in from either end and one+    /// GAP apart — so its last item ends exactly PAD from its right edge.+    #[test]+    fn the_pill_is_as_wide_as_its_items() {+        let canvas = Rect::from_min_size(pos2(0.0, 0.0), vec2(1400.0, 800.0));+        for (groups, board) in [(GROUPS, false), (BOARD_GROUPS, true)] {+            let pill = pill_rect(canvas, main_width(groups, board));+            let mut x = pill.min.x + PAD;+            for g in groups {+                x += TOOL_CELL + if g.has_flyout() { FLYOUT_GAP + FLYOUT_W } else { 0.0 } + GAP;+            }+            x += TOOL_CELL + GAP; // Actions+            x += DIVIDER + GAP; // the divider+            let end = if board { 2.0 * TOOL_CELL + GAP } else { 4.0 * TOOL_CELL };+            assert!((x + end - (pill.max.x - PAD)).abs() < 0.01, "board {board}: {} vs {}", x + end, pill.max.x - PAD);+        }+    }+     /// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point.     fn widths(ppp: f32, icon: Icon, size: f32) -> Vec<f32> {         let ctx = egui::Context::default();diff --git a/crates/ui-egui/src/widgets/board_picker.rs b/crates/ui-egui/src/widgets/board_picker.rs--- a/crates/ui-egui/src/widgets/board_picker.rs+++ b/crates/ui-egui/src/widgets/board_picker.rs@@ -25,7 +25,8 @@ const GAP: f32 = 8.0; const TITLE_H: f32 = 26.0;  /// The templates button at `at` (its left edge, the toolbar's middle) and, while open, its picker-/// above the toolbar (whose top is `top`). Where the next button starts.+/// above the toolbar (whose top is `top`). The button's right edge: the toolbar puts the next+/// button a GAP after it. pub(crate) fn button(app: &mut FigApp, ui: &mut Ui, at: Pos2, top: f32, t: &Tokens) -> f32 {     let r = Rect::from_min_size(pos2(at.x, at.y - 16.0), vec2(32.0, 32.0));     let open = app.ui.tool_flyout == Some(KEY);@@ -39,7 +40,7 @@ pub(crate) fn button(app: &mut FigApp, ui: &mut Ui, at: Pos2, top: f32, t: &Toke     if app.ui.tool_flyout == Some(KEY) {         picker(app, ui, r, top - 8.0, t);     }-    r.max.x + 4.0+    r.max.x }  fn area_id() -> egui::Id {diff --git a/docs/boards-parity.md b/docs/boards-parity.md--- a/docs/boards-parity.md+++ b/docs/boards-parity.md@@ -27,7 +27,7 @@ all end with the same geometry. | Feature | Status | Notes | |---|---|---| | Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) |-| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Resources, Actions, the Design panel's button |+| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Actions, then the templates and the Design panel's buttons — on the same 32 pt grid as a design file's (no Resources button, as there) | | Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL), connector straight/elbow/curved and its arrow; the Design panel opens with its button or View ▸ Design panel | | Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why | | Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it |diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- a/docs/ui-parity.md+++ b/docs/ui-parity.md@@ -43,7 +43,7 @@ interaction feel not yet compared. | Rename: double click, F2 / Cmd+R, Tab to the next row; several layers open Rename layers (match, regular expressions, current name and number codes, start value, preview) | done | numbered in stacking order, pinned | | Find layers (filter by name) | done | not covered by the documentation | | Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | the line between Pages and Layers drags to size the pages list, kept in the preferences; a long list scrolls in its own area and a page dropped outside the visible list stays put (#272) (`layers_behaviour_tests`) |-| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | Shift+I (Quick insert) and the toolbar's Resources open it (they didn't, #710) |+| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | Shift+I (Quick insert), Alt+2 and View ▸ Panels ▸ Assets open it; the toolbar's Resources button was removed (the left rail's Assets tab and those shortcuts are the ways in) | | Hide when publishing from the Assets panel's context menu | done | a right-click on one of this file's components (also Libraries ▸ Hide when publishing for the selection, `component.hideWhenPublishing {ids?, on?}`): the model's sign of it is the name, so hiding puts a `.` before the name and showing takes the leading `.` and `_` away (one undo step, saved and synced like a rename; a variant or a layer inside hides its whole component or set); a hidden component shows a crossed eye on its tile and a dimmer name, the menu item its check mark; the next publish leaves it out and brings it back when shown again (`hide_publishing_tests`). Library components (read-only here) have no such menu |  ## Selection and transform