collective

Browse / github.com/storytold/figcraft / patch

M4: the colour picker opens on the control that opened it, and the icon rhythm

M4: the colour picker opens on the control that opened it, and the icon rhythm **Depends on #789** (`feat/popover`, the shared popover). Merge that first. This branch is based on it, so #789's commits show in this PR's list too — **this PR's own work is the last commit** (`ce2ce75`). ### What it adds The colour picker's top sits on the control that opened it, at every opener: a paint's swatch, a text range's mixed row, a variable's swatch, an effect's colour, the page background, and the + that adds a paint. Before, it kept the height of whichever swatch opened it last. The style picker's EditPaint has no swatch to sit on (the click came from the editor's list), so it

Status
open
Score
0.00
Adopters
0 other operators
Author
yelintong (imported from pull request #822, open)
Base
dd6bd3fe1c11d81b04e075c8a363f8a31fd7fb8d
Size
+1177 −451 in 15 files
Review
pending
Shared
2026-10-28
Id
31cbecc71ea0ece705578c81

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui-egui/src/colorpicker.rs crates/ui-egui/src/colorpicker_selection_tests.rs crates/ui-egui/src/icon_spacing_tests.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/panel_remove_tests.rs crates/ui-egui/src/plugins_ui.rs crates/ui-egui/src/popover.rs crates/ui-egui/src/popover_tests.rs crates/ui-egui/src/right.rs crates/ui-egui/src/selection_colors.rs crates/ui-egui/src/shortcuts.rs crates/ui-egui/src/style_picker.rs crates/ui-egui/src/variables_panel.rs crates/ui-egui/src/widgets.rs docs/ui-parity.md

diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs--- a/crates/ui-egui/src/colorpicker.rs+++ b/crates/ui-egui/src/colorpicker.rs@@ -1,7 +1,7 @@ //! The colour picker popover: paint type, saturation/brightness square, hue and alpha sliders, //! hex and RGB fields, gradient stops, image and pattern fills and document colours. -use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};+use egui::{Align2, Color32, CornerRadius, Id, Mesh, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_color::{     Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint, PatternTile, };@@ -12,7 +12,8 @@ use crate::FigApp; use crate::a11y::Tip; use crate::i18n::{tr, tr_noop, trf}; use crate::icons::{self, Icon};-use crate::theme::{Tokens, doc_c32, regular, semibold};+use crate::popover::{Hold, Open, Popover};+use crate::theme::{Tokens, doc_c32, regular}; use crate::widgets;  #[derive(Clone, Debug, PartialEq)]@@ -450,417 +451,431 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {             finish(app);         }         app.ui.picker = None;+        app.ui.picker_popover = None;         return;     };     let t = Tokens::get(ctx);     let screen = ctx.content_rect();     // A plugin's colour field: beside its window, which sits where the picker otherwise would.-    let beside = match &target {-        PickerTarget::PluginField { plugin, .. } => crate::plugins_ui::field_window(app, ctx, plugin).map(|r| pos2(r.min.x - 248.0, r.min.y)),-        _ => None,+    let open = match &target {+        PickerTarget::PluginField { plugin, .. } => crate::plugins_ui::field_window(app, ctx, plugin)+            .map_or(Open::At(pos2(screen.min.x + 8.0, 140.0)), |r| Open::Anchor(pos2(r.min.x - 8.0, r.min.y))),+        // Its right edge on the Design panel's left one, its top on the swatch that opened it.+        _ => Open::Anchor(pos2(screen.max.x - app.ui.right_width, app.ui.picker_y.unwrap_or(140.0))),     };-    let pos = beside.unwrap_or(pos2(screen.max.x - app.ui.right_width - 260.0, 140.0));-    let mut open = true;-    let mut close_now = false;-    egui::Window::new("color_picker")-        .title_bar(false)-        .fixed_pos(pos)-        .fixed_size(vec2(240.0, 0.0))-        .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(0)))-        .open(&mut open)-        .show(ctx, |ui| {-            ui.spacing_mut().item_spacing = vec2(8.0, 8.0);-            // Header.-            let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover());-            ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text);-            let cr = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0));-            let cresp = ui.interact(cr, Id::new("cp_close"), Sense::click());-            icons::paint(ui.painter(), cr.shrink(6.0), Icon::Close, t.icon);-            if cresp.clicked() {-                close_now = true;-            }-            // Paint type.-            if !matches!(-                target,-                PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }-            ) {-                let kinds = [-                    (Icon::Fill, tr("Solid")),-                    (Icon::GradientLinear, tr("Linear")),-                    (Icon::GradientRadial, tr("Radial")),-                    (Icon::GradientAngular, tr("Angular")),-                    (Icon::GradientDiamond, tr("Diamond")),-                    (Icon::Image, tr("Image")),-                    (Icon::ViewGrid, tr("Pattern")),-                ];-                let active = match &paint.kind {-                    PaintKind::Solid { .. } => 0,-                    PaintKind::Gradient(g) => match g.kind {-                        GradientKind::Linear => 1,-                        GradientKind::Radial => 2,-                        GradientKind::Angular => 3,-                        GradientKind::Diamond => 4,-                    },-                    PaintKind::Image(_) => 5,-                    PaintKind::Pattern(_) => 6,-                };-                ui.horizontal(|ui| {-                    ui.add_space(12.0);-                    if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {-                        let base = paint.preview_color();-                        let mut image_pending = false;-                        paint.kind = match i {-                            0 => PaintKind::Solid { color: base.with_alpha(1.0) },-                            5 => {-                                image_pending = true;-                                let target = target.clone();-                                app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {-                                    let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };-                                    let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };-                                    if let Some(mut paint) = current_paint(app, &target) {-                                        paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;-                                        write(app, &target, &paint, false);-                                    }-                                });+    // The colour picker's own header — title 16 in, the X 8 in from the right — is the shared+    // popover chrome now; the picker keeps its body.+    let mut pop = app.ui.picker_popover.take().unwrap_or_else(|| Popover::new("color_picker", tr("Custom")).pad(0.0));+    // Closing is the shared widget's: the X, Escape and a press outside it. Presses in the panel+    // beside it keep it (swatches and fields there switch the colour it edits); the control that+    // opened it is held too, so pressing it again reaches its own toggle.+    let panel = Rect::from_min_max(pos2(screen.max.x - app.ui.right_width, screen.min.y), screen.max);+    let mut held: Vec<Rect> = vec![panel];+    held.extend(app.ui.picker_button);+    let closed = pop.show(ctx, open, Hold { rects: &held, escape: false }, |ui| {+        ui.spacing_mut().item_spacing = vec2(8.0, 8.0);+        // Paint type.+        if !matches!(+            target,+            PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }+        ) {+            let kinds = [+                (Icon::Fill, tr("Solid")),+                (Icon::GradientLinear, tr("Linear")),+                (Icon::GradientRadial, tr("Radial")),+                (Icon::GradientAngular, tr("Angular")),+                (Icon::GradientDiamond, tr("Diamond")),+                (Icon::Image, tr("Image")),+                (Icon::ViewGrid, tr("Pattern")),+            ];+            let active = match &paint.kind {+                PaintKind::Solid { .. } => 0,+                PaintKind::Gradient(g) => match g.kind {+                    GradientKind::Linear => 1,+                    GradientKind::Radial => 2,+                    GradientKind::Angular => 3,+                    GradientKind::Diamond => 4,+                },+                PaintKind::Image(_) => 5,+                PaintKind::Pattern(_) => 6,+            };+            ui.horizontal(|ui| {+                ui.add_space(12.0);+                if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {+                    let base = paint.preview_color();+                    let mut image_pending = false;+                    paint.kind = match i {+                        0 => PaintKind::Solid { color: base.with_alpha(1.0) },+                        5 => {+                            image_pending = true;+                            let target = target.clone();+                            app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {+                                let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };+                                let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };+                                if let Some(mut paint) = current_paint(app, &target) {+                                    paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;+                                    write(app, &target, &paint, false);+                                }+                            });+                            paint.kind.clone()+                        }+                        6 => match pattern_sources(app, &target).first() {+                            Some((id, _)) => Paint::pattern(id.0).kind,+                            None => {+                                app.toast(tr("Add a layer or component to tile first").to_string());                                 paint.kind.clone()                             }-                            6 => match pattern_sources(app, &target).first() {-                                Some((id, _)) => Paint::pattern(id.0).kind,-                                None => {-                                    app.toast(tr("Add a layer or component to tile first").to_string());-                                    paint.kind.clone()-                                }-                            },-                            k => {-                                let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1];-                                match &paint.kind {-                                    PaintKind::Gradient(g) => {-                                        PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) })-                                    }-                                    _ => PaintKind::Gradient(Gradient::default_for(kind, base)),+                        },+                        k => {+                            let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1];+                            match &paint.kind {+                                PaintKind::Gradient(g) => {+                                    PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) })                                 }+                                _ => PaintKind::Gradient(Gradient::default_for(kind, base)),                             }-                        };-                        if !image_pending {-                            write(app, &target, &paint, false);                         }+                    };+                    if !image_pending {+                        write(app, &target, &paint, false);                     }-                });-            }-            // The paint's own blend mode, with the paints and layers under it.-            if !matches!(-                target,-                PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }-            ) {-                ui.horizontal(|ui| {-                    ui.add_space(16.0);-                    let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));-                    crate::right::menu_popup(&resp).width(200.0).show(|ui| {-                        if let Some(b) = crate::right::blend_menu(ui, false) {-                            paint.blend = b;-                            write(app, &target, &paint, false);-                        }-                    });-                });-            }-            // Gradient stops.-            let mut edit_color: Option<Color> = match &paint.kind {-                PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),-                _ => None,-            };-            let sel_stop_id = stop_id();-            if let PaintKind::Gradient(g) = &paint.kind {-                let mut g = g.clone();-                let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1));-                let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());-                let bar = bar.shrink2(vec2(16.0, 6.0));-                widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0);-                let n = 24;-                for i in 0..n {-                    let a = i as f32 / n as f32;-                    let b = (i + 1) as f32 / n as f32;-                    let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y));-                    ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0)));                 }-                let mut changed = false;-                let mut done = false;-                // The bar first, so the stops on it take their own clicks.-                let bresp = ui.interact(bar, Id::new("cp_bar"), Sense::click());-                for (i, s) in g.stops.clone().iter().enumerate() {-                    let x = bar.min.x + s.position * bar.width();-                    let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0));-                    let resp = ui.interact(r, Id::new(("cp_stopk", i)), Sense::click_and_drag());-                    ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0)));-                    ui.painter().rect_stroke(-                        r,-                        CornerRadius::same(3),-                        Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }),-                        StrokeKind::Outside,-                    );-                    if resp.clicked() || resp.drag_started() {-                        sel = i;-                    }-                    if resp.dragged()-                        && let Some(p) = resp.interact_pointer_pos()-                        && let Some(st) = g.stops.get_mut(i)-                    {-                        st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);-                        changed = true;-                    }-                    if resp.drag_stopped() {-                        done = true;+            });+        }+        // The paint's own blend mode, with the paints and layers under it.+        if !matches!(+            target,+            PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }+        ) {+            ui.horizontal(|ui| {+                ui.add_space(16.0);+                let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));+                crate::right::menu_popup(&resp).width(200.0).show(|ui| {+                    if let Some(b) = crate::right::blend_menu(ui, false) {+                        paint.blend = b;+                        write(app, &target, &paint, false);                     }+                });+            });+        }+        // Gradient stops.+        let mut edit_color: Option<Color> = match &paint.kind {+            PaintKind::Solid { color } => Some(color.with_alpha(color.a * paint.opacity)),+            _ => None,+        };+        let sel_stop_id = stop_id();+        if let PaintKind::Gradient(g) = &paint.kind {+            let mut g = g.clone();+            let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1));+            let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());+            let bar = bar.shrink2(vec2(16.0, 6.0));+            widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0);+            let n = 24;+            for i in 0..n {+                let a = i as f32 / n as f32;+                let b = (i + 1) as f32 / n as f32;+                let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y));+                ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0)));+            }+            let mut changed = false;+            let mut done = false;+            // The bar first, so the stops on it take their own clicks.+            let bresp = ui.interact(bar, Id::new("cp_bar"), Sense::click());+            for (i, s) in g.stops.clone().iter().enumerate() {+                let x = bar.min.x + s.position * bar.width();+                let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0));+                let resp = ui.interact(r, Id::new(("cp_stopk", i)), Sense::click_and_drag());+                ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0)));+                ui.painter().rect_stroke(+                    r,+                    CornerRadius::same(3),+                    Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }),+                    StrokeKind::Outside,+                );+                if resp.clicked() || resp.drag_started() {+                    sel = i;                 }-                // Click on the bar adds a stop.-                if bresp.clicked()-                    && let Some(p) = bresp.interact_pointer_pos()+                if resp.dragged()+                    && let Some(p) = resp.interact_pointer_pos()+                    && let Some(st) = g.stops.get_mut(i)                 {-                    let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);-                    g.stops.push(ColorStop { position: pos, color: g.sample(pos) });-                    sel = g.stops.len() - 1;+                    st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);                     changed = true;+                }+                if resp.drag_stopped() {                     done = true;                 }-                ui.data_mut(|d| d.insert_temp(sel_stop_id, sel));-                if changed {+            }+            // Click on the bar adds a stop.+            if bresp.clicked()+                && let Some(p) = bresp.interact_pointer_pos()+            {+                let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);+                g.stops.push(ColorStop { position: pos, color: g.sample(pos) });+                sel = g.stops.len() - 1;+                changed = true;+                done = true;+            }+            ui.data_mut(|d| d.insert_temp(sel_stop_id, sel));+            if changed {+                paint.kind = PaintKind::Gradient(g.clone());+                write(app, &target, &paint, !done);+            }+            if done {+                finish(app);+            }+            edit_color = g.stops.get(sel).map(|s| s.color);+            ui.horizontal(|ui| {+                ui.add_space(16.0);+                if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 {+                    g.stops.remove(sel);+                    ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize));+                    paint.kind = PaintKind::Gradient(g.clone());+                    write(app, &target, &paint, false);+                }+                if ui.small_button(tr("Flip")).clicked() {+                    for s in &mut g.stops {+                        s.position = 1.0 - s.position;+                    }                     paint.kind = PaintKind::Gradient(g.clone());-                    write(app, &target, &paint, !done);+                    write(app, &target, &paint, false);                 }-                if done {-                    finish(app);+                if ui.small_button(tr("Rotate 90°")).clicked() {+                    let (fx, fy) = g.from;+                    let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx));+                    g.to = rot(g.to);+                    g.width = rot(g.width);+                    paint.kind = PaintKind::Gradient(g.clone());+                    write(app, &target, &paint, false);                 }-                edit_color = g.stops.get(sel).map(|s| s.color);+            });+            // The selected stop's colour can come from a colour variable.+            if let PickerTarget::Paint { fills, index, ids } = &target {+                let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" });+                let stop = g.stops.get(sel).map(|s| s.color);                 ui.horizontal(|ui| {                     ui.add_space(16.0);-                    if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 {-                        g.stops.remove(sel);-                        ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize));-                        paint.kind = PaintKind::Gradient(g.clone());-                        write(app, &target, &paint, false);-                    }-                    if ui.small_button(tr("Flip")).clicked() {-                        for s in &mut g.stops {-                            s.position = 1.0 - s.position;+                    crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| {+                        let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover());+                        ui.painter().rect_filled(r, CornerRadius::same(5), t.field);+                        let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0));+                        if let Some(c) = stop {+                            widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c));+                            let text = trf("Stop {n}  {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]);+                            ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text);+                        }+                    });+                });+            }+        }+        if let PaintKind::Image(img) = &paint.kind {+            let mut img = img.clone();+            ui.horizontal(|ui| {+                ui.add_space(16.0);+                let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None);+                crate::right::menu_popup(&resp).show(|ui| {+                    for (l, m) in [+                        (tr("Fill"), ImageScaleMode::Fill),+                        (tr("Fit"), ImageScaleMode::Fit),+                        (tr("Crop"), ImageScaleMode::Crop),+                        (tr("Tile"), ImageScaleMode::Tile),+                    ] {+                        if crate::right::menu_item(ui, l, "") {+                            img.scale_mode = m;+                            paint.kind = PaintKind::Image(img.clone());+                            write(app, &target, &paint, false);+                            ui.close();                         }-                        paint.kind = PaintKind::Gradient(g.clone());-                        write(app, &target, &paint, false);-                    }-                    if ui.small_button(tr("Rotate 90°")).clicked() {-                        let (fx, fy) = g.from;-                        let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx));-                        g.to = rot(g.to);-                        g.width = rot(g.width);-                        paint.kind = PaintKind::Gradient(g.clone());-                        write(app, &target, &paint, false);                     }                 });-                // The selected stop's colour can come from a colour variable.-                if let PickerTarget::Paint { fills, index, ids } = &target {-                    let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" });-                    let stop = g.stops.get(sel).map(|s| s.color);-                    ui.horizontal(|ui| {-                        ui.add_space(16.0);-                        crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| {-                            let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover());-                            ui.painter().rect_filled(r, CornerRadius::same(5), t.field);-                            let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0));-                            if let Some(c) = stop {-                                widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c));-                                let text = trf("Stop {n}  {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]);-                                ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text);-                            }-                        });-                    });+                if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() {+                    img.rotation = (img.rotation + 1) % 4;+                    paint.kind = PaintKind::Image(img.clone());+                    write(app, &target, &paint, false);                 }-            }-            if let PaintKind::Image(img) = &paint.kind {-                let mut img = img.clone();+            });+            // The image's own colour profile (decoding converts it into the file's).+            let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image)));+            let source = match source.as_deref() {+                None => tr("Unknown — treated as sRGB"),+                Some("") => tr("Embedded profile"),+                Some(name) => name,+            };+            ui.horizontal(|ui| {+                ui.add_space(16.0);+                let text = trf("Color profile: {name}", &[("name", &source)]);+                ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate());+            });+            image_presets(app, ui, &target, &mut paint, img.filters);+            for (label, val) in [+                (tr_noop("Exposure"), img.filters.exposure),+                (tr_noop("Contrast"), img.filters.contrast),+                (tr_noop("Saturation"), img.filters.saturation),+                (tr_noop("Temperature"), img.filters.temperature),+                (tr_noop("Tint"), img.filters.tint),+                (tr_noop("Highlights"), img.filters.highlights),+                (tr_noop("Shadows"), img.filters.shadows),+            ] {                 ui.horizontal(|ui| {                     ui.add_space(16.0);-                    let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None);-                    crate::right::menu_popup(&resp).show(|ui| {-                        for (l, m) in [-                            (tr("Fill"), ImageScaleMode::Fill),-                            (tr("Fit"), ImageScaleMode::Fit),-                            (tr("Crop"), ImageScaleMode::Crop),-                            (tr("Tile"), ImageScaleMode::Tile),-                        ] {-                            if crate::right::menu_item(ui, l, "") {-                                img.scale_mode = m;-                                paint.kind = PaintKind::Image(img.clone());-                                write(app, &target, &paint, false);-                                ui.close();-                            }+                    // Labels share a column so the sliders line up.+                    let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover());+                    ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text);+                    let mut v = val;+                    let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false));+                    if r.changed() {+                        let f = &mut img.filters;+                        match label {+                            "Exposure" => f.exposure = v,+                            "Contrast" => f.contrast = v,+                            "Saturation" => f.saturation = v,+                            "Temperature" => f.temperature = v,+                            "Tint" => f.tint = v,+                            "Highlights" => f.highlights = v,+                            _ => f.shadows = v,                         }-                    });-                    if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() {-                        img.rotation = (img.rotation + 1) % 4;                         paint.kind = PaintKind::Image(img.clone());-                        write(app, &target, &paint, false);+                        write(app, &target, &paint, !r.drag_stopped());+                    }+                    if r.drag_stopped() {+                        finish(app);                     }                 });-                // The image's own colour profile (decoding converts it into the file's).-                let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image)));-                let source = match source.as_deref() {-                    None => tr("Unknown — treated as sRGB"),-                    Some("") => tr("Embedded profile"),-                    Some(name) => name,-                };-                ui.horizontal(|ui| {-                    ui.add_space(16.0);-                    let text = trf("Color profile: {name}", &[("name", &source)]);-                    ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate());-                });-                image_presets(app, ui, &target, &mut paint, img.filters);-                for (label, val) in [-                    (tr_noop("Exposure"), img.filters.exposure),-                    (tr_noop("Contrast"), img.filters.contrast),-                    (tr_noop("Saturation"), img.filters.saturation),-                    (tr_noop("Temperature"), img.filters.temperature),-                    (tr_noop("Tint"), img.filters.tint),-                    (tr_noop("Highlights"), img.filters.highlights),-                    (tr_noop("Shadows"), img.filters.shadows),-                ] {-                    ui.horizontal(|ui| {-                        ui.add_space(16.0);-                        // Labels share a column so the sliders line up.-                        let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover());-                        ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text);-                        let mut v = val;-                        let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false));-                        if r.changed() {-                            let f = &mut img.filters;-                            match label {-                                "Exposure" => f.exposure = v,-                                "Contrast" => f.contrast = v,-                                "Saturation" => f.saturation = v,-                                "Temperature" => f.temperature = v,-                                "Tint" => f.tint = v,-                                "Highlights" => f.highlights = v,-                                _ => f.shadows = v,-                            }-                            paint.kind = PaintKind::Image(img.clone());-                            write(app, &target, &paint, !r.drag_stopped());-                        }-                        if r.drag_stopped() {-                            finish(app);-                        }-                    });-                }             }-            if let PaintKind::Pattern(pt) = paint.kind.clone() {-                pattern_controls(app, ui, &target, &mut paint, pt);+        }+        if let PaintKind::Pattern(pt) = paint.kind.clone() {+            pattern_controls(app, ui, &target, &mut paint, pt);+        }+        if let Some(color) = edit_color {+            let mut hsb = ui+                .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::new("cp_hsb")))+                .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8())+                .map(|(h, s, b, _)| Hsb { h, s, b })+                .unwrap_or_else(|| Hsb::from_color(color));+            let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover());+            let (c1, d1) = sv_square(ui, sq, &mut hsb);+            let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());+            let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0));+            if icons::button(+                &mut ui+                    .new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0)))),+                Icon::Eyedropper,+                24.0,+                false,+                tr("Pick color (I)"),+            )+            .clicked()+            {+                app.ui.picker = None;+                app.run("edit.pickColor", json!({}));+                return;             }-            if let Some(color) = edit_color {-                let mut hsb = ui-                    .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::new("cp_hsb")))-                    .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8())-                    .map(|(h, s, b, _)| Hsb { h, s, b })-                    .unwrap_or_else(|| Hsb::from_color(color));-                let (sq, _) = ui.allocate_exact_size(vec2(240.0, 240.0), Sense::hover());-                let (c1, d1) = sv_square(ui, sq, &mut hsb);-                let (hr, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());-                let hr = Rect::from_min_max(pos2(hr.min.x + 48.0, hr.min.y + 8.0), pos2(hr.max.x - 16.0, hr.max.y - 8.0));-                if icons::button(-                    &mut ui.new_child(-                        egui::UiBuilder::new().max_rect(Rect::from_min_size(pos2(hr.min.x - 36.0, hr.center().y - 12.0), vec2(24.0, 24.0))),-                    ),-                    Icon::Eyedropper,-                    24.0,-                    false,-                    tr("Pick color (I)"),-                )-                .clicked()-                {-                    app.ui.picker = None;-                    app.run("edit.pickColor", json!({}));-                    return;-                }-                let (c2, d2) = hue_slider(ui, hr, &mut hsb);-                let (ar, _) = ui.allocate_exact_size(vec2(240.0, 20.0), Sense::hover());-                let ar = Rect::from_min_max(pos2(ar.min.x + 48.0, ar.min.y + 2.0), pos2(ar.max.x - 16.0, ar.max.y - 6.0));-                let mut new_alpha = None;-                if let Some((a, d)) = alpha_slider(ui, ar, color) {-                    new_alpha = Some((a, d));-                }-                let mut nc = hsb.to_color().with_alpha(color.a);-                let mut done = d1 || d2;-                let mut changed = c1 || c2;-                if let Some((a, d)) = new_alpha {-                    nc = nc.with_alpha(a);-                    changed = true;-                    done |= d;-                }-                // Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha.-                if let Some((c, d)) = model_fields(ui, nc) {-                    nc = c;-                    hsb = Hsb::from_color(nc);-                    changed = true;-                    done |= d;-                }-                ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));-                // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one.-                if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) {-                    gamut_warning(ui, &t);-                }-                if changed {-                    match &mut paint.kind {-                        PaintKind::Solid { color } => {-                            *color = nc.with_alpha(1.0);-                            paint.opacity = nc.a;-                        }-                        PaintKind::Gradient(g) => {-                            let sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0);-                            if let Some(s) = g.stops.get_mut(sel) {-                                s.color = nc;-                            }+            let (c2, d2) = hue_slider(ui, hr, &mut hsb);+            let (ar, _) = ui.allocate_exact_size(vec2(240.0, 20.0), Sense::hover());+            let ar = Rect::from_min_max(pos2(ar.min.x + 48.0, ar.min.y + 2.0), pos2(ar.max.x - 16.0, ar.max.y - 6.0));+            let mut new_alpha = None;+            if let Some((a, d)) = alpha_slider(ui, ar, color) {+                new_alpha = Some((a, d));+            }+            let mut nc = hsb.to_color().with_alpha(color.a);+            let mut done = d1 || d2;+            let mut changed = c1 || c2;+            if let Some((a, d)) = new_alpha {+                nc = nc.with_alpha(a);+                changed = true;+                done |= d;+            }+            // Colour model fields (Hex, RGB, HSL, HSB or CSS) + alpha.+            if let Some((c, d)) = model_fields(ui, nc) {+                nc = c;+                hsb = Hsb::from_color(nc);+                changed = true;+                done |= d;+            }+            ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8())));+            // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one.+            if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) {+                gamut_warning(ui, &t);+            }+            if changed {+                match &mut paint.kind {+                    PaintKind::Solid { color } => {+                        *color = nc.with_alpha(1.0);+                        paint.opacity = nc.a;+                    }+                    PaintKind::Gradient(g) => {+                        let sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0);+                        if let Some(s) = g.stops.get_mut(sel) {+                            s.color = nc;                         }-                        PaintKind::Image(_) | PaintKind::Pattern(_) => {}                     }-                    write(app, &target, &paint, !done);-                }-                if done {-                    finish(app);+                    PaintKind::Image(_) | PaintKind::Pattern(_) => {}                 }+                write(app, &target, &paint, !done);             }-            // Colours used on this page.-            let doc_colors = page_colors(app, ui);-            if !doc_colors.is_empty() {-                ui.horizontal(|ui| {-                    ui.add_space(16.0);-                    widgets::label(ui, tr("On this page"), t.text_secondary);-                });-                // Inset on both sides: `add_space` would only indent the first row of a wrap.-                inset(ui, |ui| {-                    for c in &doc_colors {-                        if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {-                            set_color(app, &target, &mut paint, *c, sel_stop_id, ui);-                        }-                    }-                });+            if done {+                finish(app);             }-            // Recent colours.-            let recent = app.session.recent_colors.clone();-            if !recent.is_empty() {-                ui.horizontal(|ui| {-                    ui.add_space(16.0);-                    widgets::label(ui, tr("Recent"), t.text_secondary);-                });-                inset(ui, |ui| {-                    for c in recent.iter().take(14) {-                        if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {-                            set_color(app, &target, &mut paint, *c, sel_stop_id, ui);-                        }+        }+        // Colours used on this page.+        let doc_colors = page_colors(app, ui);+        if !doc_colors.is_empty() {+            ui.horizontal(|ui| {+                ui.add_space(16.0);+                widgets::label(ui, tr("On this page"), t.text_secondary);+            });+            // Inset on both sides: `add_space` would only indent the first row of a wrap.+            inset(ui, |ui| {+                for c in &doc_colors {+                    if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {+                        set_color(app, &target, &mut paint, *c, sel_stop_id, ui);                     }-                });-            }-            ui.add_space(12.0);-        });-    if !open || close_now || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {-        finish(app);-        app.ui.picker = None;+                }+            });+        }+        // Recent colours.+        let recent = app.session.recent_colors.clone();+        if !recent.is_empty() {+            ui.horizontal(|ui| {+                ui.add_space(16.0);+                widgets::label(ui, tr("Recent"), t.text_secondary);+            });+            inset(ui, |ui| {+                for c in recent.iter().take(14) {+                    if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {+                        set_color(app, &target, &mut paint, *c, sel_stop_id, ui);+                    }+                }+            });+        }+        ui.add_space(12.0);+    });+    if closed {+        close(app);+    } else if app.ui.picker.is_some() {+        // Kept only while the picker is open: a body that closed it (the eyedropper) doesn't leave+        // a stale place for the next picker to inherit.+        app.ui.picker_popover = Some(pop);     }-    let _ = Pos2::ZERO;+}++/// The colour picker closes (its X, Escape, or another popover took over): pending edits commit+/// and the picker with its popover go.+/// Escape with the picker open closes it (the selection stays).+pub fn close_if_open(app: &mut FigApp) -> bool {+    if app.ui.picker.is_none() {+        return false;+    }+    close(app);+    true+}++pub fn close(app: &mut FigApp) {+    finish(app);+    app.ui.picker = None;+    app.ui.picker_popover = None;+    app.ui.picker_button = None;+    app.ui.picker_y = None; }  /// Swatches wrapped in rows inset 16 points from both sides of the picker.@@ -1158,7 +1173,7 @@ mod selection_color_tests {         s.execute("select.set", &json!({"ids":[a,b]})).unwrap();         let mut app = FigApp::new(s, Box::new(NoServices));         let targets = collect(app.session.doc().unwrap(), &[a, b])[0].targets.clone();-        crate::selection_colors::open_picker(&mut app, targets);+        crate::selection_colors::toggle(&mut app, targets, None);         (app, a, b)     } diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs--- a/crates/ui-egui/src/colorpicker_selection_tests.rs+++ b/crates/ui-egui/src/colorpicker_selection_tests.rs@@ -52,3 +52,37 @@ fn picker_closes_when_another_layer_is_selected() {     u.frame(vec![]);     assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker); }++#[test]+fn the_picker_opens_on_the_panel_with_its_top_on_the_swatch() {+    let (mut u, a, _) = two();+    u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] });+    // Where the swatch that opened it sits: the picker's top goes there, its right edge on the+    // Design panel's left one.+    u.app.ui.picker_y = Some(200.0);+    u.frame(vec![]);+    u.frame(vec![]);+    let pop = u.app.ui.picker_popover.as_ref().expect("the picker shows");+    assert!((pop.rect.min.y - 200.0).abs() < 0.5, "its top on the swatch: {:?}", pop.rect);+    let screen = u.ctx.content_rect();+    assert!((pop.rect.max.x - (screen.max.x - u.app.ui.right_width)).abs() < 0.5, "its right edge on the panel's left edge: {:?}", pop.rect);+}++#[test]+fn clicking_the_swatch_again_closes_the_picker() {+    let (mut u, a, _) = two();+    u.app.run("fill.add", json!({"ids": [a], "color": "#00FF00"}));+    let target = PickerTarget::Paint { fills: true, index: 0, ids: vec![a] };+    u.app.toggle_picker(target.clone(), Some(200.0));+    u.frame(vec![]);+    u.frame(vec![]);+    assert_eq!(u.app.ui.picker, Some(target.clone()), "the swatch opens the picker");+    u.app.toggle_picker(target.clone(), Some(200.0));+    u.frame(vec![]);+    assert!(u.app.ui.picker.is_none(), "the same swatch toggles it shut");+    // Another paint of the same layer opens the picker for that one.+    let second = PickerTarget::Paint { fills: true, index: 1, ids: vec![a] };+    u.app.toggle_picker(second.clone(), Some(200.0));+    u.frame(vec![]);+    assert_eq!(u.app.ui.picker, Some(second), "another paint opens the picker for it");+}diff --git a/crates/ui-egui/src/icon_spacing_tests.rs b/crates/ui-egui/src/icon_spacing_tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/icon_spacing_tests.rs@@ -0,0 +1,98 @@+//! The right panel's icon rhythm: a row that opens with a 24-point icon sits it 8 in from the+//! panel's edge, an icon keeps 8 to the next control, and two icons side by side keep 4.++use egui::{Rect, pos2};+use figcraft_engine::Session;+use serde_json::json;++use crate::layers_drag_tests::Ui;++/// Every accesskit node labelled `label` (the icon buttons carry their tips), as egui rects.+fn all_bounds(out: &egui::FullOutput, label: &str) -> Vec<Rect> {+    out.platform_output+        .accesskit_update+        .as_ref()+        .expect("accesskit update")+        .nodes+        .iter()+        .filter(|(_, n)| n.label() == Some(label))+        .filter_map(|(_, n)| n.bounds())+        .map(|b| Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32)))+        .collect()+}++/// The one pair of an eye and a remove on the same row (rows share their x, so the y tells them+/// apart), and the gap between the two icons.+fn pair_gap(out: &egui::FullOutput, eye_label: &str) -> f32 {+    let eyes = all_bounds(out, eye_label);+    let removes = all_bounds(out, "Remove");+    for e in &eyes {+        for r in &removes {+            if (e.min.y - r.min.y).abs() < 1.0 {+                return r.min.x - e.max.x;+            }+        }+    }+    panic!("no eye/remove pair on one row: {eyes:?} {removes:?}");+}++#[test]+fn a_leading_icon_sits_8_in_and_an_icon_pair_keeps_4() {+    let mut s = Session::new();+    let frame =+        figcraft_doc::NodeId(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());+    s.execute("effect.add", &json!({"ids": [frame], "type": "dropShadow"})).unwrap();+    s.execute("select.set", &json!({"ids": [frame]})).unwrap();+    let mut u = Ui::new(s);+    for _ in 0..3 {+        u.frame(vec![]);+    }+    // Accesskit is enabled once the layout has settled: its next frame reports the whole tree.+    u.ctx.enable_accesskit();+    let out = u.frame_output(vec![]);+    let settings = all_bounds(&out, "Effect settings").remove(0);+    // The leading icon sits 8 in from the panel's edge — tighter than the fields' 16 (the panel's+    // border and the accesskit rounding land within a point of it).+    let inset = settings.min.x - (u.ctx.content_rect().max.x - u.app.ui.right_width);+    assert!((8.0..16.0).contains(&inset), "the leading icon sits 8 in, not the fields' 16: {inset}");+    assert!((pair_gap(&out, "Toggle") - 4.0).abs() < 0.5, "an icon pair keeps 4");+}++#[test]+fn a_paint_rows_visibility_and_remove_keep_4() {+    let mut s = Session::new();+    let r = figcraft_doc::NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap());+    s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap();+    s.execute("select.set", &json!({"ids": [r]})).unwrap();+    let mut u = Ui::new(s);+    for _ in 0..3 {+        u.frame(vec![]);+    }+    u.ctx.enable_accesskit();+    let out = u.frame_output(vec![]);+    assert!((pair_gap(&out, "Toggle visibility") - 4.0).abs() < 0.5, "an icon pair keeps 4");+}++/// A row that opens with an icon (the effect and layout-grid rows, inset 8) keeps the margin the+/// Fill and Stroke rows' buttons end at: the icon moves the row's left edge only.+#[test]+fn a_row_that_opens_with_an_icon_ends_its_buttons_where_the_fill_rows_do() {+    let mut s = Session::new();+    let frame =+        figcraft_doc::NodeId(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());+    s.execute("fill.add", &json!({"ids": [frame], "color": "#FF0000"})).unwrap();+    s.execute("effect.add", &json!({"ids": [frame], "type": "dropShadow"})).unwrap();+    s.execute("select.set", &json!({"ids": [frame]})).unwrap();+    let mut u = Ui::new(s);+    for _ in 0..3 {+        u.frame(vec![]);+    }+    u.ctx.enable_accesskit();+    let out = u.frame_output(vec![]);+    let removes = all_bounds(&out, "Remove");+    assert!(removes.len() >= 2, "a fill row's remove and an effect row's: {removes:?}");+    let right = removes[0].max.x;+    for r in &removes {+        assert!((r.max.x - right).abs() < 0.5, "every remove ends at one margin: {removes:?}");+    }+}diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -44,6 +44,7 @@ pub mod missing_fonts; pub mod online; pub mod pending_open; pub mod plugins_ui;+pub mod popover; pub mod present; pub mod proto_canvas; pub mod proto_editor;@@ -117,6 +118,8 @@ mod home_recents_tests; #[cfg(test)] mod home_tests; #[cfg(test)]+mod icon_spacing_tests;+#[cfg(test)] mod idle_tests; #[cfg(test)] mod ime_tests;@@ -179,6 +182,8 @@ mod plugin_panel_tests; #[cfg(test)] mod plugins_browse_tests; #[cfg(test)]+mod popover_tests;+#[cfg(test)] mod popup_width_tests; #[cfg(test)] mod present_links_tests;@@ -550,6 +555,13 @@ pub struct UiState {     /// The prototype preview pane floating over the canvas, when open.     pub preview: Option<present::Pane>,     pub picker: Option<colorpicker::PickerTarget>,+    /// The colour picker's popover while it is open (its place survives a drag).+    pub picker_popover: Option<popover::Popover>,+    /// The control that opened the picker, refreshed every frame it is drawn (the panel scrolls):+    /// a press in it is held out of the popover's closing, so its own click toggles the picker.+    pub picker_button: Option<egui::Rect>,+    /// The screen y of the swatch that opened the colour picker (its top the picker aligns with).+    pub picker_y: Option<f32>,     pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,     /// The context menu was asked for from the keyboard (`view.contextMenu`): it opens at the     /// focused layer row or the canvas selection and takes the keyboard.@@ -595,6 +607,8 @@ pub struct UiState {     pub color_profile: Option<figcraft_color::ColorProfile>,     /// The open style picker.     pub style_picker: Option<style_picker::Picker>,+    /// The style picker's popover while it is open (its place survives a drag).+    pub style_picker_popover: Option<popover::Popover>,     /// The style picker shows colour styles as a grid of swatches.     pub style_grid: bool,     /// Style folders closed in the style picker.@@ -699,6 +713,9 @@ impl Default for UiState {             present_view: present::PresentView::default(),             preview: None,             picker: None,+            picker_popover: None,+            picker_button: None,+            picker_y: None,             context_menu: None,             key_menu: false,             key_box: None,@@ -725,6 +742,7 @@ impl Default for UiState {             pdf_print: figcraft_doc::PrintSettings::default(),             color_profile: None,             style_picker: None,+            style_picker_popover: None,             style_grid: false,             style_folds: BTreeSet::new(),             text_preview: None,@@ -1691,6 +1709,23 @@ impl FigApp {         self.session.live_sync_in(|uid| uid.map_or(0, |u| windows.of(u)) == windows.current);     } +    /// Opens the colour picker for `target`, its top on the control that opened it (`top`, screen+    /// points) — a None keeps the picker at its usual height.+    pub fn open_picker(&mut self, target: crate::colorpicker::PickerTarget, top: Option<f32>) {+        self.ui.picker = Some(target);+        self.ui.picker_y = top;+    }++    /// Clicking the control that opened the colour picker closes it again; clicking another opens+    /// the picker for that one.+    pub fn toggle_picker(&mut self, target: crate::colorpicker::PickerTarget, top: Option<f32>) {+        if self.ui.picker.as_ref() == Some(&target) {+            crate::colorpicker::close(self);+        } else {+            self.open_picker(target, top);+        }+    }+     fn toast_ui(&mut self, ctx: &egui::Context) {         let Some((msg, clock)) = &mut self.ui.toast else { return };         let now = ctx.input(|i| i.time);diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs--- a/crates/ui-egui/src/panel_remove_tests.rs+++ b/crates/ui-egui/src/panel_remove_tests.rs@@ -167,3 +167,36 @@ fn adding_a_fill_to_mixed_fills_opens_no_picker() {     assert_eq!((doc.node(a).unwrap().fills.len(), doc.node(b).unwrap().fills.len()), (2, 3));     assert_eq!(u.app.ui.picker, None); }++/// The shared popover's closing reaches the colour picker: a press on the canvas closes it.+#[test]+fn a_press_on_the_canvas_closes_the_colour_picker() {+    let mut s = Session::new();+    let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());+    s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap();+    s.execute("select.set", &json!({"ids": [r]})).unwrap();+    let mut u = Ui::new(s);+    u.ctx.enable_accesskit();+    let sw = u.ctx.read_response(Id::new(("swatch", true, 0usize))).unwrap().rect.center();+    click(&mut u, sw);+    assert!(u.app.ui.picker.is_some(), "the swatch opens the picker");+    click(&mut u, Pos2::new(320.0, 300.0));+    assert_eq!(u.app.ui.picker, None, "a press on the canvas closes the picker");+}++/// Pressing the swatch that opened the picker again closes it: its press is held out of the+/// popover's outside-press closing, so the swatch's own toggle gets the click.+#[test]+fn the_swatch_that_opened_the_colour_picker_closes_it_again() {+    let mut s = Session::new();+    let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());+    s.execute("fill.add", &json!({"ids": [r], "color": "#FF0000"})).unwrap();+    s.execute("select.set", &json!({"ids": [r]})).unwrap();+    let mut u = Ui::new(s);+    u.ctx.enable_accesskit();+    let sw = u.ctx.read_response(Id::new(("swatch", true, 0usize))).unwrap().rect.center();+    click(&mut u, sw);+    assert!(u.app.ui.picker.is_some(), "the swatch opens the picker");+    click(&mut u, sw);+    assert_eq!(u.app.ui.picker, None, "the same swatch closes it again");+}diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs--- a/crates/ui-egui/src/plugins_ui.rs+++ b/crates/ui-egui/src/plugins_ui.rs@@ -222,7 +222,7 @@ fn panel_windows(app: &mut FigApp, ctx: &Context) {                 app.run("plugin.close", json!({"id": id}));             }             Some(Used::Set(control, value)) => run(app, "plugin.event", json!({"id": id, "control": control, "value": value})),-            Some(Used::Pick(control)) => app.ui.picker = Some(PickerTarget::PluginField { plugin: id, control, pending: None }),+            Some(Used::Pick(control)) => app.open_picker(PickerTarget::PluginField { plugin: id, control, pending: None }, None),         }     } }@@ -546,7 +546,7 @@ fn form_window(app: &mut FigApp, ctx: &Context) {     });     app.ui.plugin_form = (open && !submit && !cancel).then_some(form.clone());     if let Some(control) = pick {-        app.ui.picker = Some(PickerTarget::PluginField { plugin: form.id.clone(), control, pending: None });+        app.open_picker(PickerTarget::PluginField { plugin: form.id.clone(), control, pending: None }, None);     }     if submit {         run(app, "plugin.run", json!({"id": form.id, "command": form.command, "params": form.values}));diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/popover.rs@@ -0,0 +1,277 @@+//! The chrome every detached panel shares: a card with a header — the title 16 points in from its+//! left edge, a 24-point X 8 in from its right — a hairline under the header and the theme's popup+//! shadow, around one slot.+//!+//! The measurements are our own: they are the header the colour picker has always drawn, and the+//! card is as wide as it. The shadow comes from the theme, so the dark theme gets its own. Nothing+//! here is taken from another product's interface.+//!+//! It opens against an anchor (its right edge on it, its top on it, lifted so its bottom keeps off+//! the screen's bottom) or at a point, the header drags it, and it closes itself on the X, on+//! Escape and on a press outside it — except in the rects the caller holds out (the button that+//! opened it, a popover it opened in turn), so that button's own click can toggle it shut instead+//! of the press closing it and the release opening it again.++use egui::epaint::RectShape;+use egui::{Align2, Color32, Context, CornerRadius, Id, Pos2, Rect, Sense, Ui, Vec2, pos2, vec2};++use crate::a11y::Tip;+use crate::i18n::tr;+use crate::icons::{self, Icon};+use crate::theme::{Tokens, semibold};+use crate::widgets;++/// The default width: the colour picker's, and the one every detached panel shares.+pub const WIDTH: f32 = 240.0;+/// The header's height: the title's line and the X sit in it.+pub const HEADER_H: f32 = 40.0;+/// The title starts this far from the panel's left edge, the X ends this far from its right one.+const TITLE_LEFT: f32 = 16.0;+const CLOSE_RIGHT: f32 = 8.0;+/// The X's button (the icon is inset in it).+const CLOSE: f32 = 24.0;+/// The slot's default margin inside the panel.+const SLOT_PAD: f32 = 16.0;+/// A lifted popover keeps this much screen under it; it stays [`EDGE`] inside the others.+const SCREEN_GAP: f32 = 16.0;+const EDGE: f32 = 8.0;+/// The panel's corners.+const RADIUS: u8 = 13;++/// The surface a popover draws on: the panels' own, or the dark one the style picker uses.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Surface {+    Panel,+    Menu,+}++/// Where a popover opens when the caller has not put it down itself.+#[derive(Clone, Copy, Debug, PartialEq)]+pub enum Open {+    /// Its right edge on `x` (the right panel's left edge), its top on `y` (the button's top),+    /// lifted when that would run its bottom past the screen's bottom.+    Anchor(Pos2),+    /// Its top-left exactly here.+    At(Pos2),+}++/// What keeps a popover open while a press or Escape happens somewhere else.+#[derive(Clone, Copy, Default)]+pub struct Hold<'a> {+    /// Presses in these rects do not close it: the button that opened it, the popovers it opened.+    pub rects: &'a [Rect],+    /// Escape belongs to something above it (a field being typed in, a picker of its own).+    pub escape: bool,+}++/// Where the popover shows.+#[derive(Clone, Copy, Debug, PartialEq)]+enum Place {+    /// From [`Open`], until the header drags it.+    Auto,+    /// Here: put down by the caller or by the drag.+    At(Pos2),+}++/// An open popover.+pub struct Popover {+    id: Id,+    /// The header's line.+    pub title: String,+    width: f32,+    pad: f32,+    surface: Surface,+    /// Whether a press outside it closes it.+    outside: bool,+    place: Place,+    /// While the header is held down: where in the panel the pointer grabbed it.+    grab: Option<Vec2>,+    /// The panel, its header, its slot and the X, as drawn last frame.+    pub rect: Rect,+    pub header: Rect,+    pub slot: Rect,+    pub close: Rect,+    /// The panel's size as laid out last frame.+    size: Vec2,+}++impl Popover {+    /// A popover titled `title`; `id` names its egui area, so it must be stable and unique.+    pub fn new(id: impl std::hash::Hash + std::fmt::Debug, title: impl Into<String>) -> Self {+        Self {+            id: Id::new(id),+            title: title.into(),+            width: WIDTH,+            pad: SLOT_PAD,+            surface: Surface::Panel,+            outside: true,+            place: Place::Auto,+            grab: None,+            rect: Rect::NOTHING,+            header: Rect::NOTHING,+            slot: Rect::NOTHING,+            close: Rect::NOTHING,+            size: vec2(WIDTH, 0.0),+        }+    }++    /// `width` points wide instead of [`WIDTH`].+    pub fn width(mut self, width: f32) -> Self {+        self.width = width.max(1.0);+        self.size.x = self.width;+        self+    }++    /// `pad` points of margin around the slot instead of the default 16.+    pub fn pad(mut self, pad: f32) -> Self {+        self.pad = pad.max(0.0);+        self+    }++    /// Drawn on the dark surface the style browser uses.+    pub fn surface(mut self, surface: Surface) -> Self {+        self.surface = surface;+        self+    }++    /// Whether a press outside it closes it (the default). The colour picker stays open, so a+    /// press on a row beside it keeps editing the paint it is on.+    pub fn close_on_outside(mut self, yes: bool) -> Self {+        self.outside = yes;+        self+    }++    /// Its top-left at `pos`, instead of where [`Open`] would put it.+    pub fn at(mut self, pos: Pos2) -> Self {+        self.place = Place::At(pos);+        self+    }++    /// Draws the panel and fills its slot with `body`; true when it asked to close — the X,+    /// Escape or a press outside it (outside [`Hold`]'s rects) on this frame.+    pub fn show(&mut self, ctx: &Context, open: Open, hold: Hold<'_>, body: impl FnOnce(&mut Ui)) -> bool {+        let t = Tokens::get(ctx);+        let dark = self.surface == Surface::Menu;+        let screen = ctx.content_rect();+        // The anchor rule needs the panel's height, which only a layout gives: the first frame+        // lays it out invisibly and the next one places it — a jump would show.+        let first = self.size.y <= 0.0;+        let pos = match self.place {+            Place::At(p) => clamp(p, self.size, screen),+            Place::Auto => {+                let p = match open {+                    Open::At(p) => p,+                    Open::Anchor(a) => open_pos(a, self.size, screen),+                };+                clamp(p, self.size, screen)+            }+        };+        let mut close = false;+        egui::Area::new(self.id).order(egui::Order::Foreground).fixed_pos(pos).show(ctx, |ui| {+            if first {+                ui.set_invisible();+                ctx.request_repaint();+            }+            // The panel is painted into a place kept before the content: the shadow and the fill+            // go under it, though its size is only known once the slot is laid out.+            let bg = ui.painter().add(egui::Shape::Noop);+            let spacing = ui.spacing().item_spacing;+            ui.spacing_mut().item_spacing = Vec2::ZERO;+            // 1. The header: the title, then the X at the right.+            let (header, _) = ui.allocate_exact_size(vec2(self.width, HEADER_H), Sense::hover());+            let close_rect = Rect::from_min_size(pos2(header.max.x - CLOSE_RIGHT - CLOSE, header.center().y - CLOSE / 2.0), Vec2::splat(CLOSE));+            let handle = Rect::from_min_max(header.min, pos2(close_rect.min.x - 6.0, header.max.y));+            let drag = ui.interact(handle, self.id.with("drag"), Sense::drag());+            let font = semibold(11.0);+            let title = widgets::elide_font(ui, &self.title, (handle.width() - TITLE_LEFT - 8.0).max(0.0), font.clone());+            ui.painter().text(pos2(header.min.x + TITLE_LEFT, header.center().y), Align2::LEFT_CENTER, title, font, header_text(t, dark));+            close = close_button(ui, self.id.with("close"), close_rect, dark, t);+            // 2. The header drags the panel (grabbed where the press landed, so the first frame of+            // a fast drag counts too); the X keeps its own clicks.+            if drag.hovered() && ctx.input(|i| i.pointer.primary_pressed()) {+                self.grab = ctx.input(|i| i.pointer.interact_pos()).map(|at| at - pos);+            }+            if drag.dragged()+                && let (Some(grab), Some(at)) = (self.grab, drag.interact_pointer_pos())+            {+                self.place = Place::At(clamp(at - grab, self.size, screen));+            }+            if ctx.input(|i| i.pointer.any_released()) {+                self.grab = None;+            }+            if drag.dragged() || drag.hovered() {+                ui.ctx().set_cursor_icon(if drag.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });+            }+            // 3. The slot, under a hairline.+            let frame = egui::Frame::NONE.inner_margin(egui::Margin::same(self.pad as i8)).show(ui, |ui| {+                ui.spacing_mut().item_spacing = spacing;+                ui.set_width(self.width - 2.0 * self.pad);+                body(ui);+                ui.min_rect()+            });+            let slot = frame.inner;+            let panel = Rect::from_min_size(header.min, vec2(self.width, frame.response.rect.max.y - header.min.y));+            let shadow = ctx.global_style().visuals.popup_shadow;+            let fill = if dark { t.menu } else { t.panel };+            let shapes = vec![+                egui::Shape::from(shadow.as_shape(panel, RADIUS)),+                RectShape::filled(panel, CornerRadius::same(RADIUS), fill).into(),+                // A point of the theme's border colour under the header.+                RectShape::filled(+                    Rect::from_min_max(pos2(panel.min.x, header.max.y - 1.0), pos2(panel.max.x, header.max.y)),+                    CornerRadius::ZERO,+                    if dark { Color32::from_white_alpha(24) } else { t.border },+                )+                .into(),+            ];+            ui.painter().set(bg, egui::Shape::Vec(shapes));+            self.rect = panel;+            self.header = header;+            self.slot = slot;+            self.close = close_rect;+            self.size = panel.size();+        });+        // Closing is the widget's: the X above, Escape and a press outside (a child popup or a+        // held-out rect keeps it open, so the button that opened it can click it shut).+        let popup_open = egui::Popup::is_any_open(ctx);+        let escape = !hold.escape && !popup_open && ctx.input(|i| i.key_pressed(egui::Key::Escape));+        let outside = self.outside+            && !popup_open+            && ctx.input(|i| {+                i.pointer.any_pressed()+                    && i.pointer.interact_pos().is_some_and(|at| !self.rect.contains(at) && !hold.rects.iter().any(|r| r.contains(at)))+            });+        close || escape || outside+    }+}++fn header_text(t: Tokens, dark: bool) -> Color32 {+    if dark { t.menu_text } else { t.text }+}++/// The X: a 24-point button, its icon inset 6 (the colour picker's header).+fn close_button(ui: &mut Ui, id: Id, rect: Rect, dark: bool, t: Tokens) -> bool {+    let resp = ui.interact(rect, id, Sense::click()).tip(tr("Close"));+    if resp.hovered() {+        ui.painter().rect_filled(rect, CornerRadius::same(5), if dark { Color32::from_white_alpha(20) } else { t.hover });+    }+    icons::paint(ui.painter(), rect.shrink(6.0), Icon::Close, if dark { t.menu_text } else { t.icon });+    resp.clicked()+}++/// The opening position: the right edge on `anchor.x` (the right panel's left edge), the top on+/// `anchor.y` (the button's top) — lifted, when that runs its bottom past the screen's bottom, to+/// keep [`SCREEN_GAP`] under it.+fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 {+    let bottom = screen.max.y - SCREEN_GAP;+    let y = if anchor.y + size.y > bottom { bottom - size.y } else { anchor.y };+    pos2(anchor.x - size.x, y)+}++/// Keeps the panel inside the screen: whole when it fits, its top-left corner when it doesn't.+fn clamp(pos: Pos2, size: Vec2, screen: Rect) -> Pos2 {+    let (x0, y0) = (screen.min.x + EDGE, screen.min.y + EDGE);+    let x = pos.x.clamp(x0, (screen.max.x - size.x - EDGE).max(x0));+    let y = pos.y.clamp(y0, (screen.max.y - size.y - EDGE).max(y0));+    pos2(x, y)+}diff --git a/crates/ui-egui/src/popover_tests.rs b/crates/ui-egui/src/popover_tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/popover_tests.rs@@ -0,0 +1,175 @@+//! The shared popover drawn on its own: 240 points wide, a 40-high header with the title 16 in+//! from the left and the X 8 in from the right, the slot under a hairline, the opening rule+//! against an anchor (right edge on it, top aligned, lifted when that would run off the screen),+//! the header dragging it, and closing on the X, Escape and a press outside — but not on a press+//! in a held-out rect, so the button that opened it can toggle it shut.++use egui::{Event, Key, PointerButton, Pos2, RawInput, Rect, pos2, vec2};++use crate::popover::{HEADER_H, Hold, Open, Popover, WIDTH};++const SCREEN: egui::Vec2 = vec2(1440.0, 900.0);++/// A context with the UI's fonts: the header's title is drawn in the semibold family.+fn context() -> egui::Context {+    let ctx = egui::Context::default();+    crate::theme::install_fonts(&ctx, false, 0);+    ctx+}++fn input(t: &mut f64, events: Vec<Event>) -> RawInput {+    *t += 1.0 / 60.0;+    RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SCREEN)), time: Some(*t), events, ..Default::default() }+}++fn press(p: Pos2, pressed: bool) -> Vec<Event> {+    vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }]+}++fn escape() -> Vec<Event> {+    vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]+}++/// One frame of `p` opened at `open`, its slot holding a row: the shapes it painted and whether it+/// asked to close.+fn draw(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open, hold: Hold<'_>, events: Vec<Event>) -> (Vec<egui::Shape>, bool) {+    let mut closed = false;+    let mut out = ctx.run_ui(input(t, events), |ui| {+        closed = p.show(ui.ctx(), open, hold, |ui| {+            ui.label("slot");+        });+    });+    out.textures_delta.clear();+    (crate::live_tests::flat(&out.shapes), closed)+}++/// Two frames: the first lays the panel out to learn its height, the second places and paints it.+fn settle(ctx: &egui::Context, t: &mut f64, p: &mut Popover, open: Open) -> Vec<egui::Shape> {+    draw(ctx, t, p, open, Hold::default(), vec![]);+    draw(ctx, t, p, open, Hold::default(), vec![]).0+}++fn near(a: Pos2, b: Pos2) -> bool {+    (a - b).length() < 0.05+}++#[test]+fn the_header_is_40_high_with_the_title_16_in_and_the_x_8_in() {+    let ctx = context();+    let mut t = 0.0;+    let mut p = Popover::new("popover_header", "Custom");+    let shapes = settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));+    assert_eq!(p.rect.width(), WIDTH, "240 points wide");+    assert!(near(p.rect.min, pos2(160.0, 100.0)), "where it was put: {:?}", p.rect);+    assert_eq!(p.header.height(), HEADER_H, "the header is 40 high");+    // The X: 24 square, its right edge 8 in from the panel's, centred on the header's line.+    assert!((p.close.max.x - (p.rect.max.x - 8.0)).abs() < 0.01, "the X is 8 in from the right: {:?}", p.close);+    assert!((p.close.center().y - p.header.center().y).abs() < 0.01, "the X is on the header's line");+    // The title: one line, 16 in from the left, on the header's middle.+    let (_, at, size) = crate::live_tests::texts(&shapes).into_iter().find(|(s, _, _)| s == "Custom").expect("the title shows");+    assert!((at.x - (p.rect.min.x + 16.0)).abs() < 0.5, "the title starts 16 in: {at:?}");+    assert!((at.y + size.y / 2.0 - p.header.center().y).abs() < 1.0, "the title is on the header's line: {at:?}");+    // The slot: 16 in from the panel's sides, under the header.+    assert!((p.slot.min.x - (p.rect.min.x + 16.0)).abs() < 0.01, "the slot starts 16 in: {:?}", p.slot);+    assert!((p.slot.min.y - (p.header.max.y + 16.0)).abs() < 0.01, "the slot starts under the header: {:?}", p.slot);+}++#[test]+fn it_opens_with_its_right_edge_on_the_anchor_and_its_top_aligned() {+    let ctx = context();+    let mut t = 0.0;+    let mut p = Popover::new("popover_anchor", "Popup");+    settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 200.0)));+    assert!((p.rect.max.x - 900.0).abs() < 0.01, "its right edge on the anchor: {:?}", p.rect);+    assert!((p.rect.min.y - 200.0).abs() < 0.01, "its top on the anchor: {:?}", p.rect);+}++#[test]+fn it_lifts_so_its_bottom_keeps_16_from_the_screens_bottom() {+    let ctx = context();+    let mut t = 0.0;+    let mut p = Popover::new("popover_lift", "Popup");+    settle(&ctx, &mut t, &mut p, Open::Anchor(pos2(900.0, 860.0)));+    // Aligning the top would run it off: it rises so its bottom sits 16 above the screen's.+    assert!((p.rect.max.y - (SCREEN.y - 16.0)).abs() < 0.01, "its bottom is 16 above the screen's: {:?}", p.rect);+    assert!(p.rect.min.y < 860.0, "it was lifted: {:?}", p.rect);+}++#[test]+fn the_header_drags_it_and_the_slot_does_not() {+    let ctx = context();+    let mut t = 0.0;+    let mut p = Popover::new("popover_drag", "Popup");+    settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));+    let at = Open::At(pos2(160.0, 100.0));+    let (from, to) = (pos2(200.0, 120.0), pos2(250.0, 180.0));+    let before = p.rect.min;+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);+    assert!(near(p.rect.min, before + (to - from)), "the header dragged it by the pointer's move: {:?} from {before:?}", p.rect.min);+    // The slot does not drag it.+    let (from, to) = (p.slot.center(), p.slot.center() + vec2(-70.0, -40.0));+    let held = p.rect.min;+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);+    assert!(near(p.rect.min, held), "a press in the slot leaves it: {:?} from {held:?}", p.rect.min);+}++#[test]+fn the_x_and_escape_close_it() {+    let ctx = context();+    let mut t = 0.0;+    let mut p = Popover::new("popover_close", "Popup");+    settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));+    let at = Open::At(pos2(160.0, 100.0));+    let close = p.close.center();+    assert!(!draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, true)).1, "a press is not a click yet");+    assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), press(close, false)).1, "the X asks for the close");+    assert!(draw(&ctx, &mut t, &mut p, at, Hold::default(), escape()).1, "Escape asks for the close");+    // Escape that belongs to something above it does not.+    let mut p = Popover::new("popover_escape_held", "Popup");+    settle(&ctx, &mut t, &mut p, Open::At(pos2(160.0, 100.0)));+    let hold = Hold { rects: &[], escape: true };+    assert!(!draw(&ctx, &mut t, &mut p, at, hold, escape()).1, "a held Escape leaves it open");+}++#[test]+fn a_press_outside_closes_it_but_not_on_the_button_that_opened_it() {+    let ctx = context();+    let mut t = 0.0;+    let mut p = Popover::new("popover_outside", "Popup");+    settle(&ctx, &mut t, &mut p, Open::At(pos2(300.0, 200.0)));+    let at = Open::At(pos2(300.0, 200.0));+    // The button that opened it: a press there leaves it open, so its release can toggle it shut.+    let opener = Rect::from_min_size(pos2(60.0, 60.0), vec2(24.0, 24.0));+    let hold = Hold { rects: &[opener], escape: false };+    let on_opener = opener.center();+    assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, true)).1, "a press on the opener leaves it open");+    assert!(!draw(&ctx, &mut t, &mut p, at, hold, press(on_opener, false)).1, "and so does its release");+    // A press anywhere else closes it, before the release.+    let elsewhere = pos2(900.0, 400.0);+    assert!(draw(&ctx, &mut t, &mut p, at, hold, press(elsewhere, true)).1, "a press outside closes it");+}++#[test]+fn a_caller_can_put_it_where_it_likes() {+    let ctx = context();+    let mut t = 0.0;+    // Placed outright, the opening rule no longer matters.+    let mut p = Popover::new("popover_at", "Popup").at(pos2(300.0, 250.0));+    settle(&ctx, &mut t, &mut p, Open::At(pos2(900.0, 100.0)));+    assert!(near(p.rect.min, pos2(300.0, 250.0)), "where the caller put it: {:?}", p.rect);+    // Nor after a drag: it stays where it was dropped.+    let at = Open::At(pos2(900.0, 100.0));+    let (from, to) = (pos2(340.0, 270.0), pos2(400.0, 320.0));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(from, true));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![Event::PointerMoved(to)]);+    draw(&ctx, &mut t, &mut p, at, Hold::default(), press(to, false));+    draw(&ctx, &mut t, &mut p, at, Hold::default(), vec![]);+    assert!(near(p.rect.min, pos2(360.0, 300.0)), "dropped where the drag left it: {:?}", p.rect);+}diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs--- a/crates/ui-egui/src/right.rs+++ b/crates/ui-egui/src/right.rs@@ -318,16 +318,28 @@ pub(crate) fn row(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) {  /// [`row`], returning the area the row takes. pub(crate) fn row_rect(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) -> Rect {-    block(ui, h, (h - FIELD_H) / 2.0, add)+    row_inset(ui, h, PAD, add)+}++/// [`row_rect`] whose content starts `inset_x` from the panel's edge: a row that opens with a+/// 24-point icon sits it 8 in, where the other rows' fields start at 16.+pub(crate) fn row_inset(ui: &mut Ui, h: f32, inset_x: f32, add: impl FnOnce(&mut Ui)) -> Rect {+    block_inset(ui, h, inset_x, (h - FIELD_H) / 2.0, add) }  /// A panel row `h` tall whose content is inset `inset_y` from its top and bottom (for content /// taller than one field); returns the area the row takes. pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) -> Rect {+    block_inset(ui, h, PAD, inset_y, add)+}++pub(crate) fn block_inset(ui: &mut Ui, h: f32, inset_x: f32, inset_y: f32, add: impl FnOnce(&mut Ui)) -> Rect {     let w = ui.available_width();     let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());-    let mut child =-        ui.new_child(egui::UiBuilder::new().max_rect(r.shrink2(vec2(PAD, inset_y))).layout(egui::Layout::left_to_right(egui::Align::Center)));+    // `inset_x` moves the row's left edge only: its buttons end at the same margin as every other+    // row's, so a row that opens with an icon keeps the panel's right edge.+    let inner = Rect::from_min_max(pos2(r.min.x + inset_x, r.min.y + inset_y), pos2(r.max.x - PAD, r.max.y - inset_y));+    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)));     child.spacing_mut().item_spacing = vec2(8.0, 0.0);     add(&mut child);     r@@ -400,11 +412,14 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {     };     widgets::section_header(ui, tr("Page"), &[], false);     let mut open_picker = false;+    let mut swatch_top = None;     let mut new_hex = None;     row(ui, 32.0, |ui| {         let p = Paint::solid(bg);-        if widgets::swatch(ui, &p, 16.0).clicked() {+        let sw = widgets::swatch(ui, &p, 16.0);+        if sw.clicked() {             open_picker = true;+            swatch_top = Some(sw.rect.min.y);         }         if let Some(h) = widgets::text_field(ui, Id::new("pagebg"), &bg.to_hex(), 96.0, "") {             new_hex = Some(h);@@ -412,7 +427,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) {         widgets::label(ui, "100 %", t.text);     });     if open_picker {-        app.ui.picker = Some(crate::colorpicker::PickerTarget::PageBackground);+        app.toggle_picker(crate::colorpicker::PickerTarget::PageBackground, swatch_top);     }     if let Some(h) = new_hex         && let Some(c) = figcraft_color::Color::from_hex_or_name(&h)@@ -1054,8 +1069,9 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start:         match fills.as_ref().and_then(|f| f.first()) {             Some(p) => {                 widgets::paint_swatch(ui.painter(), sw, p);-                if ui.interact(sw.expand(2.0), Id::new("range_swatch"), Sense::click()).clicked() {-                    app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end });+                let range_swatch = ui.interact(sw.expand(2.0), Id::new("range_swatch"), Sense::click());+                if range_swatch.clicked() {+                    app.toggle_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(range_swatch.rect.min.y));                 }                 if let Some(c) = p.solid_color() {                     let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));@@ -1071,8 +1087,10 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start:             }             None => {                 ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, tr("Mixed"), regular(11.0), t.text_secondary);-                if ui.interact(r, Id::new("range_mixed"), Sense::click()).clicked() {-                    app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end });+                let mixed = ui.interact(r, Id::new("range_mixed"), Sense::click());+                if mixed.clicked() {+                    // Its top on this row: never the height of whichever swatch opened the picker last.+                    app.open_picker(crate::colorpicker::PickerTarget::TextRange { id, start, end }, Some(mixed.rect.min.y));                 }             }         }@@ -1098,6 +1116,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:     let title = if fills { tr("Fill") } else { tr("Stroke") };     let empty = list.as_ref().is_none_or(|l| l.is_empty());     let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke };+    let header_top = ui.cursor().min.y;     if let Some(i) = widgets::section_header(         ui,         title,@@ -1109,7 +1128,8 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills:             if app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})).is_some()                 && let Some(l) = &list             {-                app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: l.len(), ids: ids.clone() });+                // The + button that added it sits 8 points into the 40-high header row.+                app.open_picker(crate::colorpicker::PickerTarget::Paint { fills, index: l.len(), ids: ids.clone() }, Some(header_top + 8.0));             }         } else {             style_picker::open(app, ui, &ids, slot);@@ -1537,14 +1557,17 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,     let ids = ids.to_vec();     let cmd = if fills { "fill.set" } else { "stroke.set" };     row_rect(ui, 32.0, |ui| {-        let w = ui.available_width() - 56.0;+        let w = ui.available_width() - 52.0;         let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover());         ui.painter().rect_filled(r, CornerRadius::same(5), t.field);         let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));         widgets::paint_swatch(ui.painter(), sw, p);         let sresp = ui.interact(sw.expand(2.0), Id::new(("swatch", fills, i)), Sense::click());+        if app.ui.picker.as_ref() == Some(&crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }) {+            app.ui.picker_button = Some(sresp.rect);+        }         if sresp.clicked() {-            app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() });+            app.toggle_picker(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, Some(sresp.rect.min.y));         }         let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y));         let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));@@ -1605,11 +1628,18 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,             }             None => {}         }-        if icons::button(ui, if p.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle visibility")).clicked() {-            app.run(cmd, json!({"ids": ids, "index": i, "visible": !p.visible}));-        }-        if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked()-            && app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": ids, "index": i})).is_some()+        // The eye and the remove keep 4 to each other (an icon pair).+        let remove = widgets::icon_pair(+            ui,+            |ui| {+                if icons::button(ui, if p.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle visibility")).clicked() {+                    app.run(cmd, json!({"ids": &ids, "index": i, "visible": !p.visible}));+                }+            },+            |ui| icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")),+        );+        if remove.clicked()+            && app.run(if fills { "fill.remove" } else { "stroke.remove" }, json!({"ids": &ids, "index": i})).is_some()             && let Some(crate::colorpicker::PickerTarget::Paint { fills: f, index, .. }) = &mut app.ui.picker             && *f == fills         {@@ -1650,12 +1680,12 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {     let mut removed = None;     for (i, e) in list.iter().enumerate().rev() {         let mut open = effect_open(ui.ctx(), i);-        let header = row_rect(ui, 32.0, |ui| {+        let header = row_inset(ui, 32.0, 8.0, |ui| {             if icons::button(ui, Icon::Effects, 24.0, open, tr("Effect settings")).clicked() {                 open = !open;                 ui.data_mut(|d| d.insert_temp(Id::new((FX_OPEN, i)), open));             }-            let w = ui.available_width() - 56.0;+            let w = ui.available_width() - 52.0;             let resp = widgets::dropdown(ui, tr(e.label()), w, None);             menu_popup(&resp).show(|ui| {                 for (l, ty) in Effect::TYPES {@@ -1667,14 +1697,19 @@ fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {                     }                 }             });-            if icons::button(ui, if e.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle")).clicked() {-                let mut e2 = e.clone();-                e2.visible = !e2.visible;-                app.run("effect.set", json!({"ids": ids, "index": i, "effect": e2}));-            }-            if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked()-                && app.run("effect.remove", json!({"ids": ids, "index": i})).is_some()-            {+            // The eye and the remove keep 4 to each other (an icon pair).+            let remove = widgets::icon_pair(+                ui,+                |ui| {+                    if icons::button(ui, if e.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle")).clicked() {+                        let mut e2 = e.clone();+                        e2.visible = !e2.visible;+                        app.run("effect.set", json!({"ids": &ids, "index": i, "effect": e2}));+                    }+                },+                |ui| icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")),+            );+            if remove.clicked() && app.run("effect.remove", json!({"ids": &ids, "index": i})).is_some() {                 removed = Some(i);             }         });@@ -1927,8 +1962,9 @@ fn tweaked(e: &Effect, f: impl FnOnce(&mut EffectKind)) -> Effect { /// duo noise's second colour), in the current row; `edit` applies a new one. fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, secondary: bool, edit: &ColorEdit<'_>) {     let Some(c) = e.color(secondary) else { return };-    if widgets::swatch(ui, &Paint::solid(c), 16.0).clicked() {-        app.ui.picker = Some(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary });+    let sw = widgets::swatch(ui, &Paint::solid(c), 16.0);+    if sw.clicked() {+        app.toggle_picker(crate::colorpicker::PickerTarget::Effect { ids: ids.to_vec(), index: i, secondary }, Some(sw.rect.min.y));     }     if let Some(h) = widgets::text_field(ui, Id::new(("fx_c", i, secondary)), &c.to_hex(), 80.0, "")         && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h)@@ -1962,12 +1998,12 @@ fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {     for (i, g) in list.iter().enumerate().rev() {         let open_id = Id::new(("grid_open", i));         let mut open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);-        let header = row_rect(ui, 32.0, |ui| {+        let header = row_inset(ui, 32.0, 8.0, |ui| {             if icons::button(ui, Icon::Grid, 24.0, open, tr("Layout grid settings")).clicked() {                 open = !open;                 ui.data_mut(|d| d.insert_temp(open_id, open));             }-            let w = ui.available_width() - 56.0;+            let w = ui.available_width() - 52.0;             let resp = widgets::dropdown(ui, &grid_label(g), w, None);             menu_popup(&resp).width(220.0).show(|ui| {                 for (l, kind) in [(tr("Grid"), GridKind::Grid), (tr("Columns"), GridKind::Columns), (tr("Rows"), GridKind::Rows)] {@@ -1982,12 +2018,17 @@ fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {                     }                 }             });-            if icons::button(ui, if g.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle")).clicked() {-                app.set_props(&ids, json!({"grids": grids_with(&list, i, |x| x.visible = !x.visible)}));-            }-            if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")).clicked()-                && app.run("grid.remove", json!({"ids": ids, "index": i})).is_some()-            {+            // The eye and the remove keep 4 to each other (an icon pair).+            let remove = widgets::icon_pair(+                ui,+                |ui| {+                    if icons::button(ui, if g.visible { Icon::Eye } else { Icon::EyeOff }, 24.0, false, tr("Toggle")).clicked() {+                        app.set_props(&ids, json!({"grids": grids_with(&list, i, |x| x.visible = !x.visible)}));+                    }+                },+                |ui| icons::button(ui, Icon::Minus, 24.0, false, tr("Remove")),+            );+            if remove.clicked() && app.run("grid.remove", json!({"ids": &ids, "index": i})).is_some() {                 removed = Some(i);             }         });diff --git a/crates/ui-egui/src/selection_colors.rs b/crates/ui-egui/src/selection_colors.rs--- a/crates/ui-egui/src/selection_colors.rs+++ b/crates/ui-egui/src/selection_colors.rs@@ -44,9 +44,9 @@ fn colors(ui: &Ui, document: u64, doc: &Arc<Document>, ids: &[NodeId], interacti     colors } -pub(crate) fn open_picker(app: &mut FigApp, targets: Vec<PaintTarget>) {+pub(crate) fn toggle(app: &mut FigApp, targets: Vec<PaintTarget>, top: Option<f32>) {     let Ok(ds) = app.session.ds() else { return };-    app.ui.picker = Some(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid });+    app.toggle_picker(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }, top); }  pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId]) {@@ -66,7 +66,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId                     crate::right::row(ui, 32.0, |ui| {                         let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour"));                         if swatch.clicked() {-                            open_picker(app, group.targets.clone());+                            toggle(app, group.targets.clone(), Some(swatch.rect.min.y));                         }                         let width = (ui.available_width() - 116.0).max(40.0);                         let linked_name = group@@ -76,7 +76,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId                             .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str())));                         if let Some(name) = linked_name {                             if widgets::dropdown(ui, name, width, None).clicked() {-                                open_picker(app, group.targets.clone());+                                toggle(app, group.targets.clone(), None);                             }                         } else if let Some(color) = group.paint.solid_color() {                             if let Some(hex) = widgets::text_field_named(ui, ui.id().with("hex"), &color.to_hex(), width, tr("Color")) {@@ -94,7 +94,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId                         } else {                             let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None);                             if response.clicked() {-                                open_picker(app, group.targets.clone());+                                toggle(app, group.targets.clone(), Some(response.rect.min.y));                             }                         }                         let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0));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@@ -241,6 +241,11 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re     if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) {         return true;     }+    // The colour picker open: Escape closes it here, before the binding deselects — unless one of+    // its own dropdowns is open, which takes this Escape.+    if key == Key::Escape && !egui::Popup::is_any_open(ctx) && crate::colorpicker::close_if_open(app) {+        return true;+    }     if key_box(app, key, m) {         return true;     }diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs--- a/crates/ui-egui/src/style_picker.rs+++ b/crates/ui-egui/src/style_picker.rs@@ -19,11 +19,13 @@ use crate::a11y::Tip; use crate::colorpicker::PickerTarget; use crate::i18n::{tr, trf}; use crate::icons::{self, Icon};+use crate::popover::{Hold, Open, Popover, Surface}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets; -/// Content width of the popover.-const W: f32 = 240.0;+/// The slot's margin inside the shared popover, and the content width left of it.+const PAD: f32 = 8.0;+const W: f32 = 224.0; /// Height of a list row. const ROW_H: f32 = 26.0; /// Height of a text style's list row (its name in its own font).@@ -167,6 +169,9 @@ impl Picker { /// Open the picker for `slot` of `ids`, level with the pointer. pub fn open(app: &mut FigApp, ui: &Ui, ids: &[NodeId], slot: StyleSlot) {     let y = ui.input(|i| i.pointer.interact_pos()).map_or(140.0, |p| p.y - 12.0);+    // Opening the style picker closes the colour picker (it would linger beside it).+    crate::colorpicker::close(app);+    app.ui.style_picker_popover = None;     app.ui.style_picker = Some(Picker { slot, ids: ids.to_vec(), y, query: String::new(), edit: None }); } @@ -174,6 +179,8 @@ pub fn open(app: &mut FigApp, ui: &Ui, ids: &[NodeId], slot: StyleSlot) { pub fn open_for_selection(app: &mut FigApp, slot: StyleSlot, style: Option<&str>) {     let ids = app.session.selection();     let edit = style.and_then(|s| app.session.doc().ok()?.style(s).map(editing));+    crate::colorpicker::close(app);+    app.ui.style_picker_popover = None;     app.ui.style_picker = Some(Picker { slot, ids, y: 140.0, query: String::new(), edit }); } @@ -516,9 +523,8 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut     let link = link_of(app, doc, &p.ids, slot);     let applied = link.clone().flatten().map(|id| Key::of_local(doc, &id));     let grid = slot.has_grid() && app.ui.style_grid;-    // Header: title, then view toggle, detach and create.+    // The title is the popover's; this row keeps the view toggle, detach and create.     let (hr, _) = ui.allocate_exact_size(vec2(W, 24.0), Sense::hover());-    ui.painter().text(pos2(hr.min.x + 2.0, hr.center().y), Align2::LEFT_CENTER, slot.title(), semibold(11.0), t.menu_text);     let mut x = hr.max.x - 24.0;     let mut slot_rect = || {         let r = Rect::from_min_size(pos2(x, hr.min.y), vec2(24.0, 24.0));@@ -777,7 +783,6 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { /// Editing one style: name, description, its values (paints open the colour picker; text styles /// have their typography), update from the selection, delete. fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {-    let t = Tokens::get(ui.ctx());     let slot = p.slot;     let has_selection = !p.ids.is_empty();     let Some(e) = p.edit.as_mut() else { return };@@ -789,7 +794,6 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {     if menu_icon(ui, Rect::from_min_size(hr.min, vec2(24.0, 24.0)), Icon::ChevronLeft, tr("Back to styles"), false).clicked() {         out.act = Some(Act::Back);     }-    ui.painter().text(pos2(hr.min.x + 30.0, hr.center().y), Align2::LEFT_CENTER, tr("Edit style"), semibold(11.0), t.menu_text);     ui.add_space(6.0);     caption(ui, tr("Name"));     let r = ui.add(egui::TextEdit::singleline(&mut e.name).font(regular(11.0)).desired_width(W));@@ -878,9 +882,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {         if let Some(e) = &p.edit {             commit_edit(app, &doc, e);         }+        app.ui.style_picker_popover = None;         return;     }-    let t = Tokens::get(ctx);     let screen = ctx.content_rect();     // Left of the Design panel; further left while the colour picker (which sits there) is open.     let mut x = screen.max.x - app.ui.right_width - W - 32.0;@@ -889,34 +893,24 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {     }     let y = p.y.clamp(screen.min.y + 48.0, (screen.max.y - 460.0).max(screen.min.y + 48.0));     let mut out = Frame { act: None, hover: None, budget: 4 };-    let area = egui::Area::new(Id::new("style_picker_area")).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| {-        egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {-            ui.set_width(W);-            if p.edit.is_some() {-                editor(ui, &doc, &mut p, &mut out);-            } else {-                listing(app, ui, &doc, &mut p, &mut out);-            }-        });-    });-    let rect = area.response.rect;-    ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("style_picker_border"))).rect_stroke(-        rect,-        CornerRadius::same(10),-        Stroke::new(1.0, Color32::from_black_alpha(40)),-        egui::StrokeKind::Inside,-    );-    // Clicks in the colour picker editing this picker's style, or in a menu the editor opened,-    // keep it open.+    // The title and the X are the shared popover's now; the list keeps its tools row.+    let title = if p.edit.is_some() { tr("Edit style") } else { p.slot.title() };+    let mut pop = app.ui.style_picker_popover.take().unwrap_or_else(|| Popover::new("style_picker_area", title).pad(PAD).surface(Surface::Menu));+    pop.title = title.to_string();+    // Clicks in the colour picker editing this picker's style, in the variable picker binding one+    // of its fields, or in a menu the editor opened, keep it open.     let picker_rect =         app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(Id::new("color_picker"))));-    // So do clicks in the variable picker binding one of its fields (Escape closes that first).     let variables = crate::variable_ui::picker_rect(ctx);-    let in_popup = egui::Popup::is_any_open(ctx) || variables.is_some();-    let outside = ctx.input(|i| {-        i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|pos| !rect.contains(pos) && !picker_rect.is_some_and(|r| r.contains(pos)))-    }) && !in_popup;-    let mut close = outside || (ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !in_popup);+    let holds: Vec<Rect> = picker_rect.into_iter().chain(variables).collect();+    let closed = pop.show(ctx, Open::At(pos2(x, y)), Hold { rects: &holds, escape: variables.is_some() || picker_rect.is_some() }, |ui| {+        if p.edit.is_some() {+            editor(ui, &doc, &mut p, &mut out);+        } else {+            listing(app, ui, &doc, &mut p, &mut out);+        }+    });+    let mut close = closed;     let ids = p.ids.clone();     let slot = p.slot;     // The canvas shows the text style under the pointer on the selected text.@@ -959,7 +953,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {             if let Some(e) = &p.edit {                 commit_edit(app, &doc, e);             }-            app.ui.picker = Some(PickerTarget::Style { id, index });+            // No swatch to sit on here (the click came from the editor's list): the picker takes+            // its usual height rather than the one a swatch opened it at before.+            app.open_picker(PickerTarget::Style { id, index }, None);         }         Some(Act::Unbind(id, field)) => {             app.run("style.bind", json!({"style": id, "field": field}));@@ -989,8 +985,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {         if matches!(app.ui.picker, Some(PickerTarget::Style { .. })) {             app.ui.picker = None;         }+        app.ui.style_picker_popover = None;     } else {         app.ui.style_picker = Some(p);+        app.ui.style_picker_popover = Some(pop);     } } @@ -998,6 +996,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { fn edit_local(app: &mut FigApp, ui: &Ui, slot: StyleSlot, id: &str) {     let y = ui.input(|i| i.pointer.interact_pos()).map_or(140.0, |p| p.y - 12.0);     let edit = app.session.doc().ok().and_then(|d| d.style(id).map(editing));+    crate::colorpicker::close(app);+    app.ui.style_picker_popover = None;     app.ui.style_picker = Some(Picker { slot, ids: app.session.selection(), y, query: String::new(), edit }); } diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs--- a/crates/ui-egui/src/variables_panel.rs+++ b/crates/ui-egui/src/variables_panel.rs@@ -830,7 +830,8 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C             let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0));             widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c));             if ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).tip(tr("Edit colour")).clicked() {-                app.ui.picker = Some(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() });+                // Its top on the swatch (a variable's paint has no place of its own to sit on).+                app.open_picker(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() }, Some(sw.min.y));             }             let hex_r = Rect::from_min_max(pos2(sw.max.x + 8.0, body.min.y + 4.0), pos2(body.max.x - 44.0, body.max.y - 4.0));             if let Some(h) = widgets::text_field_inline(ui, id.with("hex"), &c.to_hex(), hex_r, regular(11.0))diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs--- a/crates/ui-egui/src/widgets.rs+++ b/crates/ui-egui/src/widgets.rs@@ -1145,6 +1145,17 @@ pub fn note(ui: &mut Ui, text: &str, color: Color32) {     }); } +/// Draws a row's two trailing icon buttons 4 points apart (a control keeps 8), returning the+/// second's response. The gap after a widget is fixed when it is laid out, so the tighter spacing+/// has to be in place before the first icon; the row's own spacing comes back afterwards.+pub fn icon_pair(ui: &mut Ui, first: impl FnOnce(&mut Ui), second: impl FnOnce(&mut Ui) -> Response) -> Response {+    let spacing = ui.spacing().item_spacing;+    ui.spacing_mut().item_spacing.x = 4.0;+    first(ui);+    let out = second(ui);+    ui.spacing_mut().item_spacing = spacing;+    out+} /// A small grey label above a row of fields ("Position", "Dimensions"…). With two labels the /// second starts at the second field column. pub fn sublabels(ui: &mut Ui, a: &str, b: Option<&str>) {diff --git a/docs/ui-parity.md b/docs/ui-parity.md--- 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-10-18 · **Change:** minor (a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (the colour picker sits on the control that opened it, at every opener; the panel's icon rhythm: a leading icon 8 in, an icon pair 4 apart; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)  Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:@@ -172,8 +172,9 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look  | Date | Change | Summary | |---|---|---|+| 2026-10-18 | minor | The colour picker opens with its top on the control that opened it, at every opener (a paint's swatch, a text range's mixed row, a variable's swatch, an effect's colour, the page background), instead of keeping the height of whichever swatch opened it last. The panel's icon rhythm: a row that opens with a 24-point icon insets it 8 from the panel's edge (the effect and layout-grid rows), an icon keeps 8 to the next control, and two icons keep 4 | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |-| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |+| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |609b (M4: a row that opens with an icon keeps the panel's right edge) | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME |