Repository: GraphiteEditor/Graphite (language: rust)

Issue:
Make all dragging actions abortable with right click
This is an ongoing list of all actions we find which need to be made abortable by right-clicking. Right clicking should be possible anywhere in or outside the app window, not just over the widget that's being dragged.

## Node graph
- [x] Dragging nodes should restore their initial positions
- [x] Dragging a wire should restore its initial connection
  - Moved to #2336:
    > - <kbd>Esc</kbd> needs to not close the graph
- [x] Box selection should, upon right clicking, restore the selection before drag-selecting
  - Moved to #2336:
    > - <kbd>Esc</kbd> needs to not close the graph

## Layers panel
- Moved to: #2335
  > - Dragging to rearrange layers (in Firefox, this actually confirms the action instead of aborting it, in Chrome it just does nothing)

## Workspace
- [x] Resizing the gutter between panel areas

## Color picker
- [x] Saturation/Value square
- [x] Hue track
- [x] Alpha track
- [x] Gradient spectrum input knobs

## Viewport
- [x] Scrollbars

## Tools
- Moved to: #2279
  > - Select & Path tools: drag-selecting, upon right clicking, should restore the selection before drag-selecting (details and explanation webcast in [this discussion](https://discord.com/channels/731730685944922173/1216976541947531264/1325911477042942073) on Discord)

## Header bar and widgets
- Moved to: #2280
  > - Menu bar entries should—first of all, open on mousedown and let you drag down and release on an entry—then right click in the menu to abort and close it
  > - Same as above for dropdown menus


Discussion / hints:
@Keavon is this the expected behaviour??
https://github.com/user-attachments/assets/19489765-4ab1-4f29-a9f7-9b14bae62589

@a-khushal did you upload the wrong video?
@Keavon The video is the solution for this issue: "Menu bar entries should—first of all, open on mousedown and let you drag down and release on an entry—then right click in the menu to abort and close it", whenever mousedown happens on a menu, it opens the dropdown, and when dragged down and released on an entry, it selects that entry
@a-khushal I see, although I won't be able to tell much from the video. Please open a PR and I'll try it out.
@Keavon please refer #2164 

Fix patch:
diff --git a/editor/src/messages/layout/layout_message_handler.rs b/editor/src/messages/layout/layout_message_handler.rs
index b80301ecf2..a5b130b529 100644
--- a/editor/src/messages/layout/layout_message_handler.rs
+++ b/editor/src/messages/layout/layout_message_handler.rs
@@ -80,7 +80,7 @@ impl LayoutMessageHandler {
 				};
 				responses.add(callback_message);
 			}
-			Widget::ColorButton(color_button) => {
+			Widget::ColorInput(color_button) => {
 				let callback_message = match action {
 					WidgetValueAction::Commit => (color_button.on_commit.callback)(&()),
 					WidgetValueAction::Update => {
@@ -99,7 +99,7 @@ impl LayoutMessageHandler {
 						};
 
 						(|| {
-							let update_value = value.as_object().expect("ColorButton update was not of type: object");
+							let update_value = value.as_object().expect("ColorInput update was not of type: object");
 
 							// None
 							let is_none = update_value.get("none").and_then(|x| x.as_bool());
@@ -139,7 +139,7 @@ impl LayoutMessageHandler {
 								return (color_button.on_update.callback)(color_button);
 							}
 
-							panic!("ColorButton update was not able to be parsed with color data: {color_button:?}");
+							panic!("ColorInput update was not able to be parsed with color data: {color_button:?}");
 						})()
 					}
 				};
diff --git a/editor/src/messages/layout/utility_types/layout_widget.rs b/editor/src/messages/layout/utility_types/layout_widget.rs
index 4b0acd3808..9b580b1b7e 100644
--- a/editor/src/messages/layout/utility_types/layout_widget.rs
+++ b/editor/src/messages/layout/utility_types/layout_widget.rs
@@ -327,7 +327,7 @@ impl LayoutGroup {
 		for widget in &mut widgets {
 			let val = match &mut widget.widget {
 				Widget::CheckboxInput(x) => &mut x.tooltip,
-				Widget::ColorButton(x) => &mut x.tooltip,
+				Widget::ColorInput(x) => &mut x.tooltip,
 				Widget::CurveInput(x) => &mut x.tooltip,
 				Widget::DropdownInput(x) => &mut x.tooltip,
 				Widget::FontInput(x) => &mut x.tooltip,
@@ -498,7 +498,7 @@ impl<T> Default for WidgetCallback<T> {
 pub enum Widget {
 	BreadcrumbTrailButtons(BreadcrumbTrailButtons),
 	CheckboxInput(CheckboxInput),
-	ColorButton(ColorButton),
+	ColorInput(ColorInput),
 	CurveInput(CurveInput),
 	DropdownInput(DropdownInput),
 	FontInput(FontInput),
@@ -571,7 +571,7 @@ impl DiffUpdate {
 			let mut tooltip_shortcut = match &mut widget_holder.widget {
 				Widget::BreadcrumbTrailButtons(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
 				Widget::CheckboxInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
-				Widget::ColorButton(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
+				Widget::ColorInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
 				Widget::DropdownInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
 				Widget::FontInput(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
 				Widget::IconButton(widget) => Some((&mut widget.tooltip, &mut widget.tooltip_shortcut)),
diff --git a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs
index e4ec11eb3c..8854667ecf 100644
--- a/editor/src/messages/layout/utility_types/widgets/button_widgets.rs
+++ b/editor/src/messages/layout/utility_types/widgets/button_widgets.rs
@@ -148,7 +148,7 @@ pub struct ImageButton {
 
 #[derive(Clone, Derivative, serde::Serialize, serde::Deserialize, WidgetBuilder, specta::Type)]
 #[derivative(Debug, PartialEq, Default)]
-pub struct ColorButton {
+pub struct ColorInput {
 	#[widget_builder(constructor)]
 	pub value: FillChoice,
 
@@ -174,7 +174,7 @@ pub struct ColorButton {
 	// Callbacks
 	#[serde(skip)]
 	#[derivative(Debug = "ignore", PartialEq = "ignore")]
-	pub on_update: WidgetCallback<ColorButton>,
+	pub on_update: WidgetCallback<ColorInput>,
 
 	#[serde(skip)]
 	#[derivative(Debug = "ignore", PartialEq = "ignore")]
diff --git a/editor/src/messages/portfolio/document/navigation/navigation_message.rs b/editor/src/messages/portfolio/document/navigation/navigation_message.rs
index 13e22d274a..b5c4095222 100644
--- a/editor/src/messages/portfolio/document/navigation/navigation_message.rs
+++ b/editor/src/messages/portfolio/document/navigation/navigation_message.rs
@@ -11,6 +11,8 @@ pub enum NavigationMessage {
 	BeginCanvasTilt { was_dispatched_from_menu: bool },
 	BeginCanvasZoom,
 	CanvasPan { delta: DVec2 },
+	CanvasPanAbortPrepare { x_not_y_axis: bool },
+	CanvasPanAbort { x_not_y_axis: bool },
 	CanvasPanByViewportFraction { delta: DVec2 },
 	CanvasPanMouseWheel { use_y_as_x: bool },
 	CanvasTiltResetAndZoomTo100Percent,
diff --git a/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs b/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs
index 9bff84b35d..10e4564a26 100644
--- a/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs
+++ b/editor/src/messages/portfolio/document/navigation/navigation_message_handler.rs
@@ -29,6 +29,7 @@ pub struct NavigationMessageHandler {
 	navigation_operation: NavigationOperation,
 	mouse_position: ViewportPosition,
 	finish_operation_with_click: bool,
+	abortable_pan_start: Option<f64>,
 }
 
 impl MessageHandler<NavigationMessage, NavigationMessageData<'_>> for NavigationMessageHandler {
@@ -141,6 +142,28 @@ impl MessageHandler<NavigationMessage, NavigationMessageData<'_>> for Navigation
 				responses.add(BroadcastEvent::CanvasTransformed);
 				responses.add(DocumentMessage::PTZUpdate);
 			}
+			NavigationMessage::CanvasPanAbortPrepare { x_not_y_axis } => {
+				let Some(ptz) = get_ptz_mut(document_ptz, network_interface, graph_view_overlay_open, breadcrumb_network_path) else {
+					log::error!("Could not get PTZ in CanvasPanAbortPrepare");
+					return;
+				};
+				self.abortable_pan_start = Some(if x_not_y_axis { ptz.pan.x } else { ptz.pan.y });
+			}
+			NavigationMessage::CanvasPanAbort { x_not_y_axis } => {
+				let Some(ptz) = get_ptz_mut(document_ptz, network_interface, graph_view_overlay_open, breadcrumb_network_path) else {
+					log::error!("Could not get PTZ in CanvasPanAbort");
+					return;
+				};
+				if let Some(abortable_pan_start) = self.abortable_pan_start {
+					if x_not_y_axis {
+						ptz.pan.x = abortable_pan_start;
+					} else {
+						ptz.pan.y = abortable_pan_start;
+					}
+				}
+				self.abortable_pan_start = None;
+				responses.add(DocumentMessage::PTZUpdate);
+			}
 			NavigationMessage::CanvasPanByViewportFraction { delta } => {
 				let Some(ptz) = get_ptz_mut(document_ptz, network_interface, graph_view_overlay_open, breadcrumb_network_path) else {
 					log::error!("Could not get node graph PTZ in CanvasPanByViewportFraction");
diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs
index 264ee3c9da..8d6ab11b8f 100644
--- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs
+++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs
@@ -3303,7 +3303,7 @@ fn static_input_properties() -> InputProperties {
 		"assign_colors_gradient".to_string(),
 		Box::new(|node_id, index, context| {
 			let (document_node, input_name) = node_properties::query_node_and_input_name(node_id, index, context)?;
-			let gradient_row = node_properties::color_widget(document_node, node_id, index, input_name, ColorButton::default().allow_none(false), true);
+			let gradient_row = node_properties::color_widget(document_node, node_id, index, input_name, ColorInput::default().allow_none(false), true);
 			Ok(vec![gradient_row])
 		}),
 	);
@@ -3329,7 +3329,7 @@ fn static_input_properties() -> InputProperties {
 		"mask_stencil".to_string(),
 		Box::new(|node_id, index, context| {
 			let (document_node, input_name) = node_properties::query_node_and_input_name(node_id, index, context)?;
-			let mask = node_properties::color_widget(document_node, node_id, index, input_name, ColorButton::default(), true);
+			let mask = node_properties::color_widget(document_node, node_id, index, input_name, ColorInput::default(), true);
 			Ok(vec![mask])
 		}),
 	);
@@ -3403,7 +3403,7 @@ fn static_input_properties() -> InputProperties {
 				node_id,
 				index,
 				input_name,
-				ColorButton::default().allow_none(false),
+				ColorInput::default().allow_none(false),
 				true,
 			)])
 		}),
diff --git a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs
index 8a5075397d..4a452864ca 100644
--- a/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs
+++ b/editor/src/messages/portfolio/document/node_graph/node_graph_message_handler.rs
@@ -1822,7 +1822,7 @@ impl NodeGraphMessageHandler {
 		// If only one node is selected then show the preview or stop previewing button
 		if let Some(node_id) = previewing {
 			let button = TextButton::new("End Preview")
-				.icon(Some("Rescale".to_string()))
+				.icon(Some("FrameAll".to_string()))
 				.tooltip("Restore preview to the graph output")
 				.on_update(move |_| NodeGraphMessage::TogglePreview { node_id }.into())
 				.widget_holder();
@@ -1834,7 +1834,7 @@ impl NodeGraphMessageHandler {
 				.any(|export| matches!(export, NodeInput::Node { node_id: export_node_id, .. } if *export_node_id == node_id));
 			if selection_is_not_already_the_output && no_other_selections {
 				let button = TextButton::new("Preview")
-					.icon(Some("Rescale".to_string()))
+					.icon(Some("FrameAll".to_string()))
 					.tooltip("Preview selected node/layer (Shortcut: Alt-click node/layer)")
 					.on_update(move |_| NodeGraphMessage::TogglePreview { node_id }.into())
 					.widget_holder();
diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs
index c17f8bf931..8e137f8a74 100644
--- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs
+++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs
@@ -140,8 +140,8 @@ pub(crate) fn property_from_type(node_id: NodeId, index: usize, ty: &Type, numbe
 						Some(x) if x == TypeId::of::<u32>() => number_widget(document_node, node_id, index, name, number_input.int().min(min(0.)).max(max(f64::from(u32::MAX))), true).into(),
 						Some(x) if x == TypeId::of::<u64>() => number_widget(document_node, node_id, index, name, number_input.int().min(min(0.)), true).into(),
 						Some(x) if x == TypeId::of::<String>() => text_widget(document_node, node_id, index, name, true).into(),
-						Some(x) if x == TypeId::of::<Color>() => color_widget(document_node, node_id, index, name, ColorButton::default().allow_none(false), true),
-						Some(x) if x == TypeId::of::<Option<Color>>() => color_widget(document_node, node_id, index, name, ColorButton::default().allow_none(true), true),
+						Some(x) if x == TypeId::of::<Color>() => color_widget(document_node, node_id, index, name, ColorInput::default().allow_none(false), true),
+						Some(x) if x == TypeId::of::<Option<Color>>() => color_widget(document_node, node_id, index, name, ColorInput::default().allow_none(true), true),
 						Some(x) if x == TypeId::of::<DVec2>() => vec2_widget(document_node, node_id, index, name, "X", "Y", "", None, add_blank_assist),
 						Some(x) if x == TypeId::of::<UVec2>() => vec2_widget(document_node, node_id, index, name, "X", "Y", "", Some(0.), add_blank_assist),
 						Some(x) if x == TypeId::of::<IVec2>() => vec2_widget(document_node, node_id, index, name, "X", "Y", "", None, add_blank_assist),
@@ -152,7 +152,7 @@ pub(crate) fn property_from_type(node_id: NodeId, index: usize, ty: &Type, numbe
 							font_widgets.into_iter().chain(style_widgets.unwrap_or_default()).collect::<Vec<_>>().into()
 						}
 						Some(x) if x == TypeId::of::<Curve>() => curves_widget(document_node, node_id, index, name, true),
-						Some(x) if x == TypeId::of::<GradientStops>() => color_widget(document_node, node_id, index, name, ColorButton::default().allow_none(false), true),
+						Some(x) if x == TypeId::of::<GradientStops>() => color_widget(document_node, node_id, index, name, ColorInput::default().allow_none(false), true),
 						Some(x) if x == TypeId::of::<VectorDataTable>() => vector_widget(document_node, node_id, index, name, true).into(),
 						Some(x) if x == TypeId::of::<RasterFrame>() || x == TypeId::of::<ImageFrameTable<Color>>() || x == TypeId::of::<TextureFrame>() => {
 							raster_widget(document_node, node_id, index, name, true).into()
@@ -1067,7 +1067,7 @@ pub fn line_join_widget(document_node: &DocumentNode, node_id: NodeId, index: us
 	LayoutGroup::Row { widgets }
 }
 
-pub fn color_widget(document_node: &DocumentNode, node_id: NodeId, index: usize, name: &str, color_button: ColorButton, blank_assist: bool) -> LayoutGroup {
+pub fn color_widget(document_node: &DocumentNode, node_id: NodeId, index: usize, name: &str, color_button: ColorInput, blank_assist: bool) -> LayoutGroup {
 	let mut widgets = start_widgets(document_node, node_id, index, name, FrontendGraphDataType::General, blank_assist);
 
 	// Return early with just the label if the input is exposed to the graph, meaning we don't want to show the color picker widget in the Properties panel
@@ -1080,7 +1080,7 @@ pub fn color_widget(document_node: &DocumentNode, node_id: NodeId, index: usize,
 		TaggedValue::Color(color) => widgets.push(
 			color_button
 				.value(FillChoice::Solid(*color))
-				.on_update(update_value(|x: &ColorButton| TaggedValue::Color(x.value.as_solid().unwrap_or_default()), node_id, index))
+				.on_update(update_value(|x: &ColorInput| TaggedValue::Color(x.value.as_solid().unwrap_or_default()), node_id, index))
 				.on_commit(commit_value)
 				.widget_holder(),
 		),
@@ -1090,7 +1090,7 @@ pub fn color_widget(document_node: &DocumentNode, node_id: NodeId, index: usize,
 					Some(color) => FillChoice::Solid(*color),
 					None => FillChoice::None,
 				})
-				.on_update(update_value(|x: &ColorButton| TaggedValue::OptionalColor(x.value.as_solid()), node_id, index))
+				.on_update(update_value(|x: &ColorInput| TaggedValue::OptionalColor(x.value.as_solid()), node_id, index))
 				.on_commit(commit_value)
 				.widget_holder(),
 		),
@@ -1098,7 +1098,7 @@ pub fn color_widget(document_node: &DocumentNode, node_id: NodeId, index: usize,
 			color_button
 				.value(FillChoice::Gradient(x.clone()))
 				.on_update(update_value(
-					|x: &ColorButton| TaggedValue::GradientStops(x.value.as_gradient().cloned().unwrap_or_default()),
+					|x: &ColorInput| TaggedValue::GradientStops(x.value.as_gradient().cloned().unwrap_or_default()),
 					node_id,
 					index,
 				))
@@ -1776,7 +1776,7 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties
 // 		if let Some(&TaggedValue::F64(seed)) = &input.as_non_exposed_value() {
 // 			widgets.extend_from_slice(&[
 // 				Separator::new(SeparatorType::Unrelated).widget_holder(),
-// 				IconButton::new("Regenerate", 24)
+// 				IconButton::new("Resync", 24)
 // 					.tooltip("Set a new random seed")
 // 					.on_update({
 // 						let imaginate_node = imaginate_node.clone();
@@ -1856,7 +1856,7 @@ pub(crate) fn rectangle_properties(node_id: NodeId, context: &mut NodeProperties
 
 // 			widgets.extend_from_slice(&[
 // 				Separator::new(SeparatorType::Unrelated).widget_holder(),
-// 				IconButton::new("Rescale", 24)
+// 				IconButton::new("FrameAll", 24)
 // 					.tooltip("Set the layer dimensions to this resolution")
 // 					.on_update(move |_| DialogMessage::RequestComingSoonDialog { issue: None }.into())
 // 					.widget_holder(),
@@ -2211,9 +2211,9 @@ pub(crate) fn fill_properties(node_id: NodeId, context: &mut NodePropertiesConte
 
 	widgets_first_row.push(Separator::new(SeparatorType::Unrelated).widget_holder());
 	widgets_first_row.push(
-		ColorButton::default()
+		ColorInput::default()
 			.value(fill.clone().into())
-			.on_update(move |x: &ColorButton| {
+			.on_update(move |x: &ColorInput| {
 				Message::Batched(Box::new([
 					match &fill2 {
 						Fill::None => NodeGraphMessage::SetInputValue {
@@ -2380,7 +2380,7 @@ pub fn stroke_properties(node_id: NodeId, context: &mut NodePropertiesContext) -
 	let line_join_index = 6;
 	let miter_limit_index = 7;
 
-	let color = color_widget(document_node, node_id, color_index, "Color", ColorButton::default(), true);
+	let color = color_widget(document_node, node_id, color_index, "Color", ColorInput::default(), true);
 	let weight = number_widget(document_node, node_id, weight_index, "Weight", NumberInput::default().unit(" px").min(0.), true);
 
 	let dash_lengths_val = match &document_node.inputs[dash_lengths_index].as_value() {
diff --git a/editor/src/messages/portfolio/document/overlays/grid_overlays.rs b/editor/src/messages/portfolio/document/overlays/grid_overlays.rs
index ad6eb18b57..2bf84526c1 100644
--- a/editor/src/messages/portfolio/document/overlays/grid_overlays.rs
+++ b/editor/src/messages/portfolio/document/overlays/grid_overlays.rs
@@ -229,7 +229,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
 		})
 	};
 	let update_color = |grid, update: fn(&mut GridSnapping) -> Option<&mut Color>| {
-		update_val::<ColorButton>(grid, move |grid, color| {
+		update_val::<ColorInput>(grid, move |grid, color| {
 			if let FillChoice::Solid(color) = color.value {
 				if let Some(update_color) = update(grid) {
 					*update_color = color;
@@ -280,7 +280,7 @@ pub fn overlay_options(grid: &GridSnapping) -> Vec<LayoutGroup> {
 		Separator::new(SeparatorType::Related).widget_holder(),
 	]);
 	color_widgets.push(
-		ColorButton::new(FillChoice::Solid(grid.grid_color))
+		ColorInput::new(FillChoice::Solid(grid.grid_color))
 			.tooltip("Grid display color")
 			.allow_none(false)
 			.on_update(update_color(grid, |grid| Some(&mut grid.grid_color)))
diff --git a/editor/src/messages/tool/common_functionality/color_selector.rs b/editor/src/messages/tool/common_functionality/color_selector.rs
index ce42df044b..2b73b4f1f8 100644
--- a/editor/src/messages/tool/common_functionality/color_selector.rs
+++ b/editor/src/messages/tool/common_functionality/color_selector.rs
@@ -79,7 +79,7 @@ impl ToolColorOptions {
 		color_allow_none: bool,
 		reset_callback: impl Fn(&IconButton) -> Message + 'static + Send + Sync,
 		radio_callback: fn(ToolColorType) -> WidgetCallback<()>,
-		color_callback: impl Fn(&ColorButton) -> Message + 'static + Send + Sync,
+		color_callback: impl Fn(&ColorInput) -> Message + 'static + Send + Sync,
 	) -> Vec<WidgetHolder> {
 		let mut widgets = vec![TextLabel::new(label_text).widget_holder()];
 
@@ -112,7 +112,7 @@ impl ToolColorOptions {
 		widgets.push(radio);
 		widgets.push(Separator::new(SeparatorType::Related).widget_holder());
 
-		let color_button = ColorButton::new(FillChoice::from_optional_color(self.active_color()))
+		let color_button = ColorInput::new(FillChoice::from_optional_color(self.active_color()))
 			.allow_none(color_allow_none)
 			.on_update(color_callback);
 		widgets.push(color_button.widget_holder());
diff --git a/editor/src/messages/tool/tool_messages/brush_tool.rs b/editor/src/messages/tool/tool_messages/brush_tool.rs
index 4cacebe355..383e54d59d 100644
--- a/editor/src/messages/tool/tool_messages/brush_tool.rs
+++ b/editor/src/messages/tool/tool_messages/brush_tool.rs
@@ -156,7 +156,7 @@ impl LayoutHolder for BrushTool {
 			false,
 			|_| BrushToolMessage::UpdateOptions(BrushToolMessageOptionsUpdate::Color(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| BrushToolMessage::UpdateOptions(BrushToolMessageOptionsUpdate::ColorType(color_type.clone())).into()),
-			|color: &ColorButton| BrushToolMessage::UpdateOptions(BrushToolMessageOptionsUpdate::Color(color.value.as_solid())).into(),
+			|color: &ColorInput| BrushToolMessage::UpdateOptions(BrushToolMessageOptionsUpdate::Color(color.value.as_solid())).into(),
 		));
 
 		widgets.push(Separator::new(SeparatorType::Related).widget_holder());
diff --git a/editor/src/messages/tool/tool_messages/ellipse_tool.rs b/editor/src/messages/tool/tool_messages/ellipse_tool.rs
index ecbd2cbb5d..858c177eb5 100644
--- a/editor/src/messages/tool/tool_messages/ellipse_tool.rs
+++ b/editor/src/messages/tool/tool_messages/ellipse_tool.rs
@@ -91,7 +91,7 @@ impl LayoutHolder for EllipseTool {
 			true,
 			|_| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::FillColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::FillColorType(color_type.clone())).into()),
-			|color: &ColorButton| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::FillColor(color.value.as_solid())).into(),
+			|color: &ColorInput| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::FillColor(color.value.as_solid())).into(),
 		);
 
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@@ -101,7 +101,7 @@ impl LayoutHolder for EllipseTool {
 			true,
 			|_| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::StrokeColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::StrokeColorType(color_type.clone())).into()),
-			|color: &ColorButton| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
+			|color: &ColorInput| EllipseToolMessage::UpdateOptions(EllipseOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
 		));
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
 		widgets.push(create_weight_widget(self.options.line_weight));
diff --git a/editor/src/messages/tool/tool_messages/freehand_tool.rs b/editor/src/messages/tool/tool_messages/freehand_tool.rs
index c75092a87c..7d48148acd 100644
--- a/editor/src/messages/tool/tool_messages/freehand_tool.rs
+++ b/editor/src/messages/tool/tool_messages/freehand_tool.rs
@@ -99,7 +99,7 @@ impl LayoutHolder for FreehandTool {
 			true,
 			|_| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::FillColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::FillColorType(color_type.clone())).into()),
-			|color: &ColorButton| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::FillColor(color.value.as_solid())).into(),
+			|color: &ColorInput| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::FillColor(color.value.as_solid())).into(),
 		);
 
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@@ -109,7 +109,7 @@ impl LayoutHolder for FreehandTool {
 			true,
 			|_| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::StrokeColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::StrokeColorType(color_type.clone())).into()),
-			|color: &ColorButton| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
+			|color: &ColorInput| FreehandToolMessage::UpdateOptions(FreehandOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
 		));
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
 		widgets.push(create_weight_widget(self.options.line_weight));
diff --git a/editor/src/messages/tool/tool_messages/line_tool.rs b/editor/src/messages/tool/tool_messages/line_tool.rs
index b866129944..65bfa2a45d 100644
--- a/editor/src/messages/tool/tool_messages/line_tool.rs
+++ b/editor/src/messages/tool/tool_messages/line_tool.rs
@@ -85,7 +85,7 @@ impl LayoutHolder for LineTool {
 			true,
 			|_| LineToolMessage::UpdateOptions(LineOptionsUpdate::StrokeColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| LineToolMessage::UpdateOptions(LineOptionsUpdate::StrokeColorType(color_type.clone())).into()),
-			|color: &ColorButton| LineToolMessage::UpdateOptions(LineOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
+			|color: &ColorInput| LineToolMessage::UpdateOptions(LineOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
 		);
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
 		widgets.push(create_weight_widget(self.options.line_weight));
diff --git a/editor/src/messages/tool/tool_messages/pen_tool.rs b/editor/src/messages/tool/tool_messages/pen_tool.rs
index 278771c310..9ec62caebc 100644
--- a/editor/src/messages/tool/tool_messages/pen_tool.rs
+++ b/editor/src/messages/tool/tool_messages/pen_tool.rs
@@ -114,7 +114,7 @@ impl LayoutHolder for PenTool {
 			true,
 			|_| PenToolMessage::UpdateOptions(PenOptionsUpdate::FillColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| PenToolMessage::UpdateOptions(PenOptionsUpdate::FillColorType(color_type.clone())).into()),
-			|color: &ColorButton| PenToolMessage::UpdateOptions(PenOptionsUpdate::FillColor(color.value.as_solid())).into(),
+			|color: &ColorInput| PenToolMessage::UpdateOptions(PenOptionsUpdate::FillColor(color.value.as_solid())).into(),
 		);
 
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@@ -124,7 +124,7 @@ impl LayoutHolder for PenTool {
 			true,
 			|_| PenToolMessage::UpdateOptions(PenOptionsUpdate::StrokeColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| PenToolMessage::UpdateOptions(PenOptionsUpdate::StrokeColorType(color_type.clone())).into()),
-			|color: &ColorButton| PenToolMessage::UpdateOptions(PenOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
+			|color: &ColorInput| PenToolMessage::UpdateOptions(PenOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
 		));
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
 		widgets.push(create_weight_widget(self.options.line_weight));
diff --git a/editor/src/messages/tool/tool_messages/polygon_tool.rs b/editor/src/messages/tool/tool_messages/polygon_tool.rs
index 1d27f38317..0097c22a18 100644
--- a/editor/src/messages/tool/tool_messages/polygon_tool.rs
+++ b/editor/src/messages/tool/tool_messages/polygon_tool.rs
@@ -135,7 +135,7 @@ impl LayoutHolder for PolygonTool {
 			true,
 			|_| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::FillColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::FillColorType(color_type.clone())).into()),
-			|color: &ColorButton| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::FillColor(color.value.as_solid())).into(),
+			|color: &ColorInput| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::FillColor(color.value.as_solid())).into(),
 		));
 
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@@ -145,7 +145,7 @@ impl LayoutHolder for PolygonTool {
 			true,
 			|_| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::StrokeColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::StrokeColorType(color_type.clone())).into()),
-			|color: &ColorButton| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
+			|color: &ColorInput| PolygonToolMessage::UpdateOptions(PolygonOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
 		));
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
 		widgets.push(create_weight_widget(self.options.line_weight));
diff --git a/editor/src/messages/tool/tool_messages/rectangle_tool.rs b/editor/src/messages/tool/tool_messages/rectangle_tool.rs
index 09b589024f..fb34ed88eb 100644
--- a/editor/src/messages/tool/tool_messages/rectangle_tool.rs
+++ b/editor/src/messages/tool/tool_messages/rectangle_tool.rs
@@ -77,7 +77,7 @@ impl LayoutHolder for RectangleTool {
 			true,
 			|_| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::FillColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::FillColorType(color_type.clone())).into()),
-			|color: &ColorButton| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::FillColor(color.value.as_solid())).into(),
+			|color: &ColorInput| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::FillColor(color.value.as_solid())).into(),
 		);
 
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@@ -87,7 +87,7 @@ impl LayoutHolder for RectangleTool {
 			true,
 			|_| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::StrokeColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::StrokeColorType(color_type.clone())).into()),
-			|color: &ColorButton| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
+			|color: &ColorInput| RectangleToolMessage::UpdateOptions(RectangleOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
 		));
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
 		widgets.push(create_weight_widget(self.options.line_weight));
diff --git a/editor/src/messages/tool/tool_messages/spline_tool.rs b/editor/src/messages/tool/tool_messages/spline_tool.rs
index 7f1ab31fb4..f615c69cc7 100644
--- a/editor/src/messages/tool/tool_messages/spline_tool.rs
+++ b/editor/src/messages/tool/tool_messages/spline_tool.rs
@@ -102,7 +102,7 @@ impl LayoutHolder for SplineTool {
 			true,
 			|_| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::FillColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::FillColorType(color_type.clone())).into()),
-			|color: &ColorButton| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::FillColor(color.value.as_solid())).into(),
+			|color: &ColorInput| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::FillColor(color.value.as_solid())).into(),
 		);
 
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
@@ -112,7 +112,7 @@ impl LayoutHolder for SplineTool {
 			true,
 			|_| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::StrokeColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::StrokeColorType(color_type.clone())).into()),
-			|color: &ColorButton| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
+			|color: &ColorInput| SplineToolMessage::UpdateOptions(SplineOptionsUpdate::StrokeColor(color.value.as_solid())).into(),
 		));
 		widgets.push(Separator::new(SeparatorType::Unrelated).widget_holder());
 		widgets.push(create_weight_widget(self.options.line_weight));
diff --git a/editor/src/messages/tool/tool_messages/text_tool.rs b/editor/src/messages/tool/tool_messages/text_tool.rs
index 14da9427fc..69b1e2e8d8 100644
--- a/editor/src/messages/tool/tool_messages/text_tool.rs
+++ b/editor/src/messages/tool/tool_messages/text_tool.rs
@@ -160,7 +160,7 @@ impl LayoutHolder for TextTool {
 			true,
 			|_| TextToolMessage::UpdateOptions(TextOptionsUpdate::FillColor(None)).into(),
 			|color_type: ToolColorType| WidgetCallback::new(move |_| TextToolMessage::UpdateOptions(TextOptionsUpdate::FillColorType(color_type.clone())).into()),
-			|color: &ColorButton| TextToolMessage::UpdateOptions(TextOptionsUpdate::FillColor(color.value.as_solid())).into(),
+			|color: &ColorInput| TextToolMessage::UpdateOptions(TextOptionsUpdate::FillColor(color.value.as_solid())).into(),
 		));
 
 		Layout::WidgetLayout(WidgetLayout::new(vec![LayoutGroup::Row { widgets }]))
diff --git a/frontend/src/components/Editor.svelte b/frontend/src/components/Editor.svelte
index 36c0c6a04a..634ddda395 100644
--- a/frontend/src/components/Editor.svelte
+++ b/frontend/src/components/Editor.svelte
@@ -138,6 +138,7 @@
 			linear-gradient(45deg, #cccccc 25%, transparent 25%, transparent 75%, #cccccc 75%), linear-gradient(#ffffff, #ffffff);
 		--color-transparent-checkered-background-size: 16px 16px, 16px 16px, 16px 16px;
 		--color-transparent-checkered-background-position: 0 0, 8px 8px, 8px 8px;
+		--color-transparent-checkered-background-position-plus-one: 1px 1px, 9px 9px, 9px 9px;
 		--color-transparent-checkered-background-size-mini: 8px 8px, 8px 8px, 8px 8px;
 		--color-transparent-checkered-background-position-mini: 0 0, 4px 4px, 4px 4px;
 		--color-transparent-checkered-background-repeat: repeat, repeat, repeat;
diff --git a/frontend/src/components/floating-menus/ColorPicker.svelte b/frontend/src/components/floating-menus/ColorPicker.svelte
index bf70447f71..6167897b16 100644
--- a/frontend/src/components/floating-menus/ColorPicker.svelte
+++ b/frontend/src/components/floating-menus/ColorPicker.svelte
@@ -3,10 +3,11 @@
 
 	import type { Editor } from "@graphite/editor";
 	import type { HSV, RGB, FillChoice } from "@graphite/messages";
-	import { Color, Gradient } from "@graphite/messages";
+	import { Color, contrastingOutlineFactor, Gradient } from "@graphite/messages";
 	import { clamp } from "@graphite/utility-functions/math";
 
 	import FloatingMenu, { type MenuDirection } from "@graphite/components/layout/FloatingMenu.svelte";
+	import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
 	import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
 	import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
 	import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte";
@@ -65,7 +66,19 @@
 	// Transient state
 	let draggingPickerTrack: HTMLDivElement | undefined = undefined;
 	let strayCloses = true;
-
+	let gradientSpectrumDragging = false;
+	let shiftPressed = false;
+	let alignedAxis: "saturation" | "value" | undefined = undefined;
+	let hueBeforeDrag = 0;
+	let saturationBeforeDrag = 0;
+	let valueBeforeDrag = 0;
+	let alphaBeforeDrag = 0;
+	let saturationStartOfAxisAlign: number | undefined = undefined;
+	let valueStartOfAxisAlign: number | undefined = undefined;
+	let saturationRestoreWhenShiftReleased: number | undefined = undefined;
+	let valueRestoreWhenShiftReleased: number | undefined = undefined;
+
+	let self: FloatingMenu | undefined;
 	let hexCodeInputWidget: TextInput | undefined;
 	let gradientSpectrumInputWidget: SpectrumInput | undefined;
 
@@ -77,6 +90,9 @@
 	$: rgbChannels = Object.entries(newColor.toRgb255() || { r: undefined, g: undefined, b: undefined }) as [keyof RGB, number | undefined][];
 	$: hsvChannels = Object.entries(!isNone ? { h: hue * 360, s: saturation * 100, v: value * 100 } : { h: undefined, s: undefined, v: undefined }) as [keyof HSV, number | undefined][];
 	$: opaqueHueColor = new Color({ h: hue, s: 1, v: 1, a: 1 });
+	$: outlineFactor = Math.max(contrastingOutlineFactor(newColor, "--color-2-mildblack", 0.01), contrastingOutlineFactor(oldColor, "--color-2-mildblack", 0.01));
+	$: outlined = outlineFactor > 0.0001;
+	$: transparency = newColor.alpha < 1 || oldColor.alpha < 1;
 
 	function generateColor(h: number, s: number, v: number, a: number, none: boolean) {
 		if (none) return new Color("none");
@@ -119,6 +135,14 @@
 		const target = (e.target || undefined) as HTMLElement | undefined;
 		draggingPickerTrack = target?.closest("[data-saturation-value-picker], [data-hue-picker], [data-alpha-picker]") || undefined;
 
+		hueBeforeDrag = hue;
+		saturationBeforeDrag = saturation;
+		valueBeforeDrag = value;
+		alphaBeforeDrag = alpha;
+
+		saturationStartOfAxisAlign = undefined;
+		valueStartOfAxisAlign = undefined;
+
 		addEvents();
 
 		onPointerMove(e);
@@ -134,6 +158,8 @@
 			saturation = clamp((e.clientX - rectangle.left) / rectangle.width, 0, 1);
 			value = clamp(1 - (e.clientY - rectangle.top) / rectangle.height, 0, 1);
 			strayCloses = false;
+
+			if (shiftPressed) updateAxisLock();
 		} else if (draggingPickerTrack?.hasAttribute("data-hue-picker")) {
 			const rectangle = draggingPickerTrack.getBoundingClientRect();
 
@@ -148,25 +174,102 @@
 
 		const color = new Color({ h: hue, s: saturation, v: value, a: alpha });
 		setColor(color);
+
+		if (!e.shiftKey) {
+			shiftPressed = false;
+			alignedAxis = undefined;
+		} else if (!shiftPressed && draggingPickerTrack) {
+			shiftPressed = true;
+			saturationStartOfAxisAlign = saturation;
+			valueStartOfAxisAlign = value;
+		}
 	}
 
 	function onPointerUp() {
 		removeEvents();
 	}
 
+	function onMouseDown(e: MouseEvent) {
+		const BUTTONS_RIGHT = 0b0000_0010;
+		if (e.buttons & BUTTONS_RIGHT) abortDrag();
+	}
+
+	function onKeyDown(e: KeyboardEvent) {
+		if (e.key === "Escape") {
+			const element = self?.div();
+			if (element) preventEscapeClosingParentFloatingMenu(element);
+
+			abortDrag();
+		}
+	}
+
+	function onKeyUp(e: KeyboardEvent) {
+		if (e.key === "Shift") {
+			shiftPressed = false;
+			alignedAxis = undefined;
+
+			if (saturationRestoreWhenShiftReleased !== undefined && valueRestoreWhenShiftReleased !== undefined) {
+				saturation = saturationRestoreWhenShiftReleased;
+				value = valueRestoreWhenShiftReleased;
+
+				const color = new Color({ h: hue, s: saturation, v: value, a: alpha });
+				setColor(color);
+			}
+		}
+	}
+
 	function addEvents() {
 		document.addEventListener("pointermove", onPointerMove);
 		document.addEventListener("pointerup", onPointerUp);
+		document.addEventListener("mousedown", onMouseDown);
+		document.addEventListener("keydown", onKeyDown);
+		document.addEventListener("keyup", onKeyUp);
 
 		dispatch("startHistoryTransaction");
 	}
 
 	function removeEvents() {
 		draggingPickerTrack = undefined;
-		strayCloses = true;
+		// The setTimeout is necessary to prevent the FloatingMenu's `escapeCloses` from becoming true immediately upon pressing the Escape key, and thus closing
+		setTimeout(() => (strayCloses = true), 0);
+		shiftPressed = false;
+		alignedAxis = undefined;
 
 		document.removeEventListener("pointermove", onPointerMove);
 		document.removeEventListener("pointerup", onPointerUp);
+		document.removeEventListener("mousedown", onMouseDown);
+		document.removeEventListener("keydown", onKeyDown);
+		document.removeEventListener("keyup", onKeyUp);
+	}
+
+	function updateAxisLock() {
+		if (!saturationStartOfAxisAlign || !valueStartOfAxisAlign) return;
+
+		const deltaSaturation = saturation - saturationStartOfAxisAlign;
+		const deltaValue = value - valueStartOfAxisAlign;
+
+		saturationRestoreWhenShiftReleased = saturation;
+		valueRestoreWhenShiftReleased = value;
+
+		if (Math.abs(deltaSaturation) < Math.abs(deltaValue)) {
+			alignedAxis = "saturation";
+			saturation = saturationStartOfAxisAlign;
+		} else {
+			alignedAxis = "value";
+			value = valueStartOfAxisAlign;
+		}
+	}
+
+	function abortDrag() {
+		removeEvents();
+
+		hue = hueBeforeDrag;
+		saturation = saturationBeforeDrag;
+		value = valueBeforeDrag;
+		alpha = alphaBeforeDrag;
+
+		const color = new Color({ h: hue, s: saturation, v: value, a: alpha });
+		setColor(color);
 	}
 
 	function setColor(color?: Color) {
@@ -299,7 +402,7 @@
 	});
 </script>
 
-<FloatingMenu class="color-picker" {open} on:open {strayCloses} {direction} type="Popover">
+<FloatingMenu class="color-picker" {open} on:open {strayCloses} escapeCloses={strayCloses && !gradientSpectrumDragging} {direction} type="Popover" bind:this={self}>
 	<LayoutRow
 		styles={{
 			"--new-color": newColor.toHexOptionalAlpha(),
@@ -318,6 +421,15 @@
 					{#if !isNone}
 						<div class="selection-circle" style:top={`${(1 - value) * 100}%`} style:left={`${saturation * 100}%`} />
 					{/if}
+					{#if alignedAxis}
+						<div
+							class="selection-circle-alignment"
+							class:saturation={alignedAxis === "saturation"}
+							class:value={alignedAxis === "value"}
+							style:top={`${(1 - value) * 100}%`}
+							style:left={`${saturation * 100}%`}
+						/>
+					{/if}
 				</LayoutCol>
 				<LayoutCol class="hue-picker" on:pointerdown={onPointerDown} data-hue-picker>
 					{#if !isNone}
@@ -340,6 +452,7 @@
 						}}
 						on:activeMarkerIndexChange={gradientActiveMarkerIndexChange}
 						activeMarkerIndex={activeIndex}
+						on:dragging={({ detail }) => (gradientSpectrumDragging = detail)}
 						bind:this={gradientSpectrumInputWidget}
 					/>
 					{#if gradientSpectrumInputWidget && activeIndex !== undefined}
@@ -360,6 +473,8 @@
 		<LayoutCol class="details">
 			<LayoutRow
 				class="choice-preview"
+				classes={{ outlined, transparency }}
+				styles={{ "--outline-amount": outlineFactor }}
 				tooltip={!newColor.equals(oldColor) ? "Comparison between the present color choice (left) and the color before any change was made (right)" : "The present color choice"}
 			>
 				{#if !newColor.equals(oldColor)}
@@ -453,7 +568,7 @@
 				</LayoutRow>
 			</LayoutRow>
 			<LayoutRow>
-				<TextLabel tooltip="Scale from transparent (0%) to opaque (100%) for the color's alpha channel">Alpha</TextLabel>
+				<TextLabel tooltip="Scale of translucency, from transparent (0%) to opaque (100%), for the color's alpha channel">Alpha</TextLabel>
 				<Separator type="Related" />
 				<NumberInput
 					value={!isNone ? alpha * 100 : undefined}
@@ -471,18 +586,18 @@
 					unit="%"
 					mode="Range"
 					displayDecimalPlaces={1}
-					tooltip={`Scale from transparent (0%) to opaque (100%) for the color's alpha channel`}
+					tooltip={`Scale of translucency, from transparent (0%) to opaque (100%), for the color's alpha channel`}
 				/>
 			</LayoutRow>
 			<LayoutRow class="leftover-space" />
 			<LayoutRow>
 				{#if allowNone && !gradient}
-					<button class="preset-color none" on:click={() => setColorPreset("none")} title="Set to no color" tabindex="0" />
+					<button class="preset-color none" on:click={() => setColorPreset("none")} title="Set to no color" tabindex="0"></button>
 					<Separator type="Related" />
 				{/if}
-				<button class="preset-color black" on:click={() => setColorPreset("black")} title="Set to black" tabindex="0" />
+				<button class="preset-color black" on:click={() => setColorPreset("black")} title="Set to black" tabindex="0"></button>
 				<Separator type="Related" />
-				<button class="preset-color white" on:click={() => setColorPreset("white")} title="Set to white" tabindex="0" />
+				<button class="preset-color white" on:click={() => setColorPreset("white")} title="Set to white" tabindex="0"></button>
 				<Separator type="Related" />
 				<button class="preset-color pure" on:click={setColorPresetSubtile} tabindex="-1">
 					<div data-pure-tile="red" style="--pure-color: #ff0000; --pure-color-gray: #4c4c4c" title="Set to red" />
@@ -501,10 +616,13 @@
 
 <style lang="scss" global>
 	.color-picker {
+		--picker-size: 256px;
+		--picker-circle-radius: 6px;
+
 		.pickers-and-gradient {
 			.pickers {
 				.saturation-value-picker {
-					width: 256px;
+					width: var(--picker-size);
 					background-blend-mode: multiply;
 					background: linear-gradient(to bottom, #ffffff, #000000), linear-gradient(to right, #ffffff, var(--hue-color));
 					position: relative;
@@ -513,7 +631,7 @@
 				.saturation-value-picker,
 				.hue-picker,
 				.alpha-picker {
-					height: 256px;
+					height: var(--picker-size);
 					border-radius: 2px;
 					position: relative;
 					overflow: hidden;
@@ -552,8 +670,8 @@
 
 				.selection-circle {
 					position: absolute;
-					left: 0%;
-					top: 0%;
+					left: 0;
+					top: 0;
 					width: 0;
 					height: 0;
 					pointer-events: none;
@@ -562,19 +680,59 @@
 						content: "";
 						display: block;
 						position: relative;
-						left: -6px;
-						top: -6px;
-						width: 12px;
-						height: 12px;
+						left: calc(-1 * var(--picker-circle-radius));
+						top: calc(-1 * var(--picker-circle-radius));
+						width: calc(var(--picker-circle-radius) * 2 + 1px);
+						height: calc(var(--picker-circle-radius) * 2 + 1px);
 						border-radius: 50%;
 						border: 2px solid var(--opaque-color-contrasting);
 						box-sizing: border-box;
 					}
 				}
 
+				.selection-circle-alignment {
+					position: absolute;
+					pointer-events: none;
+
+					&.saturation::before,
+					&.saturation::after,
+					&.value::before,
+					&.value::after {
+						content: "";
+						position: absolute;
+						background: var(--opaque-color-contrasting);
+						width: 1px;
+						height: 1px;
+					}
+
+					&.saturation {
+						&::before {
+							height: var(--picker-size);
+							margin-top: calc(-1 * var(--picker-size) - var(--picker-circle-radius));
+						}
+
+						&::after {
+							height: var(--picker-size);
+							margin-top: var(--picker-circle-radius);
+						}
+					}
+
+					&.value {
+						&::before {
+							width: var(--picker-size);
+							margin-left: var(--picker-circle-radius);
+						}
+
+						&::after {
+							width: var(--picker-size);
+							margin-left: calc(-1 * var(--picker-size) - var(--picker-circle-radius));
+						}
+					}
+				}
+
 				.selection-needle {
 					position: absolute;
-					top: 0%;
+					top: 0;
 					width: 100%;
 					height: 0;
 					pointer-events: none;
@@ -642,14 +800,27 @@
 				width: 100%;
 				height: 32px;
 				border-radius: 2px;
-				border: 1px solid var(--color-1-nearblack);
 				box-sizing: border-box;
 				overflow: hidden;
 				position: relative;
-				background-image: var(--color-transparent-checkered-background);
-				background-size: var(--color-transparent-checkered-background-size);
-				background-position: var(--color-transparent-checkered-background-position);
-				background-repeat: var(--color-transparent-checkered-background-repeat);
+
+				&.outlined::after {
+					content: "";
+					position: absolute;
+					top: 0;
+					bottom: 0;
+					left: 0;
+					right: 0;
+					box-shadow: inset 0 0 0 1px rgba(var(--color-0-black-rgb), var(--outline-amount));
+					pointer-events: none;
+				}
+
+				&.transparency {
+					background-image: var(--color-transparent-checkered-background);
+					background-size: var(--color-transparent-checkered-background-size);
+					background-position: var(--color-transparent-checkered-background-position);
+					background-repeat: var(--color-transparent-checkered-background-repeat);
+				}
 
 				.swap-button-background {
 					overflow: hidden;
diff --git a/frontend/src/components/layout/FloatingMenu.svelte b/frontend/src/components/layout/FloatingMenu.svelte
index 861435da79..7eea20d851 100644
--- a/frontend/src/components/layout/FloatingMenu.svelte
+++ b/frontend/src/components/layout/FloatingMenu.svelte
@@ -1,6 +1,22 @@
 <script lang="ts" context="module">
 	export type MenuDirection = "Top" | "Bottom" | "Left" | "Right" | "TopLeft" | "TopRight" | "BottomLeft" | "BottomRight" | "Center";
 	export type MenuType = "Popover" | "Dropdown" | "Dialog" | "Cursor";
+
+	/// Prevents the escape key from closing the parent floating menu of the given element.
+	/// This works by momentarily setting the `data-escape-does-not-close` attribute on the parent floating menu element.
+	/// After checking for the Escape key, it checks (in one `setTimeout`) for the attribute and ignores the key if it's present.
+	/// Then after two calls of `setTimeout`, we can safely remove the attribute here.
+	export function preventEscapeClosingParentFloatingMenu(element: HTMLElement) {
+		const floatingMenuParent = element.closest("[data-floating-menu-content]") || undefined;
+		if (floatingMenuParent instanceof HTMLElement) {
+			floatingMenuParent.setAttribute("data-escape-does-not-close", "");
+			setTimeout(() => {
+				setTimeout(() => {
+					floatingMenuParent.removeAttribute("data-escape-does-not-close");
+				}, 0);
+			}, 0);
+		}
+	}
 </script>
 
 <script lang="ts">
@@ -10,6 +26,7 @@
 
 	import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
 
+	const BUTTON_LEFT = 0;
 	const POINTER_STRAY_DISTANCE = 100;
 
 	const dispatch = createEventDispatcher<{ open: boolean; naturalWidth: number }>();
@@ -302,7 +319,8 @@
 		}
 
 		// Clean up any messes from lost pointerup events
-		const eventIncludesLmb = Boolean(e.buttons & 1);
+		const BUTTONS_LEFT = 0b0000_0001;
+		const eventIncludesLmb = Boolean(e.buttons & BUTTONS_LEFT);
 		if (!open && !eventIncludesLmb) {
 			pointerStillDown = false;
 			window.removeEventListener("pointerup", pointerUpHandler);
@@ -377,8 +395,15 @@
 	}
 
 	function keyDownHandler(e: KeyboardEvent) {
-		if (escapeCloses && e.key.toLowerCase() === "escape") {
-			dispatch("open", false);
+		if (escapeCloses && e.key === "Escape") {
+			setTimeout(() => {
+				if (!floatingMenuContainer?.querySelector("[data-floating-menu-content][data-escape-does-not-close]")) {
+					dispatch("open", false);
+				}
+			}, 0);
+
+			// Find the parent floating menu and prevent it from also closing with the escape key when this floating menu does
+			if (self) preventEscapeClosingParentFloatingMenu(self);
 		}
 	}
 
@@ -388,13 +413,13 @@
 			dispatch("open", false);
 
 			// Track if the left pointer button is now down so its later click event can be canceled
-			const eventIsForLmb = e.button === 0;
+			const eventIsForLmb = e.button === BUTTON_LEFT;
 			if (eventIsForLmb) pointerStillDown = true;
 		}
 	}
 
 	function pointerUpHandler(e: PointerEvent) {
-		const eventIsForLmb = e.button === 0;
+		const eventIsForLmb = e.button === BUTTON_LEFT;
 		if (pointerStillDown && eventIsForLmb) {
 			// Clean up self
 			pointerStillDown = false;
diff --git a/frontend/src/components/panels/Document.svelte b/frontend/src/components/panels/Document.svelte
index ece02f4bdd..dc969cd2ad 100644
--- a/frontend/src/components/panels/Document.svelte
+++ b/frontend/src/components/panels/Document.svelte
@@ -169,16 +169,6 @@
 		editor.handle.panCanvas(0, -delta * scrollbarMultiplier.y);
 	}
 
-	function pageX(delta: number) {
-		const move = delta < 0 ? 1 : -1;
-		editor.handle.panCanvasByFraction(move, 0);
-	}
-
-	function pageY(delta: number) {
-		const move = delta < 0 ? 1 : -1;
-		editor.handle.panCanvasByFraction(0, move);
-	}
-
 	function canvasPointerDown(e: PointerEvent) {
 		const onEditbox = e.target instanceof HTMLDivElement && e.target.contentEditable;
 
@@ -549,21 +539,25 @@
 				<LayoutCol class="ruler-or-scrollbar right-scrollbar">
 					<ScrollbarInput
 						direction="Vertical"
-						handleLength={scrollbarSize.y}
-						handlePosition={scrollbarPos.y}
-						on:handlePosition={({ detail }) => panCanvasY(detail)}
-						on:pressTrack={({ detail }) => pageY(detail)}
+						thumbLength={scrollbarSize.y}
+						thumbPosition={scrollbarPos.y}
+						on:trackShift={({ detail }) => editor.handle.panCanvasByFraction(0, detail)}
+						on:thumbPosition={({ detail }) => panCanvasY(detail)}
+						on:thumbDragStart={() => editor.handle.panCanvasAbortPrepare(false)}
+						on:thumbDragAbort={() => editor.handle.panCanvasAbort(false)}
 					/>
 				</LayoutCol>
 			</LayoutRow>
 			<LayoutRow class="ruler-or-scrollbar bottom-scrollbar">
 				<ScrollbarInput
 					direction="Horizontal"
-					handleLength={scrollbarSize.x}
-					handlePosition={scrollbarPos.x}
-					on:handlePosition={({ detail }) => panCanvasX(detail)}
-					on:pressTrack={({ detail }) => pageX(detail)}
-					on:pointerup={() => editor.handle.setGridAlignedEdges()}
+					thumbLength={scrollbarSize.x}
+					thumbPosition={scrollbarPos.x}
+					on:trackShift={({ detail }) => editor.handle.panCanvasByFraction(detail, 0)}
+					on:thumbPosition={({ detail }) => panCanvasX(detail)}
+					on:thumbDragEnd={() => editor.handle.setGridAlignedEdges()}
+					on:thumbDragStart={() => editor.handle.panCanvasAbortPrepare(true)}
+					on:thumbDragAbort={() => editor.handle.panCanvasAbort(true)}
 				/>
 			</LayoutRow>
 		</LayoutCol>
diff --git a/frontend/src/components/panels/Layers.svelte b/frontend/src/components/panels/Layers.svelte
index ec214de7cb..f98058fea6 100644
--- a/frontend/src/components/panels/Layers.svelte
+++ b/frontend/src/components/panels/Layers.svelte
@@ -435,7 +435,7 @@
 							title={listing.entry.expanded ? "Collapse" : `Expand${listing.entry.ancestorOfSelected ? "\n(A selected layer is contained within)" : ""}`}
 							on:click|stopPropagation={() => handleExpandArrowClick(listing.entry.id)}
 							tabindex="0"
-						/>
+						></button>
 					{/if}
 					<div class="thumbnail">
 						{#if $nodeGraph.thumbnails.has(listing.entry.id)}
diff --git a/frontend/src/components/widgets/WidgetSpan.svelte b/frontend/src/components/widgets/WidgetSpan.svelte
index 2bab081416..0f95212529 100644
--- a/frontend/src/components/widgets/WidgetSpan.svelte
+++ b/frontend/src/components/widgets/WidgetSpan.svelte
@@ -8,13 +8,13 @@
 
 	import NodeCatalog from "@graphite/components/floating-menus/NodeCatalog.svelte";
 	import BreadcrumbTrailButtons from "@graphite/components/widgets/buttons/BreadcrumbTrailButtons.svelte";
-	import ColorButton from "@graphite/components/widgets/buttons/ColorButton.svelte";
 	import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte";
 	import ImageButton from "@graphite/components/widgets/buttons/ImageButton.svelte";
 	import ParameterExposeButton from "@graphite/components/widgets/buttons/ParameterExposeButton.svelte";
 	import PopoverButton from "@graphite/components/widgets/buttons/PopoverButton.svelte";
 	import TextButton from "@graphite/components/widgets/buttons/TextButton.svelte";
 	import CheckboxInput from "@graphite/components/widgets/inputs/CheckboxInput.svelte";
+	import ColorInput from "@graphite/components/widgets/inputs/ColorInput.svelte";
 	import CurveInput from "@graphite/components/widgets/inputs/CurveInput.svelte";
 	import DropdownInput from "@graphite/components/widgets/inputs/DropdownInput.svelte";
 	import FontInput from "@graphite/components/widgets/inputs/FontInput.svelte";
@@ -87,9 +87,9 @@
 		{#if checkboxInput}
 			<CheckboxInput {...exclude(checkboxInput)} on:checked={({ detail }) => widgetValueCommitAndUpdate(index, detail)} />
 		{/if}
-		{@const colorInput = narrowWidgetProps(component.props, "ColorButton")}
+		{@const colorInput = narrowWidgetProps(component.props, "ColorInput")}
 		{#if colorInput}
-			<ColorButton {...exclude(colorInput)} on:value={({ detail }) => widgetValueUpdate(index, detail)} on:startHistoryTransaction={() => widgetValueCommit(index, colorInput.value)} />
+			<ColorInput {...exclude(colorInput)} on:value={({ detail }) => widgetValueUpdate(index, detail)} on:startHistoryTransaction={() => widgetValueCommit(index, colorInput.value)} />
 		{/if}
 		{@const curvesInput = narrowWidgetProps(component.props, "CurveInput")}
 		{#if curvesInput}
diff --git a/frontend/src/components/widgets/buttons/ColorButton.svelte b/frontend/src/components/widgets/buttons/ColorButton.svelte
deleted file mode 100644
index 7b91448e5e..0000000000
--- a/frontend/src/components/widgets/buttons/ColorButton.svelte
+++ /dev/null
@@ -1,129 +0,0 @@
-<script lang="ts">
-	import { createEventDispatcher } from "svelte";
-
-	import type { FillChoice } from "@graphite/messages";
-	import { Color, Gradient } from "@graphite/messages";
-
-	import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte";
-	import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
-	import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
-
-	const dispatch = createEventDispatcher<{ value: FillChoice; startHistoryTransaction: undefined }>();
-
-	let open = false;
-
-	export let value: FillChoice;
-	export let disabled = false;
-	export let allowNone = false;
-	// export let allowTransparency = false; // TODO: Implement
-	export let tooltip: string | undefined = undefined;
-
-	$: chosenGradient = value instanceof Gradient ? value.toLinearGradientCSS() : `linear-gradient(${value.toHexOptionalAlpha()}, ${value.toHexOptionalAlpha()})`;
-</script>
-
-<LayoutCol class="color-button" classes={{ disabled, none: value instanceof Color ? value.none : false, open }} {tooltip}>
-	<button {disabled} style:--chosen-gradient={chosenGradient} on:click={() => (open = true)} tabindex="0" data-floating-menu-spawner></button>
-	{#if disabled && value instanceof Color && !value.none}
-		<TextLabel>sRGB</TextLabel>
-	{/if}
-	<ColorPicker
-		{open}
-		on:open={({ detail }) => (open = detail)}
-		colorOrGradient={value}
-		on:colorOrGradient={({ detail }) => {
-			value = detail;
-			dispatch("value", detail);
-		}}
-		on:startHistoryTransaction={() => {
-			// This event is sent to the backend so it knows to start a transaction for the history system. See discussion for some explanation:
-			// <https://github.com/GraphiteEditor/Graphite/pull/1584#discussion_r1477592483>
-			dispatch("startHistoryTransaction");
-		}}
-		{allowNone}
-	/>
-</LayoutCol>
-
-<style lang="scss" global>
-	.color-button {
-		position: relative;
-		min-width: 80px;
-		border-radius: 2px;
-		background: var(--color-5-dullgray);
-
-		&:hover,
-		&.open {
-			&,
-			> .text-label {
-				background: rgba(var(--color-6-lowergray-rgb), 0.5);
-			}
-		}
-
-		&.disabled {
-			&,
-			> .text-label {
-				background: var(--color-4-dimgray);
-				color: var(--color-8-uppergray);
-			}
-		}
-
-		> button {
-			border: none;
-			padding: 0;
-			margin: 0;
-			margin-left: 2px;
-			margin-top: 2px;
-			width: calc(100% - 4px);
-			height: calc(100% - 4px);
-			background-image: var(--chosen-gradient), var(--color-transparent-checkered-background);
-			background-size:
-				100% 100%,
-				var(--color-transparent-checkered-background-size);
-			background-position:
-				0 0,
-				var(--color-transparent-checkered-background-position);
-			background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
-		}
-
-		&.none {
-			> button {
-				background: var(--color-none);
-				background-repeat: var(--color-none-repeat);
-				background-position: var(--color-none-position);
-				background-size: var(--color-none-size-24px);
-				background-image: var(--color-none-image-24px);
-			}
-
-			&.disabled {
-				> button::after {
-					content: "";
-					position: absolute;
-					top: 0;
-					bottom: 0;
-					left: 0;
-					right: 0;
-					background: rgba(var(--color-4-dimgray-rgb), 0.5);
-				}
-			}
-		}
-
-		> .text-label {
-			background: rgba(var(--color-5-dullgray-rgb), 0.5);
-			font-size: 10px;
-			line-height: 12px;
-			height: 12px;
-			border-radius: 6px 0 0 6px;
-			padding-right: 2px;
-			padding-left: 4px;
-			margin: auto;
-			position: absolute;
-			right: 0;
-			top: 0;
-			bottom: 0;
-		}
-
-		> .floating-menu {
-			left: 50%;
-			bottom: 0;
-		}
-	}
-</style>
diff --git a/frontend/src/components/widgets/inputs/ColorInput.svelte b/frontend/src/components/widgets/inputs/ColorInput.svelte
new file mode 100644
index 0000000000..1dd01ae244
--- /dev/null
+++ b/frontend/src/components/widgets/inputs/ColorInput.svelte
@@ -0,0 +1,141 @@
+<script lang="ts">
+	import { createEventDispatcher } from "svelte";
+
+	import type { FillChoice } from "@graphite/messages";
+	import { Color, contrastingOutlineFactor, Gradient } from "@graphite/messages";
+
+	import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte";
+	import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
+	import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
+
+	const dispatch = createEventDispatcher<{ value: FillChoice; startHistoryTransaction: undefined }>();
+
+	let open = false;
+
+	export let value: FillChoice;
+	export let disabled = false;
+	export let allowNone = false;
+	// export let allowTransparency = false; // TODO: Implement
+	export let tooltip: string | undefined = undefined;
+
+	$: outlineFactor = contrastingOutlineFactor(value, ["--color-1-nearblack", "--color-3-darkgray"], 0.01);
+	$: outlined = outlineFactor > 0.0001;
+	$: chosenGradient = value instanceof Gradient ? value.toLinearGradientCSS() : `linear-gradient(${value.toHexOptionalAlpha()}, ${value.toHexOptionalAlpha()})`;
+	$: none = value instanceof Color ? value.none : false;
+	$: transparency = value instanceof Gradient ? value.stops.some((stop) => stop.color.alpha < 1) : value.alpha < 1;
+</script>
+
+<LayoutCol class="color-button" classes={{ open, disabled, none, transparency, outlined }} {tooltip}>
+	<button {disabled} style:--chosen-gradient={chosenGradient} style:--outline-amount={outlineFactor} on:click={() => (open = true)} tabindex="0" data-floating-menu-spawner>
+		{#if disabled && value instanceof Color && !value.none}
+			<TextLabel>sRGB</TextLabel>
+		{/if}
+	</button>
+	<ColorPicker
+		{open}
+		on:open={({ detail }) => (open = detail)}
+		colorOrGradient={value}
+		on:colorOrGradient={({ detail }) => {
+			value = detail;
+			dispatch("value", detail);
+		}}
+		on:startHistoryTransaction={() => {
+			// This event is sent to the backend so it knows to start a transaction for the history system. See discussion for some explanation:
+			// <https://github.com/GraphiteEditor/Graphite/pull/1584#discussion_r1477592483>
+			dispatch("startHistoryTransaction");
+		}}
+		{allowNone}
+	/>
+</LayoutCol>
+
+<style lang="scss" global>
+	.color-button {
+		position: relative;
+		min-width: 80px;
+
+		> button {
+			border: none;
+			border-radius: 2px;
+			padding: 0;
+			margin: 0;
+			width: 100%;
+			height: 100%;
+			overflow: hidden;
+			position: relative;
+
+			&::before {
+				content: "";
+				position: absolute;
+				top: 0;
+				bottom: 0;
+				left: 0;
+				right: 0;
+				background: var(--chosen-gradient);
+			}
+
+			.text-label {
+				background: var(--color-5-dullgray);
+				font-size: 10px;
+				line-height: 12px;
+				height: 12px;
+				border-radius: 0 0 0 2px;
+				padding-right: 4px;
+				padding-left: 4px;
+				position: absolute;
+				right: 0;
+				top: 0;
+			}
+		}
+
+		&.outlined > button::after {
+			content: "";
+			position: absolute;
+			top: 0;
+			bottom: 0;
+			left: 0;
+			right: 0;
+			box-shadow: inset 0 0 0 1px rgba(var(--color-5-dullgray-rgb), var(--outline-amount));
+		}
+
+		&.transparency > button {
+			background-image: var(--color-transparent-checkered-background);
+			background-size: var(--color-transparent-checkered-background-size);
+			background-position: var(--color-transparent-checkered-background-position);
+			background-repeat: var(--color-transparent-checkered-background-repeat);
+		}
+
+		&:not(.disabled).none > button {
+			background: var(--color-none);
+			background-repeat: var(--color-none-repeat);
+			background-position: var(--color-none-position);
+			background-size: var(--color-none-size-24px);
+			background-image: var(--color-none-image-24px);
+		}
+
+		&.disabled.none > button::after {
+			content: "";
+			position: absolute;
+			top: 0;
+			bottom: 0;
+			left: 0;
+			right: 0;
+			background: var(--color-4-dimgray);
+		}
+
+		&:not(.disabled):hover > button .text-label,
+		&:not(.disabled).open > button .text-label {
+			background: var(--color-6-lowergray);
+			color: var(--color-f-white);
+		}
+
+		&.disabled > button .text-label {
+			background: var(--color-4-dimgray);
+			color: var(--color-8-uppergray);
+		}
+
+		> .floating-menu {
+			left: 50%;
+			bottom: 0;
+		}
+	}
+</style>
diff --git a/frontend/src/components/widgets/inputs/FieldInput.svelte b/frontend/src/components/widgets/inputs/FieldInput.svelte
index 4a92fc71e3..2efc1353b9 100644
--- a/frontend/src/components/widgets/inputs/FieldInput.svelte
+++ b/frontend/src/components/widgets/inputs/FieldInput.svelte
@@ -3,6 +3,7 @@
 
 	import { platformIsMac } from "@graphite/utility-functions/platform";
 
+	import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
 	import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
 
 	const dispatch = createEventDispatcher<{
@@ -65,6 +66,12 @@
 	export function element(): HTMLInputElement | HTMLTextAreaElement | undefined {
 		return inputOrTextarea;
 	}
+
+	function cancel() {
+		dispatch("textChangeCanceled");
+
+		if (inputOrTextarea) preventEscapeClosingParentFloatingMenu(inputOrTextarea);
+	}
 </script>
 
 <!-- This is a base component, extended by others like NumberInput and TextInput. It should not be used directly. -->
@@ -83,7 +90,7 @@
 			on:blur={() => dispatch("textChanged")}
 			on:change={() => dispatch("textChanged")}
 			on:keydown={(e) => e.key === "Enter" && dispatch("textChanged")}
-			on:keydown={(e) => e.key === "Escape" && dispatch("textChangeCanceled")}
+			on:keydown={(e) => e.key === "Escape" && cancel()}
 			on:pointerdown
 			on:contextmenu={(e) => hideContextMenu && e.preventDefault()}
 			data-input-element
@@ -102,7 +109,7 @@
 			on:blur={() => dispatch("textChanged")}
 			on:change={() => dispatch("textChanged")}
 			on:keydown={(e) => (macKeyboardLayout ? e.metaKey : e.ctrlKey) && e.key === "Enter" && dispatch("textChanged")}
-			on:keydown={(e) => e.key === "Escape" && dispatch("textChangeCanceled")}
+			on:keydown={(e) => e.key === "Escape" && cancel()}
 			on:pointerdown
 			on:contextmenu={(e) => hideContextMenu && e.preventDefault()}
 		/>
diff --git a/frontend/src/components/widgets/inputs/NumberInput.svelte b/frontend/src/components/widgets/inputs/NumberInput.svelte
index 08b75a5ef4..ae26c20ee8 100644
--- a/frontend/src/components/widgets/inputs/NumberInput.svelte
+++ b/frontend/src/components/widgets/inputs/NumberInput.svelte
@@ -1,9 +1,11 @@
 <script lang="ts">
 	import { createEventDispatcher, onMount, onDestroy } from "svelte";
 
+	import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/io-managers/input";
 	import { type NumberInputMode, type NumberInputIncrementBehavior } from "@graphite/messages";
 	import { evaluateMathExpression } from "@graphite-frontend/wasm/pkg/graphite_wasm.js";
 
+	import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
 	import FieldInput from "@graphite/components/widgets/inputs/FieldInput.svelte";
 
 	const BUTTONS_LEFT = 0b0000_0001;
@@ -59,8 +61,10 @@
 	let self: FieldInput | undefined;
 	let inputRangeElement: HTMLInputElement | undefined;
 	let text = displayText(value, unit);
-	let isDragging = false;
 	let editing = false;
+	let isDragging = false;
+	let pressingArrow = false;
+	let repeatTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
 	// Stays in sync with a binding to the actual input range slider element.
 	let rangeSliderValue = value !== undefined ? value : 0;
 	// Value used to render the position of the fake slider when applicable, and length of the progress colored region to the slider's left.
@@ -230,8 +234,8 @@
 	// INCREMENT MODE: ARROW BUTTONS
 	// =============================
 
-	function onIncrement(direction: "Decrease" | "Increase") {
-		if (value === undefined) return;
+	function onIncrementPointerDown(e: PointerEvent, direction: "Decrease" | "Increase") {
+		if (value === undefined || e.button !== BUTTON_LEFT) return;
 
 		const actions: Record<NumberInputIncrementBehavior, () => void> = {
 			Add: () => {
@@ -250,7 +254,41 @@
 			},
 			None: () => {},
 		};
-		actions[incrementBehavior]();
+
+		const sendAction = () => {
+			if (!pressingArrow) return;
+
+			actions[incrementBehavior]();
+
+			if (afterInitialDelay) repeatTimeout = setTimeout(sendAction, PRESS_REPEAT_INTERVAL_MS);
+			afterInitialDelay = true;
+		};
+
+		pressingArrow = true;
+		initialValueBeforeDragging = value;
+		let afterInitialDelay = false;
+		sendAction();
+		repeatTimeout = setTimeout(sendAction, PRESS_REPEAT_DELAY_MS);
+		addEventListener("keydown", incrementPressAbort);
+	}
+
+	function onIncrementPointerUp() {
+		pressingArrow = false;
+		clearTimeout(repeatTimeout);
+	}
+
+	function onIncrementMouseDown(e: MouseEvent) {
+		if (e.button === BUTTON_RIGHT) incrementPressAbort();
+	}
+
+	function incrementPressAbort() {
+		const element = self?.element() || undefined;
+		if (element) preventEscapeClosingParentFloatingMenu(element);
+
+		pressingArrow = false;
+		clearTimeout(repeatTimeout);
+		updateValue(initialValueBeforeDragging);
+		removeEventListener("keydown", onIncrementPointerUp);
 	}
 
 	// =======================================
@@ -399,8 +437,8 @@
 		if (rangeSliderClickDragState === "Aborted") {
 			// If we've just aborted the drag by right clicking, but the user hasn't yet released the left mouse button, Firefox treats
 			// some subsequent interactions with the slider (like that right mouse button release, or maybe mouse movement in some cases)
-			// as input changes to the slider position. Thus, until we leave the "Aborted" state by releasing all mouse buttons,
-			// we have to set the slider position back to currently intended value to fight against Firefox's attempts to let the user move it.
+			// as input changes to the slider position. Thus, until we leave the "Aborted" state by releasing all mouse buttons, we have
+			// to set the slider position back to the currently intended value to fight against Firefox's attempts to let the user move it.
 			updateValue(rangeSliderValueAsRendered);
 
 			// Now we exit early because we're ignoring further user input until the user releases all mouse buttons, which gets us back to the "Ready" state.
@@ -511,10 +549,7 @@
 		// Logic for aborting from pressing Escape.
 		if (e instanceof KeyboardEvent) {
 			// Detect if the user pressed Escape and abort the slider drag.
-			if (e.key === "Escape") {
-				// Call the abort helper function.
-				sliderAbort();
-			}
+			if (e.key === "Escape") sliderAbort(true);
 		}
 
 		// Logic for aborting from a right click.
@@ -522,7 +557,7 @@
 		// This handler's "pointermove" event will be fired upon right click even if the cursor didn't move, which is why it's okay to check in this event handler.
 		if (e instanceof PointerEvent && e.buttons & BUTTONS_RIGHT) {
 			// Call the abort helper function
-			sliderAbort();
+			sliderAbort(false);
 		}
 
 		// Recovery from the window losing focus while dragging the slider.
@@ -543,9 +578,11 @@
 	// During this momentary step, the slider hasn't moved yet but we want to allow aborting from this limbo state.
 	function sliderAbortFromMousedown(e: MouseEvent | KeyboardEvent) {
 		// Logic for aborting from a right click or pressing Escape.
-		if ((e instanceof KeyboardEvent && e.key === "Escape") || (e instanceof MouseEvent && e.button === BUTTON_RIGHT)) {
+		const abortWithEscape = e instanceof KeyboardEvent && e.key === "Escape";
+		const abortWithRightClick = e instanceof MouseEvent && e.button === BUTTON_RIGHT;
+		if (abortWithEscape || abortWithRightClick) {
 			// Call the abort helper function
-			sliderAbort();
+			sliderAbort(abortWithEscape);
 
 			// Clean up these event listeners because they were for getting us into this function and now we're done with them.
 			removeEventListener("mousedown", sliderAbortFromMousedown);
@@ -554,7 +591,10 @@
 	}
 
 	// Helper function that performs the state management and cleanup for aborting the slider drag.
-	function sliderAbort() {
+	function sliderAbort(abortWithEscape: boolean) {
+		const element = self?.element() || undefined;
+		if (abortWithEscape && element) preventEscapeClosingParentFloatingMenu(element);
+
 		// End the user's drag by instantaneously disabling and re-enabling the range input element
 		if (inputRangeElement) inputRangeElement.disabled = true;
 		setTimeout(() => {
@@ -580,9 +620,7 @@
 			// dragging the slider, hitting Escape, then releasing the mouse button. This results in being transferred by `onSliderInput()` to the
 			// "Deciding" state when we should remain in the "Ready" state as set here. (For debugging, this can be visualized in CSS by
 			// recoloring the fake slider handle, which is shown in the "Deciding" state.)
-			setTimeout(() => {
-				rangeSliderClickDragState = "Ready";
-			}, 0);
+			setTimeout(() => (rangeSliderClickDragState = "Ready"), 0);
 
 			// Clean up the event listener that was used to call this function.
 			removeEventListener("pointerup", sliderResetAbort);
@@ -617,8 +655,22 @@
 >
 	{#if value !== undefined}
 		{#if mode === "Increment" && incrementBehavior !== "None"}
-			<button class="arrow left" on:click={() => onIncrement("Decrease")} tabindex="-1" />
-			<button class="arrow right" on:click={() => onIncrement("Increase")} tabindex="-1" />
+			<button
+				class="arrow left"
+				on:pointerdown={(e) => onIncrementPointerDown(e, "Decrease")}
+				on:mousedown={onIncrementMouseDown}
+				on:pointerup={onIncrementPointerUp}
+				on:pointerleave={onIncrementPointerUp}
+				tabindex="-1"
+			></button>
+			<button
+				class="arrow right"
+				on:pointerdown={(e) => onIncrementPointerDown(e, "Increase")}
+				on:mousedown={onIncrementMouseDown}
+				on:pointerup={onIncrementPointerUp}
+				on:pointerleave={onIncrementPointerUp}
+				tabindex="-1"
+			></button>
 		{/if}
 		{#if mode === "Range"}
 			<input
@@ -684,6 +736,8 @@
 				border: none;
 				border-radius: 2px;
 				background: rgba(var(--color-1-nearblack-rgb), 0.5);
+				// An outline can appear when pressing the arrow button with left click then hitting Escape, so this stops that from showing
+				outline: none;
 
 				&:hover {
 					background: var(--color-4-dimgray);
diff --git a/frontend/src/components/widgets/inputs/ScrollbarInput.svelte b/frontend/src/components/widgets/inputs/ScrollbarInput.svelte
index c8497bf8c9..ef96d8959a 100644
--- a/frontend/src/components/widgets/inputs/ScrollbarInput.svelte
+++ b/frontend/src/components/widgets/inputs/ScrollbarInput.svelte
@@ -3,38 +3,46 @@
 </script>
 
 <script lang="ts">
-	import { createEventDispatcher, onMount, onDestroy } from "svelte";
+	import { createEventDispatcher } from "svelte";
 
-	// Linear Interpolation
-	const lerp = (x: number, y: number, a: number): number => x * (1 - a) + y * a;
+	import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS, PRESS_REPEAT_INTERVAL_RAPID_MS } from "@graphite/io-managers/input";
 
-	// Convert the position of the handle (0-1) to the position on the track (0-1).
-	// This includes the 1/2 handle length gap of the possible handle positionson each side so the end of the handle doesn't go off the track.
-	const handleToTrack = (handleLen: number, handlePos: number): number => lerp(handleLen / 2, 1 - handleLen / 2, handlePos);
+	const ARROW_CLICK_DISTANCE = 0.05;
+	const ARROW_REPEAT_DISTANCE = 0.01;
 
-	const pointerPosition = (direction: ScrollbarDirection, e: PointerEvent): number => (direction === "Vertical" ? e.clientY : e.clientX);
+	// Convert the position of the thumb (0-1) to the position on the track (0-1).
+	// This includes the 1/2 thumb length gap of the possible thumb position each side so the end of the thumb doesn't go off the track.
+	const lerp = (a: number, b: number, t: number): number => a * (1 - t) + b * t;
+	const thumbToTrack = (thumbLength: number, thumbPosition: number): number => lerp(thumbLength / 2, 1 - thumbLength / 2, thumbPosition);
 
-	const dispatch = createEventDispatcher<{ handlePosition: number; pressTrack: number; pointerup: undefined }>();
+	const pointerPosition = (e: PointerEvent): number => (direction === "Vertical" ? e.clientY : e.clientX);
+
+	const clamp01 = (value: number): number => Math.min(Math.max(value, 0), 1);
+
+	const dispatch = createEventDispatcher<{ trackShift: number; thumbPosition: number; thumbDragStart: undefined; thumbDragEnd: undefined; thumbDragAbort: undefined }>();
 
 	export let direction: ScrollbarDirection = "Vertical";
-	export let handlePosition = 0.5;
-	export let handleLength = 0.5;
+	export let thumbPosition = 0.5;
+	export let thumbLength = 0.5;
 
 	let scrollTrack: HTMLDivElement | undefined;
 	let dragging = false;
-	let pointerPos = 0;
+	let pressingTrack = false;
+	let pressingArrow = false;
+	let repeatTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
+	let pointerPositionLastFrame = 0;
 	let thumbTop: string | undefined = undefined;
 	let thumbBottom: string | undefined = undefined;
 	let thumbLeft: string | undefined = undefined;
 	let thumbRight: string | undefined = undefined;
 
-	$: start = handleToTrack(handleLength, handlePosition) - handleLength / 2;
-	$: end = 1 - handleToTrack(handleLength, handlePosition) - handleLength / 2;
+	$: start = thumbToTrack(thumbLength, thumbPosition) - thumbLength / 2;
+	$: end = 1 - thumbToTrack(thumbLength, thumbPosition) - thumbLength / 2;
 	$: [thumbTop, thumbBottom, thumbLeft, thumbRight] = direction === "Vertical" ? [`${start * 100}%`, `${end * 100}%`, "0%", "0%"] : ["0%", "0%", `${start * 100}%`, `${end * 100}%`];
 
 	function trackLength(): number | undefined {
 		if (scrollTrack === undefined) return undefined;
-		return direction === "Vertical" ? scrollTrack.clientHeight - handleLength : scrollTrack.clientWidth;
+		return direction === "Vertical" ? scrollTrack.clientHeight - thumbLength : scrollTrack.clientWidth;
 	}
 
 	function trackOffset(): number | undefined {
@@ -42,72 +50,168 @@
 		return direction === "Vertical" ? scrollTrack.getBoundingClientRect().top : scrollTrack.getBoundingClientRect().left;
 	}
 
-	function clampHandlePosition(newPos: number) {
-		const clampedPosition = Math.min(Math.max(newPos, 0), 1);
-		dispatch("handlePosition", clampedPosition);
+	function dragThumb(e: PointerEvent) {
+		if (dragging) return;
+
+		dragging = true;
+		dispatch("thumbDragStart");
+		pointerPositionLastFrame = pointerPosition(e);
+
+		addEvents();
 	}
 
-	function updateHandlePosition(e: PointerEvent) {
-		const length = trackLength();
-		if (length === undefined) return;
+	function pressArrow(direction: number) {
+		const sendMove = () => {
+			if (!pressingArrow) return;
+
+			const distance = afterInitialDelay ? ARROW_REPEAT_DISTANCE : ARROW_CLICK_DISTANCE;
+			dispatch("trackShift", -direction * distance);
 
-		const position = pointerPosition(direction, e);
+			if (afterInitialDelay) repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_INTERVAL_RAPID_MS);
+			afterInitialDelay = true;
+		};
 
-		clampHandlePosition(handlePosition + (position - pointerPos) / (length * (1 - handleLength)));
-		pointerPos = position;
+		pressingArrow = true;
+		dispatch("thumbDragStart");
+		let afterInitialDelay = false;
+		sendMove();
+		repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_DELAY_MS);
+
+		addEvents();
 	}
 
-	function grabHandle(e: PointerEvent) {
-		if (!dragging) {
-			dragging = true;
-			pointerPos = pointerPosition(direction, e);
-		}
+	function pressTrack(e: PointerEvent) {
+		if (dragging) return;
+
+		const length = trackLength();
+		const offset = trackOffset();
+		if (length === undefined || offset === undefined) return;
+
+		const sendMove = () => {
+			if (!pressingTrack) return;
+
+			const oldPointer = thumbToTrack(thumbLength, thumbPosition) * length + offset;
+			const newPointer = pointerPosition(e);
+
+			// Check if the thumb has reached the cursor position
+			const proposedThumbPosition = (newPointer - offset) / length;
+			if (proposedThumbPosition >= start && proposedThumbPosition <= 1 - end) {
+				// End pressing the track
+				pressingTrack = false;
+				clearTimeout(repeatTimeout);
+
+				// Begin dragging the thumb
+				dragging = true;
+				pointerPositionLastFrame = newPointer;
+
+				return;
+			}
+
+			const move = newPointer - oldPointer < 0 ? 1 : -1;
+			dispatch("trackShift", move);
+
+			if (afterInitialDelay) repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_INTERVAL_MS);
+			afterInitialDelay = true;
+		};
+
+		dispatch("thumbDragStart");
+		pressingTrack = true;
+		let afterInitialDelay = false;
+		sendMove();
+		repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_DELAY_MS);
+
+		addEvents();
 	}
 
-	function grabArea(e: PointerEvent) {
-		if (!dragging) {
-			const length = trackLength();
-			const offset = trackOffset();
-			if (length === undefined || offset === undefined) return;
+	function abortInteraction() {
+		if (pressingTrack || pressingArrow) {
+			pressingTrack = false;
+			pressingArrow = false;
+			clearTimeout(repeatTimeout);
+			dispatch("thumbDragAbort");
+		}
 
-			const oldPointer = handleToTrack(handleLength, handlePosition) * length + offset;
-			const pointerPos = pointerPosition(direction, e);
-			dispatch("pressTrack", pointerPos - oldPointer);
+		if (dragging) {
+			dragging = false;
+			dispatch("thumbDragAbort");
 		}
 	}
 
-	function pointerUp() {
+	function onPointerUp() {
+		if (dragging) dispatch("thumbDragEnd");
+
 		dragging = false;
+		pressingTrack = false;
+		pressingArrow = false;
+		clearTimeout(repeatTimeout);
+		removeEvents();
 	}
 
-	function pointerMove(e: PointerEvent) {
-		if (dragging) updateHandlePosition(e);
+	function onPointerMove(e: PointerEvent) {
+		if (pressingTrack) {
+			return;
+		}
+
+		if (pressingArrow) {
+			const target = e.target || undefined;
+			if (!target || !(target instanceof Element)) return;
+			if (!target?.closest?.("[data-scrollbar-arrow]")) {
+				pressingArrow = false;
+				clearTimeout(repeatTimeout);
+				removeEvents();
+			}
+
+			return;
+		}
+
+		if (dragging) {
+			const length = trackLength();
+			if (length === undefined) return;
+
+			const positionPositionThisFrame = pointerPosition(e);
+			const dragDelta = positionPositionThisFrame - pointerPositionLastFrame;
+			const movement = dragDelta / (length * (1 - thumbLength));
+			const newThumbPosition = clamp01(thumbPosition + movement);
+			dispatch("thumbPosition", newThumbPosition);
+
+			pointerPositionLastFrame = positionPositionThisFrame;
+
+			return;
+		}
+
+		removeEvents();
 	}
 
-	function changePosition(difference: number) {
-		const length = trackLength();
-		if (length === undefined) return;
+	function onMouseDown(e: MouseEvent) {
+		const BUTTONS_RIGHT = 0b0000_0010;
+		if (e.buttons & BUTTONS_RIGHT) abortInteraction();
+	}
 
-		clampHandlePosition(handlePosition + difference / length);
+	function onKeyDown(e: KeyboardEvent) {
+		if (e.key === "Escape") abortInteraction();
 	}
 
-	onMount(() => {
-		window.addEventListener("pointerup", pointerUp);
-		window.addEventListener("pointermove", pointerMove);
-	});
+	function addEvents() {
+		window.addEventListener("pointerup", onPointerUp);
+		window.addEventListener("pointermove", onPointerMove);
+		window.addEventListener("mousedown", onMouseDown);
+		window.addEventListener("keydown", onKeyDown);
+	}
 
-	onDestroy(() => {
-		window.removeEventListener("pointerup", pointerUp);
-		window.removeEventListener("pointermove", pointerMove);
-	});
+	function removeEvents() {
+		window.removeEventListener("pointerup", onPointerUp);
+		window.removeEventListener("pointermove", onPointerMove);
+		window.removeEventListener("mousedown", onMouseDown);
+		window.removeEventListener("keydown", onKeyDown);
+	}
 </script>
 
-<div class={`scrollbar-input ${direction.toLowerCase()}`} on:pointerup={() => dispatch("pointerup")}>
-	<button class="arrow decrease" on:pointerdown={() => changePosition(-50)} tabindex="-1" />
-	<div class="scroll-track" bind:this={scrollTrack} on:pointerdown={grabArea}>
-		<div class="scroll-thumb" on:pointerdown={grabHandle} class:dragging style:top={thumbTop} style:bottom={thumbBottom} style:left={thumbLeft} style:right={thumbRight} />
+<div class={`scrollbar-input ${direction.toLowerCase()}`}>
+	<button class="arrow decrease" on:pointerdown={() => pressArrow(-1)} tabindex="-1" data-scrollbar-arrow></button>
+	<div class="scroll-track" on:pointerdown={pressTrack} bind:this={scrollTrack}>
+		<div class="scroll-thumb" on:pointerdown={dragThumb} class:dragging style:top={thumbTop} style:bottom={thumbBottom} style:left={thumbLeft} style:right={thumbRight} />
 	</div>
-	<button class="arrow increase" on:click={() => changePosition(50)} tabindex="-1" />
+	<button class="arrow increase" on:pointerdown={() => pressArrow(1)} tabindex="-1" data-scrollbar-arrow></button>
 </div>
 
 <style lang="scss" global>
@@ -115,93 +219,75 @@
 		display: flex;
 		flex: 1 1 100%;
 
+		&.vertical {
+			flex-direction: column;
+		}
+
+		&.horizontal {
+			flex-direction: row;
+		}
+
 		.arrow {
+			--arrow-color: var(--color-5-dullgray);
 			flex: 0 0 auto;
 			background: none;
 			border: none;
-			border-style: solid;
-			width: 0;
-			height: 0;
 			margin: 0;
 			padding: 0;
-		}
-
-		.scroll-track {
-			flex: 1 1 100%;
-			position: relative;
+			width: 16px;
+			height: 16px;
 
-			.scroll-thumb {
-				position: absolute;
-				border-radius: 4px;
-				background: var(--color-5-dullgray);
-
-				&:hover,
-				&.dragging {
-					background: var(--color-6-lowergray);
-				}
+			&:hover {
+				--arrow-color: var(--color-6-lowergray);
 			}
 
-			.scroll-click-area {
-				position: absolute;
+			&:hover:active {
+				--arrow-color: var(--color-c-brightgray);
 			}
-		}
 
-		&.vertical {
-			flex-direction: column;
-
-			.arrow.decrease {
-				margin: 4px 3px;
-				border-width: 0 5px 8px 5px;
-				border-color: transparent transparent var(--color-5-dullgray) transparent;
-
-				&:hover {
-					border-color: transparent transparent var(--color-6-lowergray) transparent;
-				}
-				&:active {
-					border-color: transparent transparent var(--color-c-brightgray) transparent;
-				}
+			&::after {
+				content: "";
+				display: block;
+				border-style: solid;
 			}
+		}
 
-			.arrow.increase {
-				margin: 4px 3px;
-				border-width: 8px 5px 0 5px;
-				border-color: var(--color-5-dullgray) transparent transparent transparent;
+		&.vertical .arrow.decrease::after {
+			margin: 4px 3px;
+			border-width: 0 5px 8px 5px;
+			border-color: transparent transparent var(--arrow-color) transparent;
+		}
 
-				&:hover {
-					border-color: var(--color-6-lowergray) transparent transparent transparent;
-				}
-				&:active {
-					border-color: var(--color-c-brightgray) transparent transparent transparent;
-				}
-			}
+		&.vertical .arrow.increase::after {
+			margin: 4px 3px;
+			border-width: 8px 5px 0 5px;
+			border-color: var(--arrow-color) transparent transparent transparent;
 		}
 
-		&.horizontal {
-			flex-direction: row;
+		&.horizontal .arrow.decrease::after {
+			margin: 3px 4px;
+			border-width: 5px 8px 5px 0;
+			border-color: transparent var(--arrow-color) transparent transparent;
+		}
 
-			.arrow.decrease {
-				margin: 3px 4px;
-				border-width: 5px 8px 5px 0;
-				border-color: transparent var(--color-5-dullgray) transparent transparent;
+		&.horizontal .arrow.increase::after {
+			margin: 3px 4px;
+			border-width: 5px 0 5px 8px;
+			border-color: transparent transparent transparent var(--arrow-color);
+		}
 
-				&:hover {
-					border-color: transparent var(--color-6-lowergray) transparent transparent;
-				}
-				&:active {
-					border-color: transparent var(--color-c-brightgray) transparent transparent;
-				}
-			}
+		.scroll-track {
+			position: relative;
+			flex: 1 1 100%;
 
-			.arrow.increase {
-				margin: 3px 4px;
-				border-width: 5px 0 5px 8px;
-				border-color: transparent transparent transparent var(--color-5-dullgray);
+			.scroll-thumb {
+				position: absolute;
+				border-radius: 4px;
+				background: var(--color-5-dullgray);
 
-				&:hover {
-					border-color: transparent transparent transparent var(--color-6-lowergray);
-				}
-				&:active {
-					border-color: transparent transparent transparent var(--color-c-brightgray);
+				&:hover,
+				&.dragging {
+					background: var(--color-6-lowergray);
 				}
 			}
 		}
diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte
index 0fabdb3e54..cabc70b455 100644
--- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte
+++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte
@@ -3,21 +3,27 @@
 
 	import { Color, type Gradient } from "@graphite/messages";
 
+	import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
 	import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
 	import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
 
-	const dispatch = createEventDispatcher<{ activeMarkerIndexChange: number | undefined; gradient: Gradient }>();
+	const BUTTON_LEFT = 0;
+	const BUTTON_RIGHT = 2;
+
+	const dispatch = createEventDispatcher<{ activeMarkerIndexChange: number | undefined; gradient: Gradient; dragging: boolean }>();
 
 	export let gradient: Gradient;
 	export let activeMarkerIndex = 0 as number | undefined;
 	// export let disabled = false;
 	// export let tooltip: string | undefined = undefined;
 
-	let markerTrack: LayoutRow | undefined;
+	let markerTrack: LayoutRow | undefined = undefined;
+	let positionRestore: number | undefined = undefined;
+	let deletionRestore: boolean | undefined = undefined;
 
 	function markerPointerDown(e: PointerEvent, index: number) {
 		// Left-click to select and begin potentially dragging
-		if (e.button === 0) {
+		if (e.button === BUTTON_LEFT) {
 			activeMarkerIndex = index;
 			dispatch("activeMarkerIndexChange", index);
 			addEvents();
@@ -25,7 +31,7 @@
 		}
 
 		// Right-click to delete
-		if (e.button === 2) {
+		if (e.button === BUTTON_RIGHT && deletionRestore === undefined) {
 			deleteStopByIndex(index);
 			return;
 		}
@@ -41,6 +47,8 @@
 	}
 
 	function insertStop(e: MouseEvent) {
+		if (e.button !== BUTTON_LEFT) return;
+
 		let position = markerPosition(e);
 		if (position === undefined) return;
 
@@ -62,14 +70,20 @@
 
 		gradient.stops.splice(index, 0, { position, color });
 		activeMarkerIndex = index;
+		deletionRestore = true;
 
 		dispatch("activeMarkerIndexChange", index);
 		dispatch("gradient", gradient);
+
+		addEvents();
 	}
 
 	function deleteStop(e: KeyboardEvent) {
-		if (e.key.toLowerCase() !== "delete" && e.key.toLowerCase() !== "backspace") return;
+		if (e.key !== "Delete" && e.key !== "Backspace") return;
 		if (activeMarkerIndex === undefined) return;
+
+		if (positionRestore !== undefined) stopDrag();
+
 		deleteStopByIndex(activeMarkerIndex);
 	}
 
@@ -82,6 +96,7 @@
 		} else {
 			activeMarkerIndex = Math.max(0, Math.min(gradient.stops.length - 1, index));
 		}
+		deletionRestore = undefined;
 
 		dispatch("activeMarkerIndexChange", activeMarkerIndex);
 		dispatch("gradient", gradient);
@@ -89,10 +104,18 @@
 
 	function moveMarker(e: PointerEvent, index: number) {
 		// Just in case the mouseup event is lost
-		if (e.buttons === 0) removeEvents();
+		if (e.buttons === 0) stopDrag();
 
 		let position = markerPosition(e);
 		if (position === undefined) return;
+
+		if (positionRestore === undefined) positionRestore = position;
+		if (deletionRestore === undefined) {
+			deletionRestore = false;
+
+			dispatch("dragging", true);
+		}
+
 		setPosition(index, position);
 	}
 
@@ -107,24 +130,61 @@
 		dispatch("gradient", gradient);
 	}
 
-	function onPointerMove(e: PointerEvent) {
-		if (activeMarkerIndex !== undefined) {
-			moveMarker(e, activeMarkerIndex);
+	function abortDrag() {
+		if (activeMarkerIndex === undefined) return;
+
+		if (deletionRestore) {
+			deleteStopByIndex(activeMarkerIndex);
+		} else if (positionRestore !== undefined) {
+			setPosition(activeMarkerIndex, positionRestore);
 		}
+
+		stopDrag();
 	}
 
-	function onPointerUp() {
+	function stopDrag() {
 		removeEvents();
+
+		positionRestore = undefined;
+		deletionRestore = undefined;
+
+		dispatch("dragging", false);
+	}
+
+	function onPointerMove(e: PointerEvent) {
+		if (activeMarkerIndex !== undefined) moveMarker(e, activeMarkerIndex);
+	}
+
+	function onPointerUp() {
+		stopDrag();
+	}
+
+	function onMouseDown(e: MouseEvent) {
+		const BUTTONS_RIGHT = 0b0000_0010;
+		if (e.buttons & BUTTONS_RIGHT) abortDrag();
+	}
+
+	function onKeyDown(e: KeyboardEvent) {
+		if (e.key === "Escape") {
+			const element = markerTrack?.div();
+			if (element) preventEscapeClosingParentFloatingMenu(element);
+
+			abortDrag();
+		}
 	}
 
 	function addEvents() {
 		document.addEventListener("pointermove", onPointerMove);
 		document.addEventListener("pointerup", onPointerUp);
+		document.addEventListener("mousedown", onMouseDown);
+		document.addEventListener("keydown", onKeyDown);
 	}
 
 	function removeEvents() {
 		document.removeEventListener("pointermove", onPointerMove);
 		document.removeEventListener("pointerup", onPointerUp);
+		document.removeEventListener("mousedown", onMouseDown);
+		document.removeEventListener("keydown", onKeyDown);
 	}
 
 	document.addEventListener("keydown", deleteStop);
@@ -158,24 +218,22 @@
 		"--gradient-stops": gradient.toLinearGradientCSS(),
 	}}
 >
-	<LayoutRow class="gradient-strip" on:click={insertStop}></LayoutRow>
+	<LayoutRow class="gradient-strip" on:pointerdown={insertStop}></LayoutRow>
 	<LayoutRow class="marker-track" bind:this={markerTrack}>
 		{#each gradient.stops as marker, index}
 			<svg
-				style:--marker-position={marker.position}
-				style:--marker-color={marker.color.toRgbCSS()}
 				class="marker"
 				class:active={index === activeMarkerIndex}
+				style:--marker-position={marker.position}
+				style:--marker-color={marker.color.toRgbCSS()}
+				on:pointerdown={(e) => markerPointerDown(e, index)}
 				data-gradient-marker
 				xmlns="http://www.w3.org/2000/svg"
 				viewBox="0 0 12 12"
 			>
+				<path class="inner-fill" d="M10,11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1L6,0.7l5.1,5.1c0.3,0.3,0.4,0.7,0.4,1.1V10C11.5,10.8,10.8,11.5,10,11.5z" />
 				<path
-					on:pointerdown={(e) => markerPointerDown(e, index)}
-					d="M10,11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1L6,0.7l5.1,5.1c0.3,0.3,0.4,0.7,0.4,1.1V10C11.5,10.8,10.8,11.5,10,11.5z"
-				/>
-				<path
-					on:pointerdown={(e) => markerPointerDown(e, index)}
+					class="outer-border"
 					d="M6,1.4L1.3,6.1C1.1,6.3,1,6.6,1,6.8V10c0,0.6,0.4,1,1,1h8c0.6,0,1-0.4,1-1V6.8c0-0.3-0.1-0.5-0.3-0.7L6,1.4M6,0l5.4,5.4C11.8,5.8,12,6.3,12,6.8V10c0,1.1-0.9,2-2,2H2c-1.1,0-2-0.9-2-2V6.8c0-0.5,0.2-1,0.6-1.4L6,0z"
 				/>
 			</svg>
@@ -216,6 +274,7 @@
 			margin-left: var(--marker-half-width);
 			width: calc(100% - 2 * var(--marker-half-width));
 			position: relative;
+			pointer-events: none;
 
 			.marker {
 				position: absolute;
@@ -223,30 +282,40 @@
 				left: calc(var(--marker-position) * 100%);
 				width: 12px;
 				height: 12px;
+				pointer-events: auto;
+				overflow: visible;
+				padding-top: 12px;
+				margin-top: -12px;
 
-				// Inner fill
-				path:first-child {
+				.inner-fill {
 					fill: var(--marker-color);
 				}
 
-				// Outer border
-				path:last-child {
+				.outer-border {
 					fill: var(--color-5-dullgray);
 				}
 
-				&:not(.active) path:first-child:hover + path:last-child,
-				&:not(.active) path:last-child:hover {
-					fill: var(--color-6-lowergray);
+				&:not(.active) {
+					.inner-fill:hover + .outer-border,
+					.outer-border:hover {
+						fill: var(--color-6-lowergray);
+					}
 				}
 
-				// Outer border when active
-				&.active path:last-child {
-					fill: var(--color-e-nearwhite);
-				}
+				&.active {
+					.inner-fill {
+						filter: drop-shadow(0 0 1px var(--color-2-mildblack)) drop-shadow(0 0 1px var(--color-2-mildblack));
+					}
+
+					// Outer border when active
+					.outer-border {
+						fill: var(--color-e-nearwhite);
+					}
 
-				&.active path:first-child:hover + path:last-child,
-				&.active path:last-child:hover {
-					fill: var(--color-f-white);
+					.inner-fill:hover + .outer-border,
+					.outer-border:hover {
+						fill: var(--color-f-white);
+					}
 				}
 			}
 		}
diff --git a/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte b/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte
index c88c373b6b..cff3beacba 100644
--- a/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte
+++ b/frontend/src/components/widgets/inputs/WorkingColorsInput.svelte
@@ -37,7 +37,7 @@
 
 <LayoutCol class="working-colors-button">
 	<LayoutRow class="primary swatch">
-		<button on:click={clickPrimarySwatch} class:open={primaryOpen} style:--swatch-color={primary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
+		<button on:click={clickPrimarySwatch} class:open={primaryOpen} style:--swatch-color={primary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0"></button>
 		<ColorPicker
 			open={primaryOpen}
 			on:open={({ detail }) => (primaryOpen = detail)}
@@ -47,7 +47,7 @@
 		/>
 	</LayoutRow>
 	<LayoutRow class="secondary swatch">
-		<button on:click={clickSecondarySwatch} class:open={secondaryOpen} style:--swatch-color={secondary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
+		<button on:click={clickSecondarySwatch} class:open={secondaryOpen} style:--swatch-color={secondary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0"></button>
 		<ColorPicker
 			open={secondaryOpen}
 			on:open={({ detail }) => (secondaryOpen = detail)}
@@ -70,27 +70,55 @@
 
 			> button {
 				--swatch-color: #ffffff;
+				--ring-color: var(--color-5-dullgray);
 				width: 100%;
 				height: 100%;
-				border-radius: 50%;
-				border: 2px var(--color-5-dullgray) solid;
-				box-shadow: 0 0 0 2px var(--color-3-darkgray);
 				margin: 0;
 				padding: 0;
+				border: none;
+				outline: none;
+				border-radius: 50%;
 				box-sizing: border-box;
-				background: linear-gradient(var(--swatch-color), var(--swatch-color)), var(--color-transparent-checkered-background);
-				background-size:
-					100% 100%,
-					var(--color-transparent-checkered-background-size);
-				background-position:
-					0 0,
-					var(--color-transparent-checkered-background-position);
-				background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
 				overflow: hidden;
+				position: relative;
+				// Color of the panel background, used to extend outside the ring and appear to cut out a crescent from the lower circle (by covering it up with the panel background color)
+				box-shadow: 0 0 0 2px var(--color-3-darkgray);
+				background: var(--color-3-darkgray);
+
+				// Main color and checked transparency pattern (inset by 1px to begin inside/below the ring to avoid antialiasing artifacts)
+				&::before {
+					content: "";
+					position: absolute;
+					top: 1px;
+					bottom: 1px;
+					left: 1px;
+					right: 1px;
+					border-radius: 50%;
+					background: linear-gradient(var(--swatch-color), var(--swatch-color)), var(--color-transparent-checkered-background);
+					background-size:
+						100% 100%,
+						var(--color-transparent-checkered-background-size);
+					background-position:
+						0 0,
+						var(--color-transparent-checkered-background-position-plus-one);
+					background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
+				}
+
+				// Gray ring outline
+				&::after {
+					content: "";
+					position: absolute;
+					top: 0;
+					bottom: 0;
+					left: 0;
+					right: 0;
+					border-radius: 50%;
+					box-shadow: inset 0 0 0 2px var(--ring-color);
+				}
 
 				&:hover,
 				&.open {
-					border-color: var(--color-6-lowergray);
+					--ring-color: var(--color-6-lowergray);
 				}
 			}
 
diff --git a/frontend/src/components/widgets/labels/IconLabel.svelte b/frontend/src/components/widgets/labels/IconLabel.svelte
index f031763019..ab69928b60 100644
--- a/frontend/src/components/widgets/labels/IconLabel.svelte
+++ b/frontend/src/components/widgets/labels/IconLabel.svelte
@@ -12,7 +12,14 @@
 	export let tooltip: string | undefined = undefined;
 
 	$: iconSizeClass = ((icon: IconName) => {
-		return `size-${iconSizeOverride || ICONS[icon].size}`;
+		const iconData = ICONS[icon];
+		// eslint-disable-next-line no-console
+		if (!iconData) {
+			console.warn(`Icon "${icon}" does not exist.`);
+			return "size-24";
+		}
+		if (iconData.size === undefined) return "";
+		return `size-${iconSizeOverride || iconData.size}`;
 	})(icon);
 	$: extraClasses = Object.entries(classes)
 		.flatMap(([className, stateName]) => (stateName ? [className] : []))
@@ -20,7 +27,7 @@
 </script>
 
 <LayoutRow class={`icon-label ${iconSizeClass} ${className} ${extraClasses}`.trim()} classes={{ disabled }} {tooltip}>
-	{@html ICON_SVG_STRINGS[icon]}
+	{@html ICON_SVG_STRINGS[icon] || "�"}
 </LayoutRow>
 
 <style lang="scss" global>
diff --git a/frontend/src/components/window/workspace/Panel.svelte b/frontend/src/components/window/workspace/Panel.svelte
index efd1798f30..ea99294c55 100644
--- a/frontend/src/components/window/workspace/Panel.svelte
+++ b/frontend/src/components/window/workspace/Panel.svelte
@@ -28,6 +28,8 @@
 	import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
 	import UserInputLabel from "@graphite/components/widgets/labels/UserInputLabel.svelte";
 
+	const BUTTON_MIDDLE = 1;
+
 	const editor = getContext<Editor>("editor");
 
 	export let tabMinWidths = false;
@@ -100,7 +102,7 @@
 					}}
 					on:auxclick={(e) => {
 						// Middle mouse button click
-						if (e.button === 1) {
+						if (e.button === BUTTON_MIDDLE) {
 							e.stopPropagation();
 							closeAction?.(tabIndex);
 						}
@@ -110,7 +112,7 @@
 						// https://developer.mozilla.org/en-US/docs/Web/API/Element/auxclick_event#browser_compatibility
 						// The downside of using mouseup is that the mousedown didn't have to originate in the same element.
 						// A possible future improvement could save the target element during mousedown and check if it's the same here.
-						if (!isEventSupported("auxclick") && e.button === 1) {
+						if (!isEventSupported("auxclick") && e.button === BUTTON_MIDDLE) {
 							e.stopPropagation();
 							closeAction?.(tabIndex);
 						}
diff --git a/frontend/src/components/window/workspace/Workspace.svelte b/frontend/src/components/window/workspace/Workspace.svelte
index 1090510c50..8123b27cf7 100644
--- a/frontend/src/components/window/workspace/Workspace.svelte
+++ b/frontend/src/components/window/workspace/Workspace.svelte
@@ -14,15 +14,17 @@
 	const MIN_PANEL_SIZE = 100;
 	const PANEL_SIZES = {
 		/**/ root: 100,
-		/*   ├── */ content: 80,
-		/*   │      ├── */ document: 100,
-		/*   └── */ details: 20,
-		/*          ├── */ properties: 45,
-		/*          └── */ layers: 55,
+		/*   ├─ */ content: 80,
+		/*   │     └─ */ document: 100,
+		/*   └─ */ details: 20,
+		/*         ├─ */ properties: 45,
+		/*         └─ */ layers: 55,
 	};
 
 	let panelSizes = PANEL_SIZES;
 	let documentPanel: Panel | undefined;
+	let gutterResizeRestore: [number, number] | undefined = undefined;
+	let pointerCaptureId: number | undefined = undefined;
 
 	$: documentPanel?.scrollTabIntoView($portfolio.activeDocumentIndex);
 
@@ -64,32 +66,68 @@
 		const proportionBeingResized = totalResizingSpaceOccupied / parentElementSize;
 
 		// Prevent cursor flicker as mouse temporarily leaves the gutter
-		gutter.setPointerCapture(e.pointerId);
+		pointerCaptureId = e.pointerId;
+		gutter.setPointerCapture(pointerCaptureId);
 
 		const mouseStart = isHorizontal ? e.clientX : e.clientY;
 
-		const updatePosition = (e: PointerEvent) => {
+		const abortResize = () => {
+			if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
+			removeListeners();
+
+			pointerCaptureId = e.pointerId;
+			gutter.setPointerCapture(pointerCaptureId);
+
+			if (gutterResizeRestore !== undefined) {
+				panelSizes[nextSiblingName] = gutterResizeRestore[0];
+				panelSizes[prevSiblingName] = gutterResizeRestore[1];
+				gutterResizeRestore = undefined;
+			}
+		};
+
+		const onPointerMove = (e: PointerEvent) => {
 			const mouseCurrent = isHorizontal ? e.clientX : e.clientY;
 			let mouseDelta = mouseStart - mouseCurrent;
 
 			mouseDelta = Math.max(nextSiblingSize + mouseDelta, MIN_PANEL_SIZE) - nextSiblingSize;
 			mouseDelta = prevSiblingSize - Math.max(prevSiblingSize - mouseDelta, MIN_PANEL_SIZE);
 
+			if (gutterResizeRestore === undefined) gutterResizeRestore = [panelSizes[nextSiblingName], panelSizes[prevSiblingName]];
+
 			panelSizes[nextSiblingName] = ((nextSiblingSize + mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
 			panelSizes[prevSiblingName] = ((prevSiblingSize - mouseDelta) / totalResizingSpaceOccupied) * proportionBeingResized * 100;
 		};
 
-		const cleanup = (e: PointerEvent) => {
-			gutter.releasePointerCapture(e.pointerId);
+		const onPointerUp = () => {
+			gutterResizeRestore = undefined;
+			if (pointerCaptureId) gutter.releasePointerCapture(pointerCaptureId);
+			removeListeners();
+		};
+
+		const onMouseDown = (e: MouseEvent) => {
+			const BUTTONS_RIGHT = 0b0000_0010;
+			if (e.buttons & BUTTONS_RIGHT) abortResize();
+		};
+
+		const onKeyDown = (e: KeyboardEvent) => {
+			if (e.key === "Escape") abortResize();
+		};
+
+		const addListeners = () => {
+			document.addEventListener("pointermove", onPointerMove);
+			document.addEventListener("pointerup", onPointerUp);
+			document.addEventListener("mousedown", onMouseDown);
+			document.addEventListener("keydown", onKeyDown);
+		};
 
-			document.removeEventListener("pointermove", updatePosition);
-			document.removeEventListener("pointerleave", cleanup);
-			document.removeEventListener("pointerup", cleanup);
+		const removeListeners = () => {
+			document.removeEventListener("pointermove", onPointerMove);
+			document.removeEventListener("pointerup", onPointerUp);
+			document.removeEventListener("mousedown", onMouseDown);
+			document.removeEventListener("keydown", onKeyDown);
 		};
 
-		document.addEventListener("pointermove", updatePosition);
-		document.addEventListener("pointerleave", cleanup);
-		document.addEventListener("pointerup", cleanup);
+		addListeners();
 	}
 </script>
 
diff --git a/frontend/src/io-managers/input.ts b/frontend/src/io-managers/input.ts
index d6ef8529dd..4f9ee43a4c 100644
--- a/frontend/src/io-managers/input.ts
+++ b/frontend/src/io-managers/input.ts
@@ -12,6 +12,16 @@ import { extractPixelData } from "@graphite/utility-functions/rasterization";
 import { stripIndents } from "@graphite/utility-functions/strip-indents";
 import { updateBoundsOfViewports } from "@graphite/utility-functions/viewports";
 
+const BUTTON_LEFT = 0;
+const BUTTON_MIDDLE = 1;
+const BUTTON_RIGHT = 2;
+const BUTTON_BACK = 3;
+const BUTTON_FORWARD = 4;
+
+export const PRESS_REPEAT_DELAY_MS = 400;
+export const PRESS_REPEAT_INTERVAL_MS = 72;
+export const PRESS_REPEAT_INTERVAL_RAPID_MS = 10;
+
 type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
 type EventListenerTarget = {
 	addEventListener: typeof window.addEventListener;
@@ -155,7 +165,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
 
 	function onMouseDown(e: MouseEvent) {
 		// Block middle mouse button auto-scroll mode (the circlar gizmo that appears and allows quick scrolling by moving the cursor above or below it)
-		if (e.button === 1) e.preventDefault();
+		if (e.button === BUTTON_MIDDLE) e.preventDefault();
 	}
 
 	function onPointerDown(e: PointerEvent) {
@@ -172,7 +182,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
 		}
 
 		if (!inTextInput && !inContextMenu) {
-			const isLeftOrRightClick = e.button === 2 || e.button === 0;
+			const isLeftOrRightClick = e.button === BUTTON_RIGHT || e.button === BUTTON_LEFT;
 			if (textToolInteractiveInputElement) editor.handle.onChangeText(textInputCleanup(textToolInteractiveInputElement.innerText), isLeftOrRightClick);
 			else viewportPointerInteractionOngoing = isTargetingCanvas instanceof Element;
 		}
@@ -188,7 +198,7 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
 		// TODO: This works in Chrome but not in Firefox
 		// TODO: Possible workaround: use the browser's history API to block navigation:
 		// TODO: <https://stackoverflow.com/questions/57102502/preventing-mouse-fourth-and-fifth-buttons-from-navigating-back-forward-in-browse>
-		if (e.button === 3 || e.button === 4) e.preventDefault();
+		if (e.button === BUTTON_BACK || e.button === BUTTON_FORWARD) e.preventDefault();
 
 		if (!e.buttons) viewportPointerInteractionOngoing = false;
 
@@ -206,11 +216,11 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
 
 		// `e.buttons` is always 0 in the `mouseup` event, so we have to convert from `e.button` instead
 		let buttons = 1;
-		if (e.button === 0) buttons = 1; // Left
-		if (e.button === 2) buttons = 2; // Right
-		if (e.button === 1) buttons = 4; // Middle
-		if (e.button === 3) buttons = 8; // Back
-		if (e.button === 4) buttons = 16; // Forward
+		if (e.button === BUTTON_LEFT) buttons = 1; // Left
+		if (e.button === BUTTON_RIGHT) buttons = 2; // Right
+		if (e.button === BUTTON_MIDDLE) buttons = 4; // Middle
+		if (e.button === BUTTON_BACK) buttons = 8; // Back
+		if (e.button === BUTTON_FORWARD) buttons = 16; // Forward
 
 		const modifiers = makeKeyboardModifiersBitfield(e);
 		editor.handle.onDoubleClick(e.clientX, e.clientY, buttons, modifiers);
diff --git a/frontend/src/messages.ts b/frontend/src/messages.ts
index 3253c6a379..ca0da8f94f 100644
--- a/frontend/src/messages.ts
+++ b/frontend/src/messages.ts
@@ -667,8 +667,8 @@ export class Color {
 		return new Color(this.red, this.green, this.blue, 1);
 	}
 
-	contrastingColor(): "black" | "white" {
-		if (this.none) return "black";
+	luminance(): number | undefined {
+		if (this.none) return undefined;
 
 		// Convert alpha into white
 		const r = this.red * this.alpha + (1 - this.alpha);
@@ -681,9 +681,15 @@ export class Color {
 		const linearG = g <= 0.04045 ? g / 12.92 : ((g + 0.055) / 1.055) ** 2.4;
 		const linearB = b <= 0.04045 ? b / 12.92 : ((b + 0.055) / 1.055) ** 2.4;
 
-		const linear = linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722;
+		return linearR * 0.2126 + linearG * 0.7152 + linearB * 0.0722;
+	}
+
+	contrastingColor(): "black" | "white" {
+		if (this.none) return "black";
+
+		const luminance = this.luminance();
 
-		return linear > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white";
+		return luminance && luminance > Math.sqrt(1.05 * 0.05) - 0.05 ? "black" : "white";
 	}
 }
 
@@ -944,7 +950,7 @@ export class CheckboxInput extends WidgetProps {
 	tooltip!: string | undefined;
 }
 
-export class ColorButton extends WidgetProps {
+export class ColorInput extends WidgetProps {
 	@Transform(({ value }) => {
 		if (value instanceof Gradient) return value;
 		const gradient = value["Gradient"];
@@ -978,6 +984,37 @@ export class ColorButton extends WidgetProps {
 
 export type FillChoice = Color | Gradient;
 
+export function contrastingOutlineFactor(value: FillChoice, proximityColor: string | [string, string], proximityRange: number): number {
+	const pair = Array.isArray(proximityColor) ? [proximityColor[0], proximityColor[1]] : [proximityColor, proximityColor];
+	const [range1, range2] = pair.map((color) => Color.fromCSS(window.getComputedStyle(document.body).getPropertyValue(color)) || new Color("none"));
+
+	const contrast = (color: Color): number => {
+		const colorLuminance = color.luminance() || 0;
+		let rangeLuminance1 = range1.luminance() || 0;
+		let rangeLuminance2 = range2.luminance() || 0;
+		[rangeLuminance1, rangeLuminance2] = [Math.min(rangeLuminance1, rangeLuminance2), Math.max(rangeLuminance1, rangeLuminance2)];
+
+		const distance = (() => {
+			if (colorLuminance < rangeLuminance1) return rangeLuminance1 - colorLuminance;
+			if (colorLuminance > rangeLuminance2) return colorLuminance - rangeLuminance2;
+			return 0;
+		})();
+
+		return (1 - Math.min(distance / proximityRange, 1)) * (1 - (color.toHSV()?.s || 0));
+	};
+
+	if (value instanceof Gradient) {
+		if (value.stops.length === 0) return 0;
+
+		const first = contrast(value.stops[0].color);
+		const last = contrast(value.stops[value.stops.length - 1].color);
+
+		return Math.min(first, last);
+	}
+
+	return contrast(value);
+}
+
 type MenuEntryCommon = {
 	label: string;
 	icon?: IconName;
@@ -1314,7 +1351,7 @@ export class PivotInput extends WidgetProps {
 const widgetSubTypes = [
 	{ value: BreadcrumbTrailButtons, name: "BreadcrumbTrailButtons" },
 	{ value: CheckboxInput, name: "CheckboxInput" },
-	{ value: ColorButton, name: "ColorButton" },
+	{ value: ColorInput, name: "ColorInput" },
 	{ value: CurveInput, name: "CurveInput" },
 	{ value: DropdownInput, name: "DropdownInput" },
 	{ value: FontInput, name: "FontInput" },
diff --git a/frontend/wasm/src/editor_api.rs b/frontend/wasm/src/editor_api.rs
index e757696a7d..f42d7baee4 100644
--- a/frontend/wasm/src/editor_api.rs
+++ b/frontend/wasm/src/editor_api.rs
@@ -557,16 +557,29 @@ impl EditorHandle {
 		self.dispatch(message);
 	}
 
+	/// Translates document (in viewport coords)
+	#[wasm_bindgen(js_name = panCanvasAbortPrepare)]
+	pub fn pan_canvas_abort_prepare(&self, x_not_y_axis: bool) {
+		let message = NavigationMessage::CanvasPanAbortPrepare { x_not_y_axis };
+		self.dispatch(message);
+	}
+
+	#[wasm_bindgen(js_name = panCanvasAbort)]
+	pub fn pan_canvas_abort(&self, x_not_y_axis: bool) {
+		let message = NavigationMessage::CanvasPanAbort { x_not_y_axis };
+		self.dispatch(message);
+	}
+
 	/// Translates document (in viewport coords)
 	#[wasm_bindgen(js_name = panCanvas)]
-	pub fn translate_canvas(&self, delta_x: f64, delta_y: f64) {
+	pub fn pan_canvas(&self, delta_x: f64, delta_y: f64) {
 		let message = NavigationMessage::CanvasPan { delta: (delta_x, delta_y).into() };
 		self.dispatch(message);
 	}
 
 	/// Translates document (in viewport coords)
 	#[wasm_bindgen(js_name = panCanvasByFraction)]
-	pub fn translate_canvas_by_fraction(&self, delta_x: f64, delta_y: f64) {
+	pub fn pan_canvas_by_fraction(&self, delta_x: f64, delta_y: f64) {
 		let message = NavigationMessage::CanvasPanByViewportFraction { delta: (delta_x, delta_y).into() };
 		self.dispatch(message);
 	}


Test patch: