From c54c4567c77500704266d3e2ff784554fab02679 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 20:22:42 -0800 Subject: [PATCH] Color controls: preserve working RGB components in numeric edits Read RGB and HSB numbers in the active working RGB space in the Color panel and Color Picker. Keep native values through hex and web-safe controls and cache live components by native color and color settings. --- crates/ui-egui/src/dialogs/color_picker.rs | 18 +- crates/ui-egui/src/menus/ui_commands.rs | 4 +- crates/ui-egui/src/panels/color.rs | 45 +++-- .../ui-egui/tests/color_working_components.rs | 155 ++++++++++++++++++ docs/mcp.md | 5 + docs/target-app-parity.md | 1 + 6 files changed, 206 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/tests/color_working_components.rs diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index 60c037597..8d58f21b4 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -23,7 +23,9 @@ use vectorcraft_engine::cmd::color_value; use super::form::PREVIEWED; use super::{DialogAction, DialogSpec}; -use crate::panels::color::{GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe}; +use crate::panels::color::{ + GAMUT_WARNING, WEB_WARNING, gamut_fix, hex_digits, is_web_safe, parse_hex, warning_chip, web_safe, working_hsb, working_rgb, +}; use crate::panels::{c32, color_json}; use crate::state::Dialog; use crate::theme::Tokens; @@ -123,8 +125,9 @@ pub struct Picked { } impl Picked { + /// Read HSB from the color's native working RGB components. pub fn of(color: Color) -> Self { - Self { color, hsb: color.to_hsb() } + Self { color, hsb: working_hsb(&color) } } fn from_hsb(hsb: [f32; 3]) -> Self { Self { color: Color::from_hsb(hsb[0], hsb[1], hsb[2]), hsb } @@ -137,7 +140,7 @@ impl Picked { /// The slider value and the field point (x right, y down; 0..1 each) of a colour. pub fn field_pos(ch: Channel, p: &Picked) -> (f32, f32, f32) { - let v = if ch.is_hsb() { [p.hsb[0] / 360.0, p.hsb[1], p.hsb[2]] } else { p.color.to_rgb() }; + let v = if ch.is_hsb() { [p.hsb[0] / 360.0, p.hsb[1], p.hsb[2]] } else { working_rgb(&p.color) }; let (z, x, y) = ch.axes(); (v[z], v[x], 1.0 - v[y]) } @@ -162,7 +165,7 @@ pub fn slider_color(ch: Channel, z: f32, x: f32, y: f32) -> Color { /// The number and suffix that [`value_fields`] shows in the field of `ch` for `p`, and the field's /// largest number: H in degrees (to 360), S and B in percent (to 100), and R, G and B from 0 to 255. fn channel_value(ch: Channel, p: &Picked) -> (f32, &'static str, f32) { - let [r, g, b] = p.color.to_rgb(); + let [r, g, b] = working_rgb(&p.color); match ch { Channel::Hue => (p.hsb[0], "°", 360.0), Channel::Saturation => (p.hsb[1] * 100.0, "%", 100.0), @@ -174,6 +177,7 @@ fn channel_value(ch: Channel, p: &Picked) -> (f32, &'static str, f32) { } /// The colour the dialog holds: an edited `hex` wins over `color` (agents set either one). +/// Cached HSB is reused when it reconstructs working RGB within 1e-6 per channel. fn picked(d: &Dialog) -> Picked { let hex = d.str("hex"); let color = match parse_hex(&hex) { @@ -182,7 +186,9 @@ fn picked(d: &Dialog) -> Picked { }; let hsb = d.fields.get("__hsb").and_then(|v| serde_json::from_value::<[f32; 3]>(v.clone()).ok()); let p = match hsb { - Some(h) if Picked::from_hsb(h).color.to_hex() == color.to_hex() => Picked { color, hsb: h }, + Some(h) if working_rgb(&Picked::from_hsb(h).color).into_iter().zip(working_rgb(&color)).all(|(a, b)| (a - b).abs() <= 1e-6) => { + Picked { color, hsb: h } + } _ => Picked::of(color), }; p.snapped(d.bool("webOnly")) @@ -493,7 +499,7 @@ fn new_original_chips(ui: &mut Ui, new: &Color, original: &Color) -> bool { /// K, with a stop at each channel's radio button before its field. fn value_fields(ui: &mut Ui, p: &Picked, channel: Channel) -> (Option, Option) { let t = Tokens::get(ui.ctx()); - let rgb = p.color.to_rgb(); + let rgb = working_rgb(&p.color); let lab = p.color.to_lab(); let cms = cms::active(); let cmyk = p.color.to_cmyk_managed(cms.settings().intent); diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 92bd0ed74..a3b12f4b9 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -717,7 +717,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.colorPicker", "Color Picker…", "", - "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard", + "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); RGB, HSB and hex values use the working RGB space; OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard", ), ( "ui.sampleScreenColor", @@ -1092,7 +1092,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.colorPanel", "Color Panel Options", "", - "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", + "{mode?: grayscale|rgb|hsb|cmyk|websafe, options?: bool} pick the Color panel's mode as its panel menu does (the active color converts to the mode's model as one undo step), and Show Options (true) or Hide Options (false); both are kept with the preferences; RGB, HSB, Web Safe RGB and hex values use the working RGB space (ui.inspect: ui.color_mode, ui.color_hide_options) → {mode (null: none picked yet), options, shown: the mode the panel shows the color in, or tint for a tint of a global or spot swatch}", ), ( "ui.savePdfDialog", diff --git a/crates/ui-egui/src/panels/color.rs b/crates/ui-egui/src/panels/color.rs index 00e32808a..643a7ff66 100644 --- a/crates/ui-egui/src/panels/color.rs +++ b/crates/ui-egui/src/panels/color.rs @@ -16,6 +16,7 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::{Value, json}; +use vectorcraft_color::cms; use vectorcraft_color::{Color, GradientKind, Paint}; use super::{active_paint, color_json, live_run, pstate, set_pstate}; @@ -102,7 +103,19 @@ impl Mode { } } -/// Displayed component values of `c` in `mode` (RGB 0–255, HSB °/%/%, CMYK %, K %, Lab L*a*b*). +/// The color's numeric components in the active working RGB space. +pub(crate) fn working_rgb(c: &Color) -> [f32; 3] { + let cms = cms::active(); + cms.convert(c, cms::Model::Rgb, cms.settings().intent).to_rgb_uncalibrated() +} + +/// HSB of the working RGB components, with hue in degrees. +pub(crate) fn working_hsb(c: &Color) -> [f32; 3] { + let hsb = egui::ecolor::Hsva::from_rgb(working_rgb(c)); + [hsb.h * 360.0, hsb.s, hsb.v] +} + +/// Numeric component values of `c` in `mode` (working RGB 0 to 255, HSB °/%/%, CMYK %, K %, Lab L*a*b*). pub fn components(mode: Mode, c: &Color) -> Vec { match mode { Mode::Grayscale => { @@ -115,9 +128,9 @@ pub fn components(mode: Mode, c: &Color) -> Vec { }; vec![k * 100.0] } - Mode::Rgb | Mode::WebSafe => c.to_rgb().iter().map(|v| (v * 255.0).clamp(0.0, 255.0)).collect(), + Mode::Rgb | Mode::WebSafe => working_rgb(c).iter().map(|v| (v * 255.0).clamp(0.0, 255.0)).collect(), Mode::Hsb => { - let [h, s, b] = c.to_hsb(); + let [h, s, b] = working_hsb(c); vec![h, s * 100.0, b * 100.0] } Mode::Cmyk => c.to_cmyk().iter().map(|v| v * 100.0).collect(), @@ -146,15 +159,16 @@ pub fn from_components(mode: Mode, v: &[f32]) -> Color { } } -/// The nearest web-safe colour (each channel a multiple of 0x33). +/// The nearest web-safe color (each working RGB channel a multiple of 0x33). pub fn web_safe(c: &Color) -> Color { let q = |v: f32| (v.clamp(0.0, 1.0) * 5.0).round() / 5.0; - let [r, g, b] = c.to_rgb(); + let [r, g, b] = working_rgb(c); Color::rgb(q(r), q(g), q(b)) } +/// Whether the working RGB hex value uses only web-safe channel levels. pub fn is_web_safe(c: &Color) -> bool { - web_safe(c).to_hex() == c.to_hex() + hex_digits(&web_safe(c)) == hex_digits(c) } /// Track colour of slider `i` at position `t` (0..1) with the other components fixed (dynamic @@ -190,9 +204,10 @@ pub fn spectrum_at(mode: Mode, x: f32, y: f32) -> Color { } } -/// The hex field text of a colour (`E67828`). +/// The hex field text of a color's working RGB components (`E67828`). pub fn hex_digits(c: &Color) -> String { - c.to_hex().trim_start_matches('#').to_uppercase() + let [r, g, b] = working_rgb(c).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8); + format!("{r:02X}{g:02X}{b:02X}") } /// Parse a hex field (`E67828`, `#e67828`, `fff`). @@ -220,7 +235,7 @@ pub(crate) fn warning_chip(ui: &mut Ui, (icon, warning): (&str, &str), fix: &Col widgets::swatch_tile(ui, r, &Paint::solid(*fix), false, resp.hovered()); resp.on_hover_text(crate::i18n::fmt( tl!("{warning}: click to correct to the closest color ({hex})"), - &[("warning", tl!(warning)), ("hex", &fix.to_hex())], + &[("warning", tl!(warning)), ("hex", &format!("#{}", hex_digits(fix).to_ascii_lowercase()))], )) .clicked() }) @@ -575,6 +590,7 @@ fn spectrum(ui: &mut Ui, mode: Mode, size: egui::Vec2) -> Option { Some(SpectrumHit::Pick(spectrum_at(mode, (p.x - r.left()) / r.width(), (p.y - r.top()) / r.height()), phase)) } +/// Draw numeric paint controls, retaining live components while the color and settings match. pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let tgt = target(app, ui); @@ -597,9 +613,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } return; } - // Keep the displayed components while the colour is unchanged (hue survives S = 0 etc.). - let key = color.map(|c| (c.to_hex(), mode as u8)); - let comps: Vec = match (color, pstate::)>>(ui.ctx(), "color-comps")) { + // Keep the components while the native color and settings are unchanged (hue survives S = 0). + let settings = cms::active_settings(); + let key = color.map(|c| (c, mode as u8, settings.clone())); + let comps: Vec = match (color, pstate::)>>(ui.ctx(), "color-comps")) { (Some(_), Some((k, v))) if Some(&k) == key.as_ref() => v, (Some(c), _) => components(mode, &c), (None, _) => vec![0.0; mode.labels().len()], @@ -737,7 +754,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { apply_tint(app, &tgt, tint, t, phase); } if let Some((c, phase, comps2)) = new { - let key = (c.to_hex(), mode as u8); + let key = (c, mode as u8, settings); set_pstate(ui.ctx(), "color-comps", Some((key, comps2))); choose(app, &tgt, c, phase, false); } @@ -779,7 +796,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Copy Color Value (Hex)"), color.is_some(), false) && let Some(c) = color { - ui.ctx().copy_text(c.to_hex()); + ui.ctx().copy_text(format!("#{}", hex_digits(&c).to_ascii_lowercase())); } } diff --git a/crates/ui-egui/tests/color_working_components.rs b/crates/ui-egui/tests/color_working_components.rs new file mode 100644 index 000000000..85f392416 --- /dev/null +++ b/crates/ui-egui/tests/color_working_components.rs @@ -0,0 +1,155 @@ +//! Numeric color controls edit the working RGB channels, including colors outside display sRGB. +// Integration tests may unwrap and panic (AGENTS.md, Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::{Mutex, MutexGuard}; + +use serde_json::json; +use vectorcraft_color::Color; +use vectorcraft_color::cms::{self, ColorSettings, DISPLAY_P3, PROPHOTO_RGB, SRGB, WIDE_GAMUT_RGB}; +use vectorcraft_engine::Session; +use vectorcraft_ui_egui::VectorcraftApp; +use vectorcraft_ui_egui::panels::color::{Mode, components, from_components, hex_digits, parse_hex, web_safe}; + +// This integration binary contains only these tests; its app-wide CMS is independent of other +// test binaries. Serialize its cases and restore settings even when an assertion fails. +static CMS: Mutex<()> = Mutex::new(()); + +struct WorkingSpace { + previous: ColorSettings, + _lock: MutexGuard<'static, ()>, +} + +impl WorkingSpace { + /// Select a working profile for one case without retaining it after a panic. + fn new(rgb: &str) -> Self { + let lock = CMS.lock().unwrap_or_else(|e| e.into_inner()); + let previous = cms::active_settings(); + cms::set_active(&ColorSettings { rgb: rgb.into(), ..ColorSettings::default() }).unwrap(); + Self { previous, _lock: lock } + } +} + +impl Drop for WorkingSpace { + fn drop(&mut self) { + cms::set_active(&self.previous).unwrap(); + } +} + +/// Compare native channel values without converting them through the display profile. +fn native(color: Color, expected: [f32; 3]) { + let Color::Rgb { r, g, b } = color else { panic!("expected RGB, got {color:?}") }; + for (actual, expected) in [r, g, b].into_iter().zip(expected) { + assert!((actual - expected).abs() < 1e-6, "{color:?}: expected {expected}, got {actual}"); + } +} + +/// Editing R preserves the stored G and B components. +#[test] +fn rgb_edits_preserve_untouched_working_channels() { + for profile in [SRGB, DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] { + let _space = WorkingSpace::new(profile); + let color = Color::rgb(0.5, 0.3, 0.2); + let mut values = components(Mode::Rgb, &color); + for (actual, expected) in values.iter().zip([127.5, 76.5, 51.0]) { + assert!((actual - expected).abs() < 1e-4, "{profile}: {values:?}"); + } + values[0] = 153.0; + native(from_components(Mode::Rgb, &values), [0.6, 0.3, 0.2]); + } +} + +/// HSB describes the native RGB triplet; changing hue preserves its saturation and brightness. +#[test] +fn hsb_edits_preserve_native_saturation_and_brightness() { + for profile in [SRGB, DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] { + let _space = WorkingSpace::new(profile); + let mut values = components(Mode::Hsb, &Color::rgb(0.5, 0.3, 0.2)); + for (actual, expected) in values.iter().zip([20.0, 60.0, 50.0]) { + assert!((actual - expected).abs() < 1e-4, "{profile}: {values:?}"); + } + values[0] = 140.0; + native(from_components(Mode::Hsb, &values), [0.2, 0.5, 0.3]); + } +} + +/// Native primaries survive numeric round trips even when display conversion clips their gamut. +#[test] +fn numeric_roundtrips_preserve_out_of_display_gamut_colors() { + { + let _space = WorkingSpace::new(DISPLAY_P3); + let red = Color::rgb(1.0, 0.0, 0.0); + let red_with_green = Color::rgb(1.0, 0.05, 0.0); + assert_eq!(red.to_rgb(), red_with_green.to_rgb(), "display clipping loses their native distinction"); + native(from_components(Mode::Rgb, &components(Mode::Rgb, &red_with_green)), [1.0, 0.05, 0.0]); + } + for profile in [DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] { + let _space = WorkingSpace::new(profile); + for rgb in [[1.0, 0.2, 0.0], [0.0, 1.0, 0.2], [0.2, 0.0, 1.0]] { + let color = Color::rgb(rgb[0], rgb[1], rgb[2]); + for mode in [Mode::Rgb, Mode::Hsb] { + native(from_components(mode, &components(mode, &color)), rgb); + } + } + } +} + +/// Opening the picker reads HSB from the stored RGB components. +#[test] +fn picker_initialization_preserves_working_values() { + for profile in [SRGB, DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] { + let _space = WorkingSpace::new(profile); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("ui.colorPicker", json!({"color": [0.5, 0.3, 0.2]})).unwrap(); + let dialog = app.ui.dialog.as_ref().unwrap(); + let hsb = dialog.fields.get("__hsb").unwrap().as_array().unwrap(); + for (actual, expected) in hsb.iter().zip([20.0, 0.6, 0.5]) { + assert!((actual.as_f64().unwrap() - expected).abs() < 1e-4, "{profile}: {hsb:?}"); + } + } +} + +/// Shared hex and web-safe numeric controls round the native working RGB components. +#[test] +fn shared_hex_and_websafe_preserve_working_values() { + for profile in [SRGB, DISPLAY_P3, WIDE_GAMUT_RGB, PROPHOTO_RGB] { + let _space = WorkingSpace::new(profile); + let color = Color::rgb(0.5, 0.3, 0.2); + assert_eq!(hex_digits(&color), "804D33", "{profile}"); + native(parse_hex("804D33").unwrap(), [128.0 / 255.0, 77.0 / 255.0, 0.2]); + native(web_safe(&color), [0.6, 0.4, 0.2]); + } +} + +/// A panel slider click changes one native component and makes one undo step. +#[test] +fn color_panel_slider_preserves_native_channels_and_undo() { + use egui_kittest::kittest::Queryable; + + let _space = WorkingSpace::new(DISPLAY_P3); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("paint.setFill", json!({"color": [0.5, 0.3, 0.2]})).unwrap(); + let mut fonts = false; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(320.0, 400.0)).with_accessibility_check(false).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + vectorcraft_ui_egui::theme::install_fonts(ui.ctx()); + return; + } + vectorcraft_ui_egui::panels::color::show(app, ui); + }, + app, + ); + harness.run_steps(3); + harness.get_by_role_and_label(egui::accesskit::Role::Slider, "R").click(); + harness.run_steps(3); + let color = harness.state().session.proxy_paints().0.color().unwrap(); + let Color::Rgb { r, .. } = color else { panic!("expected RGB") }; + assert_ne!(r, 0.5, "the slider was edited"); + native(color, [r, 0.3, 0.2]); + harness.state_mut().run("edit.undo", json!({})).unwrap(); + native(harness.state().session.proxy_paints().0.color().unwrap(), [0.5, 0.3, 0.2]); +} diff --git a/docs/mcp.md b/docs/mcp.md index 131acd9be..f8f38ede3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -410,6 +410,11 @@ paint command (and the eyedropper) feeds. `paint.proxies` returns what the proxies show: the fill and stroke, which one is active, and whether the selected objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" proxy). +In the app's Color panel and Color Picker, RGB, HSB, hex and Web Safe RGB values describe the active working RGB +space. RGB edits preserve the other native channels; HSB uses those same working RGB components. Colors outside +display sRGB retain their native numeric values. `ui.colorPanel` selects the panel mode and `ui.colorPicker` +opens the picker through `run_command` with the remote backend. + | Tool | Arguments | Notes | |---|---|---| | `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6b564d9fa..4dd225851 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1022,6 +1022,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Recolor Artwork layout (#3416):** the Edit chip list uses the modal scroll helper and its actual remaining height; the tab's minimum-height space scrolls with the chips. `tests_recolor` covers 5/20/200/400 colors, window resizing, 100/125/150/200% UI scaling, scrolling to and editing the final chip, Preview, Cancel, Confirm and Undo, alongside the existing #1733 drag regressions. Scores and estimates unchanged. - **Recolor Artwork Edit displays:** buttons beside the Edit tab's wheel show the colors on a smooth color wheel, a segmented color wheel or as color bars ([Edit colors using Recolor Artwork](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/edit-colors-using-edit-colors-dialog.html)). The segmented wheel draws 24 hues by 5 saturations as solid patches, and its markers move freely as on the smooth wheel. The bars show one solid bar per recolored color in row order: a click selects a bar for the color sliders, and a drag moves its row without changing a color ([Advanced recolor options overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/learn-color-basics/edit-colors-dialog-overview.html)). The dialog opens on the smooth wheel; `display` (`smooth`, `segmented`, `bars`) is the dialog field. `tests_recolor` covers switching, the dialog height, segmented dragging and Brightness, and bar selection, editing, reordering, scrolling, Preview, Cancel and OK. Scores and estimates unchanged. - **Edit Colors parameters:** `edit.colors.saturate` needs a number `intensity`, the value from -100% to 100% that Saturate takes ([Adjusting colors](https://web.archive.org/web/20230128232631/https://helpx.adobe.com/illustrator/using/adjusting-colors.html)), and answers "missing number `intensity`" without one; a number outside the range counts as its nearest end. `edit.colors.adjustBalance` refuses a channel and `color.harmony` refuses `steps` or `amount` that is not a number; both read `null` as a value left out. `tests::editcolors` and `tests::colorguide` cover them. + - **Working RGB numeric controls:** the Color panel and Color Picker read RGB, HSB, hex and Web Safe RGB values in the active working RGB space. Editing one RGB channel preserves the other native channels, including colors outside display sRGB. Regressions cover sRGB, Display P3, Wide Gamut RGB and ProPhoto RGB, picker initialization, native numeric round trips, and a slider click with one Undo step. Scores and estimates unchanged. - **Color Picker layout (#2008):** a centered draggable header, equal-width actions above aligned HSB/RGB/Hex and Lab/CMYK fields, compact field and slider, and warnings below the preview. No horizontal scrolling; narrow windows stack the sections with vertical scrolling. Tests in `dialogs/color_picker.rs` cover actual pointer/key input, typed values before OK/Enter, Cancel/Escape rollback, one Undo step, mode-size stability, warnings and layout at two UI scales. The screen sampler sits above the field and the upstream two-grid Tab order is retained. AccessKit regressions check the action names and each channel slider's displayed value, units and range. German and enlarged English UI renders were reviewed locally; no reference images are shipped. This is a layout improvement, with no change to scores or remaining-hour estimates. - **Eyedropper sources (#1251):** ordinary and Shift clicks, Shift+Alt appearance append, raster pixels and gradient-stop sampling can read visible locked/template art and sources outside isolation. Sampling leaves the source and selection intact; Alt reverse still uses editable hits, and hidden art stays excluded. Engine/tool/hit regressions cover the editing boundary, color models and one-step Undo/Redo. The general object/image workflow is described in [public HelpX](https://helpx.adobe.com/illustrator/using/tool-techniques/eyedropper-tool.html); the lock/isolation expectations come from the user report and these regressions, without a new full-parity measurement. - **Eyedropper colors in a CMYK document (#2276):** a CMYK image (a four-component JPEG, or an 8- or 16-bit chunky CMYK TIFF) gives its ink amounts as they are, and an RGB image's pixel or an RGB color given to `paint.setFill`, `paint.setStroke`, `paint.sampleColor` or `swatch.new {color}` (hex input and gradient stops included) is separated through the color settings' CMYK working space and intent, as Document Color Mode converts it. With the default Generic CMYK, RGB 83/170/223 separates to C59 M12 Y3 K0, where the profile-free formula gave C63 M24 Y0 K13.