Merge pull request #1911 from TrevorBrown-dev/fix/layers-component-contrast

Layers panel: component rows in the theme's component tint, at AA contrast (#1903)
This commit is contained in:
BFlat authored and GitHub committed 2026-11-07 13:12:56 +08:00
commit 9bec55c05a
5 files changed
+179 -19

No files matched your search

@@ -0,0 +1,131 @@
//! 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<Shape>, 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})");
}
}
/// The Layers panel with the component "Button" hidden, and selected or not.
fn drawn_hidden(dark: bool, selected: bool) -> (Vec<Shape>, 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": 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);
(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] {
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})");
}
}
}
}
+2
View File
@@ -138,6 +138,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;
+25 -14
View File
@@ -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;
@@ -799,14 +799,22 @@ 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 {
if component {
COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 })
} else if shown {
normal
} else {
/// 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 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 !shown {
faded
} else if component {
t.component
} else {
normal
}
}
@@ -1042,13 +1050,14 @@ fn hierarchy_bg(roots: &[Option<NodeId>], i: usize, mut rr: Rect, gap: f32) -> O
}
/// A row's background: selected, inside a selected layer, or outlined when hovered.
/// `state` is [selected, in_selected, component].
/// `state` is [selected, in_selected, component]: a selected component's row takes the component
/// tint, not the plain selection's green; the rows inside it are tinted by the caller (#1903).
fn paint_row_bg(ui: &Ui, t: &Tokens, rr: Rect, state: [bool; 3], hovered: bool) {
let [selected, in_selected, is_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 && 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);
}
}
@@ -1183,12 +1192,14 @@ 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 is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. });
let is_comp = is_component(&n.kind);
let rr = r.shrink2(vec2(8.0, 0.0));
let bg = hierarchy_bg(&roots, i - roots_start, rr, pitch - ROW);
let in_selected = !selected && bg.is_some();
if let Some((rect, corners)) = bg {
ui.painter().rect_filled(rect, corners, t.selected_row_child);
// Inside a selected component, variant set or instance the rows take its tint.
let in_comp = roots.get(i - roots_start).copied().flatten().and_then(|r| doc.node(r)).is_some_and(|r| is_component(&r.kind));
ui.painter().rect_filled(rect, corners, if in_comp { t.component_row_child } else { t.selected_row_child });
}
a11y::ring_around(ui, rr, &resp);
// Over the row, its eye and padlock included (they sit on top of it).
@@ -1215,7 +1226,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) {
@@ -1223,7 +1234,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 font = name_font(&doc, page, n);
let name = row_name(n).to_string();
// Room left of the lock and eye buttons (the row is wide enough for the whole
+18 -3
View File
@@ -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,
@@ -101,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),
@@ -115,8 +124,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 +167,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),
@@ -494,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");
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); 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 pinnedLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **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); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); 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 DrawLine truncated
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:
@@ -15,8 +15,8 @@ interaction feel not yet compared.
| Item | Status | Notes |
|---|---|---|
| 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) |
@@ -190,6 +190,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| 2026-11-02 | minor | M8: layer hierarchy backgrounds compute each visible row's selection root once, plus its immediate offscreen neighbors, rather than walking ancestors three times per row. Selection membership uses a set instead of a linear search. The scrolling regression keeps the internal seams square at viewport boundaries. Numbers unchanged. |
| 2026-11-02 | minor | M8: selected layer hierarchies have a continuous background across row spacing, with rounded outer corners; nested selections stay in the same block and separate selected trees keep their own corners. Regressions cover continuous fills, the selected-row highlight above them, scrolling and the original palette's dim-text contrast. Numbers unchanged. |
| 2026-11-03 | patch | M1, #1904: the Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so the variants of an imported component set all read `Size=small, Type=prim…` and a deeply nested name lost its room to the indent. The list is now as wide as its widest name needs (the row cache lays names out in descending order of a bound on their width until the bound falls under the widest found: a list with one long name lays out a handful, names all alike all of them, through egui's galley cache) and scrolls sideways, as the reference app's Layers panel does with its horizontal scrollbar; revealing a layer scrolls up or down only; the eye and padlock sit at the end of the row and scroll with it; a drag released past the list, over the canvas, drops nowhere; a name past 1200 pt still elides (#1650). Tests: `layers_wide_names_tests`. Numbers unchanged. |
| 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-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) |
| 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. |
| 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. |