From 89d411d50fd673b251ec7cec977543c5a0cf1c61 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 09:29:49 -0500 Subject: [PATCH 1/3] M1: Layers panel component rows in the theme's component tint, at AA contrast (#1903) The Layers panel drew component, variant and instance names and icons in the canvas outline purple whatever the theme (3.1:1 on the dark panel, 2.7:1 on the selected row), and the rows inside a selected component in the plain selection's green. They take the theme's component token (the dark theme's lilac; the light theme's a shade darker than the canvas purple, so it reads at AA on a selected and a hovered row too), and a new component_row_child token paints the rows inside a selected component in a tone of its row. The canvas keeps its purple. Test: layers_component_rows_tests (both themes). Docs: ui-parity themes row, status line and revision history. Co-Authored-By: Claude Fable 5.1 --- .../src/layers_component_rows_tests.rs | 93 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/layers.rs | 40 +++++--- crates/ui-egui/src/theme.rs | 15 ++- docs/ui-parity.md | 5 +- 5 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/layers_component_rows_tests.rs diff --git a/crates/ui-egui/src/layers_component_rows_tests.rs b/crates/ui-egui/src/layers_component_rows_tests.rs new file mode 100644 index 000000000..8b659eac7 --- /dev/null +++ b/crates/ui-egui/src/layers_component_rows_tests.rs @@ -0,0 +1,93 @@ +//! Component rows in the Layers panel (#1903), drawn headless in both themes: a component's name +//! reads at WCAG AA (4.5:1) on the panel and on its selected row, and the rows inside a selected +//! component take a tone of its row, not the plain selection's green. + +use egui::{Color32, Pos2, Shape}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::theme::Tokens; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn color(x: Color32) -> figcraft_color::Color { + figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0) +} + +/// The Layers panel with the component "Button" (holding the rectangle "Label") selected and +/// expanded, in the `dark` or light theme: the frame's shapes and the theme's tokens. +fn drawn(dark: bool) -> (Vec, Tokens) { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "props": {"name": "Button"}})).unwrap()); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20, "parent": f, "props": {"name": "Label"}})).unwrap(); + let c = id(s.execute("component.create", &json!({"ids": [f]})).unwrap()); + s.execute(if dark { "view.themeDark" } else { "view.themeLight" }, &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.expanded.insert(c); + u.frame(vec![]); + let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + (shapes, Tokens::get(&u.ctx)) +} + +/// The colour the name `s` is painted in. +fn name_colour(shapes: &[Shape], s: &str) -> Color32 { + shapes + .iter() + .find_map(|sh| match sh { + Shape::Text(t) if t.galley.text() == s => Some(t.fallback_color), + _ => None, + }) + .unwrap_or_else(|| panic!("no name {s:?} painted")) +} + +/// Where the name `s` is painted (its top left). +fn name_pos(shapes: &[Shape], s: &str) -> Pos2 { + shapes + .iter() + .find_map(|sh| match sh { + Shape::Text(t) if t.galley.text() == s => Some(t.pos), + _ => None, + }) + .unwrap_or_else(|| panic!("no name {s:?} painted")) +} + +/// Whether a rectangle filled in `c` is painted under `p`. +fn rect_under(shapes: &[Shape], c: Color32, p: Pos2) -> bool { + shapes.iter().any(|s| matches!(s, Shape::Rect(r) if r.fill == c && r.rect.contains(p))) +} + +#[test] +fn component_names_read_at_aa_on_the_panel_and_on_their_selected_row() { + for dark in [false, true] { + let (shapes, t) = drawn(dark); + assert_eq!(t.dark, dark); + let col = name_colour(&shapes, "Button"); + assert_eq!(col, t.component, "the theme's component tint (dark {dark})"); + for (what, bg) in [("the panel", t.panel), ("the selected component row", t.component_row), ("a hovered row", t.hover)] { + let ratio = color(col).contrast(color(bg)); + assert!(ratio >= 4.5, "{ratio:.2}:1 on {what} (dark {dark})"); + } + // The layer inside it is a plain layer: the theme's text colour. + assert_eq!(name_colour(&shapes, "Label"), t.text); + } +} + +#[test] +fn rows_inside_a_selected_component_take_a_tone_of_its_row() { + for dark in [false, true] { + let (shapes, t) = drawn(dark); + let button = name_pos(&shapes, "Button"); + let label = name_pos(&shapes, "Label"); + // Checked under each name, by row: the pages list paints the current page in the plain + // selection's `selected_row` too. + assert!(rect_under(&shapes, t.component_row, button), "the component's own row (dark {dark})"); + assert!(rect_under(&shapes, t.component_row_child, label), "the row inside it (dark {dark})"); + assert!(!rect_under(&shapes, t.selected_row, button), "not the plain selection's row (dark {dark})"); + assert!(!rect_under(&shapes, t.selected_row_child, label), "not the plain selection's child tone (dark {dark})"); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e9969c106..f10d5c732 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -134,6 +134,8 @@ mod inspect_fields_tests; #[cfg(test)] mod layers_behaviour_tests; #[cfg(test)] +mod layers_component_rows_tests; +#[cfg(test)] mod layers_drag_tests; #[cfg(test)] mod layers_keyboard_tests; diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 164ec7e4a..9002cd031 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -10,7 +10,7 @@ use serde_json::json; use crate::a11y::{self, Region, RowInfo, Tip}; use crate::i18n::{tr, trf, trn}; use crate::icons::{self, Icon}; -use crate::theme::{ACCENT, COMPONENT, Tokens, regular, semibold}; +use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::{FigApp, LeftTab}; pub const ROW: f32 = 32.0; @@ -735,10 +735,17 @@ fn drag_autoscroll(ui: &mut Ui, dragging: bool) { } } -/// A row's icon or name colour: the component tint (faded when hidden), else `normal` or `faded`. -fn row_color(component: bool, shown: bool, normal: Color32, faded: Color32) -> Color32 { +/// Whether a layer is a component, a variant set or an instance: its rows take the component tint. +fn is_component(kind: &NodeKind) -> bool { + matches!(kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. }) +} + +/// A row's icon or name colour: the theme's component tint (faded when hidden; it reads at AA on +/// the panel and on the selected row, where the canvas purple doesn't, #1903), else `normal` or +/// `faded`. +fn row_color(t: &Tokens, component: bool, shown: bool, normal: Color32, faded: Color32) -> Color32 { if component { - COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 }) + t.component.gamma_multiply(if shown { 1.0 } else { 0.5 }) } else if shown { normal } else { @@ -957,15 +964,16 @@ fn reveal_selection(app: &mut FigApp, doc: &Document, page: NodeId, sel: &[NodeI } /// A row's background: selected, inside a selected layer, or outlined when hovered. -/// `state` is [selected, in_selected, component]. -fn paint_row_bg(ui: &Ui, t: &Tokens, rr: Rect, state: [bool; 3], hovered: bool) { - let [selected, in_selected, is_comp] = state; +/// `state` is [selected, in_selected, component, in_component]: a selected component's row and the +/// rows inside it take the component tint, not the plain selection's green (#1903). +fn paint_row_bg(ui: &Ui, t: &Tokens, rr: Rect, state: [bool; 4], hovered: bool) { + let [selected, in_selected, is_comp, in_comp] = state; if selected { ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row }); } else if in_selected { - ui.painter().rect_filled(rr, CornerRadius::ZERO, t.selected_row_child); + ui.painter().rect_filled(rr, CornerRadius::ZERO, if in_comp { t.component_row_child } else { t.selected_row_child }); } else if hovered { - ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(1.0, if is_comp { COMPONENT } else { ACCENT }), egui::StrokeKind::Inside); + ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(1.0, if is_comp { t.component } else { ACCENT }), egui::StrokeKind::Inside); } } @@ -1087,13 +1095,17 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { locked: n.locked, }; a11y::tree_item(&resp, if n.name.is_empty() { tr(n.kind.label()) } else { &n.name }, &info); - let in_selected = !selected && doc.ancestors(row.id).iter().any(|a| sel.contains(a)); - let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. }); + // The nearest selected layer above this row: inside a selected component, variant set + // or instance, the rows take its tint. + let selected_above = if selected { None } else { doc.ancestors(row.id).into_iter().find(|a| sel.contains(a)) }; + let in_selected = selected_above.is_some(); + let in_comp = selected_above.and_then(|a| doc.node(a)).is_some_and(|a| is_component(&a.kind)); + let is_comp = is_component(&n.kind); let rr = r.shrink2(vec2(8.0, 0.0)); a11y::ring_around(ui, rr, &resp); // Over the row, its eye and padlock included (they sit on top of it). let over = resp.contains_pointer(); - paint_row_bg(ui, &t, rr, [selected, in_selected, is_comp], over || hover == Some(row.id)); + paint_row_bg(ui, &t, rr, [selected, in_selected, is_comp, in_comp], over || hover == Some(row.id)); if over { hovered_row = Some(row.id); } @@ -1115,7 +1127,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } let icon = node_icon(&doc, row.id); let shown = n.visible && !row.dimmed; - let icon_col = row_color(is_comp || icon == Icon::Slot, shown, t.icon, t.text_tertiary); + let icon_col = row_color(&t, is_comp || icon == Icon::Slot, shown, t.icon, t.text_tertiary); icons::paint(ui.painter(), Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0)), icon, icon_col); let name_x = x0 + 36.0; if app.ui.rename.is_some_and(|rn| rn.id == row.id) { @@ -1123,7 +1135,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { rename_done = Some((row.id, new)); } } else { - let col = row_color(is_comp, shown, t.text, t.text_tertiary); + let col = row_color(&t, is_comp, shown, t.text, t.text_tertiary); let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page()); let font = if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) }; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 200dd72bc..173bdde4d 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -29,8 +29,15 @@ pub struct Tokens { pub accent_text: Color32, pub selected_row: Color32, pub selected_row_child: Color32, + /// Component, variant and instance names and icons in the panels: at least 4.5:1 (WCAG AA + /// for text) on the panel, on a hovered row and on `component_row`. The canvas keeps + /// [`COMPONENT`] for its outlines and labels. pub component: Color32, + /// A selected component's row in the Layers panel. pub component_row: Color32, + /// The rows inside a selected component: a tone of its row (`selected_row_child` is the plain + /// selection's). + pub component_row_child: Color32, pub danger: Color32, pub success: Color32, pub warning: Color32, @@ -115,8 +122,11 @@ impl Tokens { accent_text: Color32::from_rgb(0x00, 0x7A, 0x56), selected_row: Color32::from_rgb(0xE0, 0xFB, 0xF1), selected_row_child: Color32::from_rgb(0xF1, 0xFC, 0xF7), - component: COMPONENT, + // A shade darker than the canvas purple: 5.5:1 on white, 4.7:1 on the selected + // component row and 5.0:1 on a hovered row (the canvas purple reaches 3.8:1 and 4.1:1). + component: Color32::from_rgb(0x86, 0x38, 0xF0), component_row: Color32::from_rgb(0xF1, 0xE9, 0xFF), + component_row_child: Color32::from_rgb(0xF8, 0xF4, 0xFF), danger: Color32::from_rgb(0xF2, 0x48, 0x22), success: DEV_READY, warning: Color32::from_rgb(0xFF, 0xCD, 0x29), @@ -155,8 +165,11 @@ impl Tokens { accent_text: Color32::from_rgb(0x52, 0xE9, 0xB7), selected_row: Color32::from_rgb(0x16, 0x42, 0x36), selected_row_child: Color32::from_rgb(0x12, 0x37, 0x2D), + // 5.4:1 on the panel, 4.6:1 on the selected component row, 4.5:1 on a hovered row + // (the canvas purple reaches 3.1:1 and 2.7:1, #1903). component: Color32::from_rgb(0xD2, 0x7C, 0xFF), component_row: Color32::from_rgb(0x3E, 0x30, 0x52), + component_row_child: Color32::from_rgb(0x36, 0x2B, 0x47), danger: Color32::from_rgb(0xF2, 0x48, 0x22), success: DEV_READY, warning: Color32::from_rgb(0xFF, 0xCD, 0x29), diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cc0fc9db8..b541a4649 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); 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 (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); 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; 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) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); 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 (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); 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; 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) · **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: @@ -16,7 +16,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| | UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons; on a Wayland compositor that offers no server-side decorations (GNOME) the window gets winit's Adwaita controls instead (#592; checked by its author on GNOME, KDE not run) | -| 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`) | +| Light and dark themes | done | colours from `theme::Tokens`; component, variant and instance rows in the Layers panel take the theme's component tint, at AA on the panel and on their selected row, and the rows inside a selected component a tone of its row (`layers_component_rows_tests`, #1903); 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 (Shift+Cmd/Ctrl+\\, the Minimize button by the file name) and Show/Hide UI (Cmd/Ctrl+\\) | done | Minimize collapses the navigation bar and both sidebars and keeps the toolbar and the panel widths; "If you select an object while the panels are minimized, the left sidebar remains minimized while the right sidebar expands [...] Once all objects are deselected, the right panel minimizes again" (Explore the navigation bar and left sidebar); a small Expand button at the top left of the canvas puts them back (FigCraft's own, the article names no control for it). Hide UI takes the toolbar too (`panel_width_tests`, `shortcuts_tests`; #1835) | | 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 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) | @@ -178,6 +178,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-03 | patch | M1, #1903: the Layers panel drew component, variant and instance names in the canvas outline purple whatever the theme (3.1:1 on the dark panel, 2.7:1 on the selected row, under WCAG AA's 4.5:1), and the rows inside a selected component in the green of a plain selection. They take the theme's component tint (`Tokens::component`: the dark theme's lilac; the light theme's a shade darker than the canvas purple, so it reads at AA on a selected and a hovered row too), and the rows inside a selected component a tone of its row (`component_row_child`). The selection's own green is FigCraft's and stays; the canvas keeps its purple. Test: `layers_component_rows_tests`. Numbers unchanged. | | 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. | | 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. | | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | From 11d808833c9a863caa59934b156fd2c3c53c4bfd Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 6 Nov 2026 21:48:31 -0500 Subject: [PATCH 2/3] Layers panel: a hidden component row uses the faded colour, keeping the 3:1 floor (#1911 issue) The tint at half alpha composited to about 2.4:1 (dark) and 2.2:1 (light) on the panel, under the 3:1 floor for dim text. Hidden rows now use the faded colour; shown rows keep the tint. Co-Authored-By: Claude Fable 5.1 --- .../src/layers_component_rows_tests.rs | 34 +++++++++++++++++++ crates/ui-egui/src/panels/layers.rs | 17 +++++----- docs/ui-parity.md | 2 +- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/layers_component_rows_tests.rs b/crates/ui-egui/src/layers_component_rows_tests.rs index 8b659eac7..7bb867862 100644 --- a/crates/ui-egui/src/layers_component_rows_tests.rs +++ b/crates/ui-egui/src/layers_component_rows_tests.rs @@ -91,3 +91,37 @@ fn rows_inside_a_selected_component_take_a_tone_of_its_row() { assert!(!rect_under(&shapes, t.selected_row_child, label), "not the plain selection's child tone (dark {dark})"); } } + +/// The Layers panel with the component "Button" hidden and nothing selected. +fn drawn_hidden(dark: bool) -> (Vec, Tokens) { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "props": {"name": "Button"}})).unwrap()); + let c = id(s.execute("component.create", &json!({"ids": [f]})).unwrap()); + s.execute(if dark { "view.themeDark" } else { "view.themeLight" }, &json!({})).unwrap(); + s.execute("node.set", &json!({"id": c, "props": {"visible": false}})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + (shapes, Tokens::get(&u.ctx)) +} + +/// `fg` (premultiplied, as egui paints it) composited over the opaque `bg`. +fn over(fg: Color32, bg: Color32) -> Color32 { + let k = 1.0 - f32::from(fg.a()) / 255.0; + let mix = |f: u8, b: u8| (f32::from(f) + f32::from(b) * k).round().min(255.0) as u8; + Color32::from_rgb(mix(fg.r(), bg.r()), mix(fg.g(), bg.g()), mix(fg.b(), bg.b())) +} + +#[test] +fn a_hidden_component_name_keeps_the_dim_text_floor_of_3_to_1() { + for dark in [false, true] { + let (shapes, t) = drawn_hidden(dark); + assert_eq!(t.dark, dark); + let col = name_colour(&shapes, "Button"); + for (what, bg) in [("the panel", t.panel), ("a hovered row", t.hover)] { + let ratio = color(over(col, bg)).contrast(color(bg)); + assert!(ratio >= 3.0, "hidden component name {col:?} is {ratio:.2}:1 on {what} (dark {dark})"); + } + } +} diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index 13e40aacb..d400eafd1 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -746,16 +746,17 @@ fn is_component(kind: &NodeKind) -> bool { matches!(kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. }) } -/// A row's icon or name colour: the theme's component tint (faded when hidden; it reads at AA on -/// the panel and on the selected row, where the canvas purple doesn't, #1903), else `normal` or -/// `faded`. +/// A row's icon or name colour: the theme's component tint when shown (it reads at AA on the +/// panel and on the selected row, where the canvas purple doesn't, #1903), else `normal` or, when +/// hidden, `faded` (halving the tint's alpha would drop it to about 2.2:1, under the 3:1 floor +/// for dim text). fn row_color(t: &Tokens, component: bool, shown: bool, normal: Color32, faded: Color32) -> Color32 { - if component { - t.component.gamma_multiply(if shown { 1.0 } else { 0.5 }) - } else if shown { - normal - } else { + if !shown { faded + } else if component { + t.component + } else { + normal } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 728e54d36..c58335ad3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -16,7 +16,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| | UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons; on a Wayland compositor that offers no server-side decorations (GNOME) the window gets winit's Adwaita controls instead (#592; checked by its author on GNOME, KDE not run) | -| Light and dark themes | done | colours from `theme::Tokens`; component, variant and instance rows in the Layers panel take the theme's component tint, at AA on the panel and on their selected row, and the rows inside a selected component a tone of its row (`layers_component_rows_tests`, #1903); 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`) | +| Light and dark themes | done | colours from `theme::Tokens`; component, variant and instance rows in the Layers panel take the theme's component tint, at AA on the panel and on their selected row (hidden ones in the faded text colour, at least 3:1), and the rows inside a selected component a tone of its row (`layers_component_rows_tests`, #1903); 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`) | | UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons; tabs reorder by dragging along the strip, and closing one whose file has unsaved changes asks Don't Save / Cancel / Save (Alt+close drops the changes at once, #2032); on a Wayland compositor that offers no server-side decorations (GNOME) the window gets winit's Adwaita controls instead (#592; checked by its author on GNOME, KDE not run) | | 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 (Shift+Cmd/Ctrl+\\, the Minimize button by the file name) and Show/Hide UI (Cmd/Ctrl+\\) | done | Minimize collapses the navigation bar and both sidebars and keeps the toolbar and the panel widths; "If you select an object while the panels are minimized, the left sidebar remains minimized while the right sidebar expands [...] Once all objects are deselected, the right panel minimizes again" (Explore the navigation bar and left sidebar); a small Expand button at the top left of the canvas puts them back (FigCraft's own, the article names no control for it). Hide UI takes the toolbar too (`panel_width_tests`, `shortcuts_tests`; #1835) | From 7ca01645c344d294ebe67a7919eee86eca47f18e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Fri, 6 Nov 2026 22:56:48 -0500 Subject: [PATCH 3/3] Layers panel: one row per item in the parity table, and the hidden component name reaches 3:1 on its selected row (#1911 issue) The merge's docs union left the UI3 layout and light/dark themes rows twice; each is one row again. Dim text (text_tertiary) goes from #8A8A8A to #848484 in the light theme: 2.93:1 became 3.18:1 on the selected component row, and the theme floor test now covers the component rows. Co-Authored-By: Claude Fable 5.1 --- .../src/layers_component_rows_tests.rs | 22 +++++++++++-------- crates/ui-egui/src/theme.rs | 6 +++-- docs/ui-parity.md | 4 +--- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/layers_component_rows_tests.rs b/crates/ui-egui/src/layers_component_rows_tests.rs index 7bb867862..7fc37b7f4 100644 --- a/crates/ui-egui/src/layers_component_rows_tests.rs +++ b/crates/ui-egui/src/layers_component_rows_tests.rs @@ -92,14 +92,14 @@ fn rows_inside_a_selected_component_take_a_tone_of_its_row() { } } -/// The Layers panel with the component "Button" hidden and nothing selected. -fn drawn_hidden(dark: bool) -> (Vec, Tokens) { +/// The Layers panel with the component "Button" hidden, and selected or not. +fn drawn_hidden(dark: bool, selected: bool) -> (Vec, Tokens) { let mut s = Session::new(); let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "props": {"name": "Button"}})).unwrap()); let c = id(s.execute("component.create", &json!({"ids": [f]})).unwrap()); s.execute(if dark { "view.themeDark" } else { "view.themeLight" }, &json!({})).unwrap(); s.execute("node.set", &json!({"id": c, "props": {"visible": false}})).unwrap(); - s.execute("select.set", &json!({"ids": []})).unwrap(); + s.execute("select.set", &json!({"ids": if selected { vec![c] } else { vec![] }})).unwrap(); let mut u = Ui::new(s); u.frame(vec![]); let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes); @@ -116,12 +116,16 @@ fn over(fg: Color32, bg: Color32) -> Color32 { #[test] fn a_hidden_component_name_keeps_the_dim_text_floor_of_3_to_1() { for dark in [false, true] { - let (shapes, t) = drawn_hidden(dark); - assert_eq!(t.dark, dark); - let col = name_colour(&shapes, "Button"); - for (what, bg) in [("the panel", t.panel), ("a hovered row", t.hover)] { - let ratio = color(over(col, bg)).contrast(color(bg)); - assert!(ratio >= 3.0, "hidden component name {col:?} is {ratio:.2}:1 on {what} (dark {dark})"); + for (selected, rows) in [(false, 0), (true, 1)] { + let (shapes, t) = drawn_hidden(dark, selected); + assert_eq!(t.dark, dark); + let col = name_colour(&shapes, "Button"); + let all = [("the panel", t.panel), ("a hovered row", t.hover), ("the selected component row", t.component_row)]; + // Unselected, the row shows the panel (or the hover); selected, its own tinted row. + for (what, bg) in &all[..2 + rows] { + let ratio = color(over(col, *bg)).contrast(color(*bg)); + assert!(ratio >= 3.0, "hidden component name {col:?} is {ratio:.2}:1 on {what} (dark {dark}, selected {selected})"); + } } } } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index d298fcacf..e8a039ba5 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -108,7 +108,9 @@ impl Tokens { text: Color32::from_rgb(0x1E, 0x1E, 0x1E), // 4.7:1 on fields and hovered rows (#F5F5F5), 4.6:1 on selected rows: WCAG AA. text_secondary: Color32::from_rgb(0x6E, 0x6E, 0x6E), - text_tertiary: Color32::from_rgb(0x8A, 0x8A, 0x8A), + // Dim text: 3.2:1 on the selected component row (#F1E9FF, the tightest of the + // rows it lands on; #8A8A8A was 2.93:1 there), still dimmer than secondary text. + text_tertiary: Color32::from_rgb(0x84, 0x84, 0x84), icon: Color32::from_rgb(0x1E, 0x1E, 0x1E), icon_secondary: Color32::from_rgb(0x75, 0x75, 0x75), hover: Color32::from_rgb(0xF5, 0xF5, 0xF5), @@ -507,7 +509,7 @@ mod tests { let theme = if t.dark { "dark" } else { "light" }; // Disabled text (a disabled button's label, placeholders) on panels, fields and // selected rows, still dimmer than secondary text. - for bg in [t.panel, t.field, t.hover, t.selected_row, t.selected_row_child] { + for bg in [t.panel, t.field, t.hover, t.selected_row, t.selected_row_child, t.component_row, t.component_row_child] { let ratio = c(t.text_tertiary).contrast(c(bg)); assert!(ratio >= 3.0, "{theme}: dim text {:?} on {bg:?} is {ratio:.2}:1", t.text_tertiary); assert!(ratio < c(t.text_secondary).contrast(c(bg)), "{theme}: dim text reads dimmer than secondary text"); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index ba99acf61..2bc433a5a 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -15,10 +15,8 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons; on a Wayland compositor that offers no server-side decorations (GNOME) the window gets winit's Adwaita controls instead (#592; checked by its author on GNOME, KDE not run) | -| Light and dark themes | done | colours from `theme::Tokens`; component, variant and instance rows in the Layers panel take the theme's component tint, at AA on the panel and on their selected row (hidden ones in the faded text colour, at least 3:1), and the rows inside a selected component a tone of its row (`layers_component_rows_tests`, #1903); 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`) | +| Light and dark themes | done | colours from `theme::Tokens`; component, variant and instance rows in the Layers panel take the theme's component tint, at AA on the panel and on their selected row (hidden ones in the faded text colour, at least 3:1 on the panel, a hovered row and their selected row), and the rows inside a selected component a tone of its row (`layers_component_rows_tests`, #1903); 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`) | | UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons; tabs reorder by dragging along the strip, and closing one whose file has unsaved changes asks Don't Save / Cancel / Save (Alt+close drops the changes at once, #2032); on a Wayland compositor that offers no server-side decorations (GNOME) the window gets winit's Adwaita controls instead (#592; checked by its author on GNOME, KDE not run) | -| 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 (Shift+Cmd/Ctrl+\\, the Minimize button by the file name) and Show/Hide UI (Cmd/Ctrl+\\) | done | Minimize collapses the navigation bar and both sidebars and keeps the toolbar and the panel widths; "If you select an object while the panels are minimized, the left sidebar remains minimized while the right sidebar expands [...] Once all objects are deselected, the right panel minimizes again" (Explore the navigation bar and left sidebar); a small Expand button at the top left of the canvas puts them back (FigCraft's own, the article names no control for it). Hide UI takes the toolbar too (`panel_width_tests`, `shortcuts_tests`; #1835) | | 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 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) |