Split Interactive into Interactive and Toggleable (#2628)

Nate Butler created

This is a part of the intensity driven theme rewrite. 

It introduces the `toggle` and `interactive` helper functions to build
Toggle<T> and Interactive<T> styles for interactive elements in the
theme.

This PR also removes the `theme_testbench` crate and related actions.

Huge thanks to @osiewicz and @mikayla-maki for pushing this forward 🙏🏽

Release Notes:

- Updated the style of many interactive elements.

Change summary

Cargo.lock                                             |   13 
Cargo.toml                                             |    1 
crates/activity_indicator/src/activity_indicator.rs    |    2 
crates/ai/src/assistant.rs                             |    8 
crates/auto_update/src/update_notification.rs          |    4 
crates/breadcrumbs/src/breadcrumbs.rs                  |    2 
crates/collab_ui/src/collab_titlebar_item.rs           |   34 
crates/collab_ui/src/contact_finder.rs                 |    3 
crates/collab_ui/src/contact_list.rs                   |   41 
crates/collab_ui/src/notifications.rs                  |    4 
crates/command_palette/src/command_palette.rs          |    4 
crates/context_menu/src/context_menu.rs                |   16 
crates/copilot/src/sign_in.rs                          |    6 
crates/copilot_button/src/copilot_button.rs            |    5 
crates/diagnostics/src/items.rs                        |    4 
crates/editor/src/editor.rs                            |   18 
crates/editor/src/element.rs                           |    4 
crates/feedback/src/deploy_feedback_button.rs          |    3 
crates/feedback/src/submit_feedback_button.rs          |    2 
crates/file_finder/src/file_finder.rs                  |    2 
crates/language_selector/src/active_buffer_language.rs |    2 
crates/language_selector/src/language_selector.rs      |    2 
crates/language_tools/src/lsp_log.rs                   |    8 
crates/language_tools/src/syntax_tree_view.rs          |    5 
crates/outline/src/outline.rs                          |    2 
crates/project_panel/src/project_panel.rs              |   15 
crates/project_symbols/src/project_symbols.rs          |    7 
crates/recent_projects/src/recent_projects.rs          |    2 
crates/search/src/buffer_search.rs                     |   10 
crates/search/src/project_search.rs                    |    8 
crates/theme/src/theme.rs                              |  106 
crates/theme/src/ui.rs                                 |    6 
crates/theme_selector/src/theme_selector.rs            |    2 
crates/theme_testbench/Cargo.toml                      |   19 
crates/theme_testbench/src/theme_testbench.rs          |  300 --
crates/welcome/src/base_keymap_picker.rs               |    2 
crates/workspace/src/dock.rs                           |    4 
crates/workspace/src/notifications.rs                  |    4 
crates/workspace/src/pane.rs                           |    4 
crates/workspace/src/toolbar.rs                        |    2 
crates/zed/Cargo.toml                                  |    1 
crates/zed/src/main.rs                                 |    1 
styles/.gitignore                                      |    1 
styles/.zed/settings.json                              |   20 
styles/package-lock.json                               | 1202 +++++++++++
styles/package.json                                    |   11 
styles/src/buildTokens.ts                              |   86 
styles/src/element/index.ts                            |    4 
styles/src/element/interactive.test.ts                 |   56 
styles/src/element/interactive.ts                      |   97 
styles/src/element/toggle.test.ts                      |   52 
styles/src/element/toggle.ts                           |   47 
styles/src/styleTree/app.ts                            |    1 
styles/src/styleTree/assistant.ts                      |   44 
styles/src/styleTree/commandPalette.ts                 |   18 
styles/src/styleTree/components.ts                     |    2 
styles/src/styleTree/contactList.ts                    |  200 +
styles/src/styleTree/contactNotification.ts            |   42 
styles/src/styleTree/contextMenu.ts                    |   76 
styles/src/styleTree/copilot.ts                        |  185 +
styles/src/styleTree/editor.ts                         |  114 
styles/src/styleTree/feedback.ts                       |   51 
styles/src/styleTree/picker.ts                         |   87 
styles/src/styleTree/projectPanel.ts                   |  111 
styles/src/styleTree/search.ts                         |  100 
styles/src/styleTree/simpleMessageNotification.ts      |   59 
styles/src/styleTree/statusBar.ts                      |  160 
styles/src/styleTree/tabBar.ts                         |   40 
styles/src/styleTree/toggle.ts                         |   47 
styles/src/styleTree/toolbarDropdownMenu.ts            |   76 
styles/src/styleTree/updateNotification.ts             |   39 
styles/src/styleTree/welcome.ts                        |   43 
styles/src/styleTree/workspace.ts                      |  260 +
styles/src/theme/tokens/colorScheme.ts                 |   56 
styles/src/theme/tokens/layer.ts                       |   33 
styles/src/theme/tokens/players.ts                     |   18 
styles/src/theme/tokens/token.ts                       |    9 
styles/src/utils/slugify.ts                            |   11 
styles/tsconfig.json                                   |   12 
styles/vitest.config.ts                                |    8 
80 files changed, 2,946 insertions(+), 1,220 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6916,18 +6916,6 @@ dependencies = [
  "workspace",
 ]
 
-[[package]]
-name = "theme_testbench"
-version = "0.1.0"
-dependencies = [
- "gpui",
- "project",
- "settings",
- "smallvec",
- "theme",
- "workspace",
-]
-
 [[package]]
 name = "thiserror"
 version = "1.0.40"
@@ -8888,7 +8876,6 @@ dependencies = [
  "text",
  "theme",
  "theme_selector",
- "theme_testbench",
  "thiserror",
  "tiny_http",
  "toml",

Cargo.toml 🔗

@@ -61,7 +61,6 @@ members = [
     "crates/text",
     "crates/theme",
     "crates/theme_selector",
-    "crates/theme_testbench",
     "crates/util",
     "crates/vim",
     "crates/workspace",

crates/activity_indicator/src/activity_indicator.rs 🔗

@@ -326,7 +326,7 @@ impl View for ActivityIndicator {
         let mut element = MouseEventHandler::<Self, _>::new(0, cx, |state, cx| {
             let theme = &theme::current(cx).workspace.status_bar.lsp_status;
             let style = if state.hovered() && on_click.is_some() {
-                theme.hover.as_ref().unwrap_or(&theme.default)
+                theme.hovered.as_ref().unwrap_or(&theme.default)
             } else {
                 &theme.default
             };

crates/ai/src/assistant.rs 🔗

@@ -1233,19 +1233,19 @@ impl AssistantEditor {
                                 cx,
                                 |state, _| match message.role {
                                     Role::User => {
-                                        let style = style.user_sender.style_for(state, false);
+                                        let style = style.user_sender.style_for(state);
                                         Label::new("You", style.text.clone())
                                             .contained()
                                             .with_style(style.container)
                                     }
                                     Role::Assistant => {
-                                        let style = style.assistant_sender.style_for(state, false);
+                                        let style = style.assistant_sender.style_for(state);
                                         Label::new("Assistant", style.text.clone())
                                             .contained()
                                             .with_style(style.container)
                                     }
                                     Role::System => {
-                                        let style = style.system_sender.style_for(state, false);
+                                        let style = style.system_sender.style_for(state);
                                         Label::new("System", style.text.clone())
                                             .contained()
                                             .with_style(style.container)
@@ -1484,7 +1484,7 @@ impl View for AssistantEditor {
                 Flex::row()
                     .with_child(
                         MouseEventHandler::<Model, _>::new(0, cx, |state, _| {
-                            let style = theme.model.style_for(state, false);
+                            let style = theme.model.style_for(state);
                             Label::new(model, style.text.clone())
                                 .contained()
                                 .with_style(style.container)

crates/auto_update/src/update_notification.rs 🔗

@@ -49,7 +49,7 @@ impl View for UpdateNotification {
                         )
                         .with_child(
                             MouseEventHandler::<Cancel, _>::new(0, cx, |state, _| {
-                                let style = theme.dismiss_button.style_for(state, false);
+                                let style = theme.dismiss_button.style_for(state);
                                 Svg::new("icons/x_mark_8.svg")
                                     .with_color(style.color)
                                     .constrained()
@@ -74,7 +74,7 @@ impl View for UpdateNotification {
                         ),
                 )
                 .with_child({
-                    let style = theme.action_message.style_for(state, false);
+                    let style = theme.action_message.style_for(state);
                     Text::new("View the release notes", style.text.clone())
                         .contained()
                         .with_style(style.container)

crates/breadcrumbs/src/breadcrumbs.rs 🔗

@@ -83,7 +83,7 @@ impl View for Breadcrumbs {
         }
 
         MouseEventHandler::<Breadcrumbs, Breadcrumbs>::new(0, cx, |state, _| {
-            let style = style.style_for(state, false);
+            let style = style.style_for(state);
             crumbs.with_style(style.container)
         })
         .on_click(MouseButton::Left, |_, this, cx| {

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -299,7 +299,12 @@ impl CollabTitlebarItem {
     pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext<Self>) {
         let theme = theme::current(cx).clone();
         let avatar_style = theme.workspace.titlebar.leader_avatar.clone();
-        let item_style = theme.context_menu.item.disabled_style().clone();
+        let item_style = theme
+            .context_menu
+            .item
+            .inactive_state()
+            .disabled_style()
+            .clone();
         self.user_menu.update(cx, |user_menu, cx| {
             let items = if let Some(user) = self.user_store.read(cx).current_user() {
                 vec![
@@ -361,8 +366,20 @@ impl CollabTitlebarItem {
                     .contained()
                     .with_style(titlebar.toggle_contacts_badge)
                     .contained()
-                    .with_margin_left(titlebar.toggle_contacts_button.default.icon_width)
-                    .with_margin_top(titlebar.toggle_contacts_button.default.icon_width)
+                    .with_margin_left(
+                        titlebar
+                            .toggle_contacts_button
+                            .inactive_state()
+                            .default
+                            .icon_width,
+                    )
+                    .with_margin_top(
+                        titlebar
+                            .toggle_contacts_button
+                            .inactive_state()
+                            .default
+                            .icon_width,
+                    )
                     .aligned(),
             )
         };
@@ -372,7 +389,8 @@ impl CollabTitlebarItem {
                 MouseEventHandler::<ToggleContactsMenu, Self>::new(0, cx, |state, _| {
                     let style = titlebar
                         .toggle_contacts_button
-                        .style_for(state, self.contacts_popover.is_some());
+                        .in_state(self.contacts_popover.is_some())
+                        .style_for(state);
                     Svg::new("icons/user_plus_16.svg")
                         .with_color(style.color)
                         .constrained()
@@ -419,7 +437,7 @@ impl CollabTitlebarItem {
 
         let titlebar = &theme.workspace.titlebar;
         MouseEventHandler::<ToggleScreenSharing, Self>::new(0, cx, |state, _| {
-            let style = titlebar.call_control.style_for(state, false);
+            let style = titlebar.call_control.style_for(state);
             Svg::new(icon)
                 .with_color(style.color)
                 .constrained()
@@ -473,7 +491,7 @@ impl CollabTitlebarItem {
                 .with_child(
                     MouseEventHandler::<ShareUnshare, Self>::new(0, cx, |state, _| {
                         //TODO: Ensure this button has consistent width for both text variations
-                        let style = titlebar.share_button.style_for(state, false);
+                        let style = titlebar.share_button.inactive_state().style_for(state);
                         Label::new(label, style.text.clone())
                             .contained()
                             .with_style(style.container)
@@ -511,7 +529,7 @@ impl CollabTitlebarItem {
         Stack::new()
             .with_child(
                 MouseEventHandler::<ToggleUserMenu, Self>::new(0, cx, |state, _| {
-                    let style = titlebar.call_control.style_for(state, false);
+                    let style = titlebar.call_control.style_for(state);
                     Svg::new("icons/ellipsis_14.svg")
                         .with_color(style.color)
                         .constrained()
@@ -549,7 +567,7 @@ impl CollabTitlebarItem {
     fn render_sign_in_button(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
         let titlebar = &theme.workspace.titlebar;
         MouseEventHandler::<SignIn, Self>::new(0, cx, |state, _| {
-            let style = titlebar.sign_in_prompt.style_for(state, false);
+            let style = titlebar.sign_in_prompt.inactive_state().style_for(state);
             Label::new("Sign In", style.text.clone())
                 .contained()
                 .with_style(style.container)

crates/collab_ui/src/contact_finder.rs 🔗

@@ -117,7 +117,8 @@ impl PickerDelegate for ContactFinderDelegate {
             .contact_finder
             .picker
             .item
-            .style_for(mouse_state, selected);
+            .in_state(selected)
+            .style_for(mouse_state);
         Flex::row()
             .with_children(user.avatar.clone().map(|avatar| {
                 Image::from_data(avatar)

crates/collab_ui/src/contact_list.rs 🔗

@@ -774,7 +774,8 @@ impl ContactList {
             .with_style(
                 *theme
                     .contact_row
-                    .style_for(&mut Default::default(), is_selected),
+                    .in_state(is_selected)
+                    .style_for(&mut Default::default()),
             )
             .into_any()
     }
@@ -797,7 +798,7 @@ impl ContactList {
             .width
             .or(theme.contact_avatar.height)
             .unwrap_or(0.);
-        let row = &theme.project_row.default;
+        let row = &theme.project_row.inactive_state().default;
         let tree_branch = theme.tree_branch;
         let line_height = row.name.text.line_height(font_cache);
         let cap_height = row.name.text.cap_height(font_cache);
@@ -810,8 +811,11 @@ impl ContactList {
         };
 
         MouseEventHandler::<JoinProject, Self>::new(project_id as usize, cx, |mouse_state, _| {
-            let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
-            let row = theme.project_row.style_for(mouse_state, is_selected);
+            let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
+            let row = theme
+                .project_row
+                .in_state(is_selected)
+                .style_for(mouse_state);
 
             Flex::row()
                 .with_child(
@@ -893,7 +897,7 @@ impl ContactList {
             .width
             .or(theme.contact_avatar.height)
             .unwrap_or(0.);
-        let row = &theme.project_row.default;
+        let row = &theme.project_row.inactive_state().default;
         let tree_branch = theme.tree_branch;
         let line_height = row.name.text.line_height(font_cache);
         let cap_height = row.name.text.cap_height(font_cache);
@@ -904,8 +908,11 @@ impl ContactList {
             peer_id.as_u64() as usize,
             cx,
             |mouse_state, _| {
-                let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
-                let row = theme.project_row.style_for(mouse_state, is_selected);
+                let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state);
+                let row = theme
+                    .project_row
+                    .in_state(is_selected)
+                    .style_for(mouse_state);
 
                 Flex::row()
                     .with_child(
@@ -989,7 +996,8 @@ impl ContactList {
 
         let header_style = theme
             .header_row
-            .style_for(&mut Default::default(), is_selected);
+            .in_state(is_selected)
+            .style_for(&mut Default::default());
         let text = match section {
             Section::ActiveCall => "Collaborators",
             Section::Requests => "Contact Requests",
@@ -999,7 +1007,7 @@ impl ContactList {
         let leave_call = if section == Section::ActiveCall {
             Some(
                 MouseEventHandler::<LeaveCallContactList, Self>::new(0, cx, |state, _| {
-                    let style = theme.leave_call.style_for(state, false);
+                    let style = theme.leave_call.style_for(state);
                     Label::new("Leave Call", style.text.clone())
                         .contained()
                         .with_style(style.container)
@@ -1110,8 +1118,7 @@ impl ContactList {
                             contact.user.id as usize,
                             cx,
                             |mouse_state, _| {
-                                let button_style =
-                                    theme.contact_button.style_for(mouse_state, false);
+                                let button_style = theme.contact_button.style_for(mouse_state);
                                 render_icon_button(button_style, "icons/x_mark_8.svg")
                                     .aligned()
                                     .flex_float()
@@ -1146,7 +1153,8 @@ impl ContactList {
                     .with_style(
                         *theme
                             .contact_row
-                            .style_for(&mut Default::default(), is_selected),
+                            .in_state(is_selected)
+                            .style_for(&mut Default::default()),
                     )
             })
             .on_click(MouseButton::Left, move |_, this, cx| {
@@ -1204,7 +1212,7 @@ impl ContactList {
                     let button_style = if is_contact_request_pending {
                         &theme.disabled_button
                     } else {
-                        theme.contact_button.style_for(mouse_state, false)
+                        theme.contact_button.style_for(mouse_state)
                     };
                     render_icon_button(button_style, "icons/x_mark_8.svg").aligned()
                 })
@@ -1227,7 +1235,7 @@ impl ContactList {
                     let button_style = if is_contact_request_pending {
                         &theme.disabled_button
                     } else {
-                        theme.contact_button.style_for(mouse_state, false)
+                        theme.contact_button.style_for(mouse_state)
                     };
                     render_icon_button(button_style, "icons/check_8.svg")
                         .aligned()
@@ -1250,7 +1258,7 @@ impl ContactList {
                     let button_style = if is_contact_request_pending {
                         &theme.disabled_button
                     } else {
-                        theme.contact_button.style_for(mouse_state, false)
+                        theme.contact_button.style_for(mouse_state)
                     };
                     render_icon_button(button_style, "icons/x_mark_8.svg")
                         .aligned()
@@ -1277,7 +1285,8 @@ impl ContactList {
             .with_style(
                 *theme
                     .contact_row
-                    .style_for(&mut Default::default(), is_selected),
+                    .in_state(is_selected)
+                    .style_for(&mut Default::default()),
             )
             .into_any()
     }

crates/collab_ui/src/notifications.rs 🔗

@@ -53,7 +53,7 @@ where
                 )
                 .with_child(
                     MouseEventHandler::<Dismiss, V>::new(user.id as usize, cx, |state, _| {
-                        let style = theme.dismiss_button.style_for(state, false);
+                        let style = theme.dismiss_button.style_for(state);
                         Svg::new("icons/x_mark_8.svg")
                             .with_color(style.color)
                             .constrained()
@@ -93,7 +93,7 @@ where
                     .with_children(buttons.into_iter().enumerate().map(
                         |(ix, (message, handler))| {
                             MouseEventHandler::<Button, V>::new(ix, cx, |state, _| {
-                                let button = theme.button.style_for(state, false);
+                                let button = theme.button.style_for(state);
                                 Label::new(message, button.text.clone())
                                     .contained()
                                     .with_style(button.container)

crates/command_palette/src/command_palette.rs 🔗

@@ -185,8 +185,8 @@ impl PickerDelegate for CommandPaletteDelegate {
         let mat = &self.matches[ix];
         let command = &self.actions[mat.candidate_id];
         let theme = theme::current(cx);
-        let style = theme.picker.item.style_for(mouse_state, selected);
-        let key_style = &theme.command_palette.key.style_for(mouse_state, selected);
+        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
+        let key_style = &theme.command_palette.key.in_state(selected);
         let keystroke_spacing = theme.command_palette.keystroke_spacing;
 
         Flex::row()

crates/context_menu/src/context_menu.rs 🔗

@@ -328,10 +328,8 @@ impl ContextMenu {
                 Flex::column().with_children(self.items.iter().enumerate().map(|(ix, item)| {
                     match item {
                         ContextMenuItem::Item { label, .. } => {
-                            let style = style.item.style_for(
-                                &mut Default::default(),
-                                Some(ix) == self.selected_index,
-                            );
+                            let style = style.item.in_state(self.selected_index == Some(ix));
+                            let style = style.style_for(&mut Default::default());
 
                             match label {
                                 ContextMenuItemLabel::String(label) => {
@@ -363,10 +361,8 @@ impl ContextMenu {
                     .with_children(self.items.iter().enumerate().map(|(ix, item)| {
                         match item {
                             ContextMenuItem::Item { action, .. } => {
-                                let style = style.item.style_for(
-                                    &mut Default::default(),
-                                    Some(ix) == self.selected_index,
-                                );
+                                let style = style.item.in_state(self.selected_index == Some(ix));
+                                let style = style.style_for(&mut Default::default());
 
                                 match action {
                                     ContextMenuItemAction::Action(action) => KeystrokeLabel::new(
@@ -412,8 +408,8 @@ impl ContextMenu {
                             let action = action.clone();
                             let view_id = self.parent_view_id;
                             MouseEventHandler::<MenuItem, ContextMenu>::new(ix, cx, |state, _| {
-                                let style =
-                                    style.item.style_for(state, Some(ix) == self.selected_index);
+                                let style = style.item.in_state(self.selected_index == Some(ix));
+                                let style = style.style_for(state);
                                 let keystroke = match &action {
                                     ContextMenuItemAction::Action(action) => Some(
                                         KeystrokeLabel::new(

crates/copilot/src/sign_in.rs 🔗

@@ -127,16 +127,16 @@ impl CopilotCodeVerification {
                 .with_child(
                     Label::new(
                         if copied { "Copied!" } else { "Copy" },
-                        device_code_style.cta.style_for(state, false).text.clone(),
+                        device_code_style.cta.style_for(state).text.clone(),
                     )
                     .aligned()
                     .contained()
-                    .with_style(*device_code_style.right_container.style_for(state, false))
+                    .with_style(*device_code_style.right_container.style_for(state))
                     .constrained()
                     .with_width(device_code_style.right),
                 )
                 .contained()
-                .with_style(device_code_style.cta.style_for(state, false).container)
+                .with_style(device_code_style.cta.style_for(state).container)
         })
         .on_click(gpui::platform::MouseButton::Left, {
             let user_code = data.user_code.clone();

crates/copilot_button/src/copilot_button.rs 🔗

@@ -71,7 +71,8 @@ impl View for CopilotButton {
                             .status_bar
                             .panel_buttons
                             .button
-                            .style_for(state, active);
+                            .in_state(active)
+                            .style_for(state);
 
                         Flex::row()
                             .with_child(
@@ -255,7 +256,7 @@ impl CopilotButton {
             move |state: &mut MouseState, style: &theme::ContextMenuItem| {
                 Flex::row()
                     .with_child(Label::new("Copilot Settings", style.label.clone()))
-                    .with_child(theme::ui::icon(icon_style.style_for(state, false)))
+                    .with_child(theme::ui::icon(icon_style.style_for(state)))
                     .align_children_center()
                     .into_any()
             },

crates/diagnostics/src/items.rs 🔗

@@ -100,7 +100,7 @@ impl View for DiagnosticIndicator {
                     .workspace
                     .status_bar
                     .diagnostic_summary
-                    .style_for(state, false);
+                    .style_for(state);
 
                 let mut summary_row = Flex::row();
                 if self.summary.error_count > 0 {
@@ -198,7 +198,7 @@ impl View for DiagnosticIndicator {
                 MouseEventHandler::<Message, _>::new(1, cx, |state, _| {
                     Label::new(
                         diagnostic.message.split('\n').next().unwrap().to_string(),
-                        message_style.style_for(state, false).text.clone(),
+                        message_style.style_for(state).text.clone(),
                     )
                     .aligned()
                     .contained()

crates/editor/src/editor.rs 🔗

@@ -3320,15 +3320,21 @@ impl Editor {
     pub fn render_code_actions_indicator(
         &self,
         style: &EditorStyle,
-        active: bool,
+        is_active: bool,
         cx: &mut ViewContext<Self>,
     ) -> Option<AnyElement<Self>> {
         if self.available_code_actions.is_some() {
             enum CodeActions {}
             Some(
                 MouseEventHandler::<CodeActions, _>::new(0, cx, |state, _| {
-                    Svg::new("icons/bolt_8.svg")
-                        .with_color(style.code_actions.indicator.style_for(state, active).color)
+                    Svg::new("icons/bolt_8.svg").with_color(
+                        style
+                            .code_actions
+                            .indicator
+                            .in_state(is_active)
+                            .style_for(state)
+                            .color,
+                    )
                 })
                 .with_cursor_style(CursorStyle::PointingHand)
                 .with_padding(Padding::uniform(3.))
@@ -3378,10 +3384,8 @@ impl Editor {
                                     .with_color(
                                         style
                                             .indicator
-                                            .style_for(
-                                                mouse_state,
-                                                fold_status == FoldStatus::Folded,
-                                            )
+                                            .in_state(fold_status == FoldStatus::Folded)
+                                            .style_for(mouse_state)
                                             .color,
                                     )
                                     .constrained()

crates/editor/src/element.rs 🔗

@@ -1529,7 +1529,7 @@ impl EditorElement {
 
                         enum JumpIcon {}
                         MouseEventHandler::<JumpIcon, _>::new((*id).into(), cx, |state, _| {
-                            let style = style.jump_icon.style_for(state, false);
+                            let style = style.jump_icon.style_for(state);
                             Svg::new("icons/arrow_up_right_8.svg")
                                 .with_color(style.color)
                                 .constrained()
@@ -2094,7 +2094,7 @@ impl Element<Editor> for EditorElement {
                     .folds
                     .ellipses
                     .background
-                    .style_for(&mut cx.mouse_state::<FoldMarkers>(id as usize), false)
+                    .style_for(&mut cx.mouse_state::<FoldMarkers>(id as usize))
                     .color;
 
                 (id, fold, color)

crates/feedback/src/deploy_feedback_button.rs 🔗

@@ -41,7 +41,8 @@ impl View for DeployFeedbackButton {
                         .status_bar
                         .panel_buttons
                         .button
-                        .style_for(state, active);
+                        .in_state(active)
+                        .style_for(state);
 
                     Svg::new("icons/feedback_16.svg")
                         .with_color(style.icon_color)

crates/feedback/src/submit_feedback_button.rs 🔗

@@ -48,7 +48,7 @@ impl View for SubmitFeedbackButton {
         let theme = theme::current(cx).clone();
         enum SubmitFeedbackButton {}
         MouseEventHandler::<SubmitFeedbackButton, Self>::new(0, cx, |state, _| {
-            let style = theme.feedback.submit_button.style_for(state, false);
+            let style = theme.feedback.submit_button.style_for(state);
             Label::new("Submit as Markdown", style.text.clone())
                 .contained()
                 .with_style(style.container)

crates/file_finder/src/file_finder.rs 🔗

@@ -546,7 +546,7 @@ impl PickerDelegate for FileFinderDelegate {
             .get(ix)
             .expect("Invalid matches state: no element for index {ix}");
         let theme = theme::current(cx);
-        let style = theme.picker.item.style_for(mouse_state, selected);
+        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
         let (file_name, file_name_positions, full_path, full_path_positions) =
             self.labels_for_match(path_match, cx, ix);
         Flex::column()

crates/language_selector/src/active_buffer_language.rs 🔗

@@ -55,7 +55,7 @@ impl View for ActiveBufferLanguage {
 
             MouseEventHandler::<Self, Self>::new(0, cx, |state, cx| {
                 let theme = &theme::current(cx).workspace.status_bar;
-                let style = theme.active_language.style_for(state, false);
+                let style = theme.active_language.style_for(state);
                 Label::new(active_language_text, style.text.clone())
                     .contained()
                     .with_style(style.container)

crates/language_selector/src/language_selector.rs 🔗

@@ -180,7 +180,7 @@ impl PickerDelegate for LanguageSelectorDelegate {
     ) -> AnyElement<Picker<Self>> {
         let theme = theme::current(cx);
         let mat = &self.matches[ix];
-        let style = theme.picker.item.style_for(mouse_state, selected);
+        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
         let buffer_language_name = self.buffer.read(cx).language().map(|l| l.name());
         let mut label = mat.string.clone();
         if buffer_language_name.as_deref() == Some(mat.string.as_str()) {

crates/language_tools/src/lsp_log.rs 🔗

@@ -681,7 +681,7 @@ impl LspLogToolbarItemView {
                     )
                 })
                 .unwrap_or_else(|| "No server selected".into());
-            let style = theme.toolbar_dropdown_menu.header.style_for(state, false);
+            let style = theme.toolbar_dropdown_menu.header.style_for(state);
             Label::new(label, style.text.clone())
                 .contained()
                 .with_style(style.container)
@@ -722,7 +722,8 @@ impl LspLogToolbarItemView {
                     let style = theme
                         .toolbar_dropdown_menu
                         .item
-                        .style_for(state, logs_selected);
+                        .in_state(logs_selected)
+                        .style_for(state);
                     Label::new(SERVER_LOGS, style.text.clone())
                         .contained()
                         .with_style(style.container)
@@ -739,7 +740,8 @@ impl LspLogToolbarItemView {
                     let style = theme
                         .toolbar_dropdown_menu
                         .item
-                        .style_for(state, rpc_trace_selected);
+                        .in_state(rpc_trace_selected)
+                        .style_for(state);
                     Flex::row()
                         .with_child(
                             Label::new(RPC_MESSAGES, style.text.clone())

crates/language_tools/src/syntax_tree_view.rs 🔗

@@ -565,7 +565,7 @@ impl SyntaxTreeToolbarItemView {
     ) -> impl Element<Self> {
         enum ToggleMenu {}
         MouseEventHandler::<ToggleMenu, Self>::new(0, cx, move |state, _| {
-            let style = theme.toolbar_dropdown_menu.header.style_for(state, false);
+            let style = theme.toolbar_dropdown_menu.header.style_for(state);
             Flex::row()
                 .with_child(
                     Label::new(active_layer.language.name().to_string(), style.text.clone())
@@ -601,7 +601,8 @@ impl SyntaxTreeToolbarItemView {
             let style = theme
                 .toolbar_dropdown_menu
                 .item
-                .style_for(state, is_selected);
+                .in_state(is_selected)
+                .style_for(state);
             Flex::row()
                 .with_child(
                     Label::new(layer.language.name().to_string(), style.text.clone())

crates/outline/src/outline.rs 🔗

@@ -204,7 +204,7 @@ impl PickerDelegate for OutlineViewDelegate {
         cx: &AppContext,
     ) -> AnyElement<Picker<Self>> {
         let theme = theme::current(cx);
-        let style = theme.picker.item.style_for(mouse_state, selected);
+        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
         let string_match = &self.matches[ix];
         let outline_item = &self.outline.items[string_match.candidate_id];
 

crates/project_panel/src/project_panel.rs 🔗

@@ -1253,7 +1253,10 @@ impl ProjectPanel {
         let show_editor = details.is_editing && !details.is_processing;
 
         MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
-            let mut style = entry_style.style_for(state, details.is_selected).clone();
+            let mut style = entry_style
+                .in_state(details.is_selected)
+                .style_for(state)
+                .clone();
 
             if cx
                 .global::<DragAndDrop<Workspace>>()
@@ -1264,7 +1267,7 @@ impl ProjectPanel {
                     .filter(|destination| details.path.starts_with(destination))
                     .is_some()
             {
-                style = entry_style.active.clone().unwrap();
+                style = entry_style.active_state().default.clone();
             }
 
             let row_container_style = if show_editor {
@@ -1405,9 +1408,11 @@ impl View for ProjectPanel {
                         let button_style = theme.open_project_button.clone();
                         let context_menu_item_style = theme::current(cx).context_menu.item.clone();
                         move |state, cx| {
-                            let button_style = button_style.style_for(state, false).clone();
-                            let context_menu_item =
-                                context_menu_item_style.style_for(state, true).clone();
+                            let button_style = button_style.style_for(state).clone();
+                            let context_menu_item = context_menu_item_style
+                                .active_state()
+                                .style_for(state)
+                                .clone();
 
                             theme::ui::keystroke_label(
                                 "Open a project",

crates/project_symbols/src/project_symbols.rs 🔗

@@ -196,7 +196,7 @@ impl PickerDelegate for ProjectSymbolsDelegate {
     ) -> AnyElement<Picker<Self>> {
         let theme = theme::current(cx);
         let style = &theme.picker.item;
-        let current_style = style.style_for(mouse_state, selected);
+        let current_style = style.in_state(selected).style_for(mouse_state);
 
         let string_match = &self.matches[ix];
         let symbol = &self.symbols[string_match.candidate_id];
@@ -229,7 +229,10 @@ impl PickerDelegate for ProjectSymbolsDelegate {
             .with_child(
                 // Avoid styling the path differently when it is selected, since
                 // the symbol's syntax highlighting doesn't change when selected.
-                Label::new(path.to_string(), style.default.label.clone()),
+                Label::new(
+                    path.to_string(),
+                    style.inactive_state().default.label.clone(),
+                ),
             )
             .contained()
             .with_style(current_style.container)

crates/recent_projects/src/recent_projects.rs 🔗

@@ -173,7 +173,7 @@ impl PickerDelegate for RecentProjectsDelegate {
         cx: &gpui::AppContext,
     ) -> AnyElement<Picker<Self>> {
         let theme = theme::current(cx);
-        let style = theme.picker.item.style_for(mouse_state, selected);
+        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
 
         let string_match = &self.matches[ix];
 

crates/search/src/buffer_search.rs 🔗

@@ -328,7 +328,11 @@ impl BufferSearchBar {
         Some(
             MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
                 let theme = theme::current(cx);
-                let style = theme.search.option_button.style_for(state, is_active);
+                let style = theme
+                    .search
+                    .option_button
+                    .in_state(is_active)
+                    .style_for(state);
                 Label::new(icon, style.text.clone())
                     .contained()
                     .with_style(style.container)
@@ -371,7 +375,7 @@ impl BufferSearchBar {
         enum NavButton {}
         MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
             let theme = theme::current(cx);
-            let style = theme.search.option_button.style_for(state, false);
+            let style = theme.search.option_button.inactive_state().style_for(state);
             Label::new(icon, style.text.clone())
                 .contained()
                 .with_style(style.container)
@@ -403,7 +407,7 @@ impl BufferSearchBar {
 
         enum CloseButton {}
         MouseEventHandler::<CloseButton, _>::new(0, cx, |state, _| {
-            let style = theme.dismiss_button.style_for(state, false);
+            let style = theme.dismiss_button.style_for(state);
             Svg::new("icons/x_mark_8.svg")
                 .with_color(style.color)
                 .constrained()

crates/search/src/project_search.rs 🔗

@@ -896,7 +896,7 @@ impl ProjectSearchBar {
         enum NavButton {}
         MouseEventHandler::<NavButton, _>::new(direction as usize, cx, |state, cx| {
             let theme = theme::current(cx);
-            let style = theme.search.option_button.style_for(state, false);
+            let style = theme.search.option_button.inactive_state().style_for(state);
             Label::new(icon, style.text.clone())
                 .contained()
                 .with_style(style.container)
@@ -927,7 +927,11 @@ impl ProjectSearchBar {
         let is_active = self.is_option_enabled(option, cx);
         MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
             let theme = theme::current(cx);
-            let style = theme.search.option_button.style_for(state, is_active);
+            let style = theme
+                .search
+                .option_button
+                .in_state(is_active)
+                .style_for(state);
             Label::new(icon, style.text.clone())
                 .contained()
                 .with_style(style.container)

crates/theme/src/theme.rs 🔗

@@ -128,12 +128,12 @@ pub struct Titlebar {
     pub leader_avatar: AvatarStyle,
     pub follower_avatar: AvatarStyle,
     pub inactive_avatar_grayscale: bool,
-    pub sign_in_prompt: Interactive<ContainedText>,
+    pub sign_in_prompt: Toggleable<Interactive<ContainedText>>,
     pub outdated_warning: ContainedText,
-    pub share_button: Interactive<ContainedText>,
+    pub share_button: Toggleable<Interactive<ContainedText>>,
     pub call_control: Interactive<IconButton>,
-    pub toggle_contacts_button: Interactive<IconButton>,
-    pub user_menu_button: Interactive<IconButton>,
+    pub toggle_contacts_button: Toggleable<Interactive<IconButton>>,
+    pub user_menu_button: Toggleable<Interactive<IconButton>>,
     pub toggle_contacts_badge: ContainerStyle,
 }
 
@@ -204,12 +204,12 @@ pub struct ContactList {
     pub user_query_editor: FieldEditor,
     pub user_query_editor_height: f32,
     pub add_contact_button: IconButton,
-    pub header_row: Interactive<ContainedText>,
+    pub header_row: Toggleable<Interactive<ContainedText>>,
     pub leave_call: Interactive<ContainedText>,
-    pub contact_row: Interactive<ContainerStyle>,
+    pub contact_row: Toggleable<Interactive<ContainerStyle>>,
     pub row_height: f32,
-    pub project_row: Interactive<ProjectRow>,
-    pub tree_branch: Interactive<TreeBranch>,
+    pub project_row: Toggleable<Interactive<ProjectRow>>,
+    pub tree_branch: Toggleable<Interactive<TreeBranch>>,
     pub contact_avatar: ImageStyle,
     pub contact_status_free: ContainerStyle,
     pub contact_status_busy: ContainerStyle,
@@ -251,7 +251,7 @@ pub struct DropdownMenu {
     pub container: ContainerStyle,
     pub header: Interactive<DropdownMenuItem>,
     pub section_header: ContainedText,
-    pub item: Interactive<DropdownMenuItem>,
+    pub item: Toggleable<Interactive<DropdownMenuItem>>,
     pub row_height: f32,
 }
 
@@ -270,7 +270,7 @@ pub struct DropdownMenuItem {
 pub struct TabBar {
     #[serde(flatten)]
     pub container: ContainerStyle,
-    pub pane_button: Interactive<IconButton>,
+    pub pane_button: Toggleable<Interactive<IconButton>>,
     pub pane_button_container: ContainerStyle,
     pub active_pane: TabStyles,
     pub inactive_pane: TabStyles,
@@ -359,7 +359,7 @@ pub struct Search {
     pub include_exclude_editor: FindEditor,
     pub invalid_include_exclude_editor: ContainerStyle,
     pub include_exclude_inputs: ContainedText,
-    pub option_button: Interactive<ContainedText>,
+    pub option_button: Toggleable<Interactive<ContainedText>>,
     pub match_background: Color,
     pub match_index: ContainedText,
     pub results_status: TextStyle,
@@ -395,7 +395,7 @@ pub struct StatusBarPanelButtons {
     pub group_left: ContainerStyle,
     pub group_bottom: ContainerStyle,
     pub group_right: ContainerStyle,
-    pub button: Interactive<PanelButton>,
+    pub button: Toggleable<Interactive<PanelButton>>,
 }
 
 #[derive(Deserialize, Default)]
@@ -444,10 +444,10 @@ pub struct PanelButton {
 pub struct ProjectPanel {
     #[serde(flatten)]
     pub container: ContainerStyle,
-    pub entry: Interactive<ProjectPanelEntry>,
+    pub entry: Toggleable<Interactive<ProjectPanelEntry>>,
     pub dragged_entry: ProjectPanelEntry,
-    pub ignored_entry: Interactive<ProjectPanelEntry>,
-    pub cut_entry: Interactive<ProjectPanelEntry>,
+    pub ignored_entry: Toggleable<Interactive<ProjectPanelEntry>>,
+    pub cut_entry: Toggleable<Interactive<ProjectPanelEntry>>,
     pub filename_editor: FieldEditor,
     pub indent_width: f32,
     pub open_project_button: Interactive<ContainedText>,
@@ -481,7 +481,7 @@ pub struct GitProjectStatus {
 pub struct ContextMenu {
     #[serde(flatten)]
     pub container: ContainerStyle,
-    pub item: Interactive<ContextMenuItem>,
+    pub item: Toggleable<Interactive<ContextMenuItem>>,
     pub keystroke_margin: f32,
     pub separator: ContainerStyle,
 }
@@ -498,7 +498,7 @@ pub struct ContextMenuItem {
 
 #[derive(Debug, Deserialize, Default)]
 pub struct CommandPalette {
-    pub key: Interactive<ContainedLabel>,
+    pub key: Toggleable<ContainedLabel>,
     pub keystroke_spacing: f32,
 }
 
@@ -565,7 +565,7 @@ pub struct Picker {
     pub input_editor: FieldEditor,
     pub empty_input_editor: FieldEditor,
     pub no_matches: ContainedLabel,
-    pub item: Interactive<ContainedLabel>,
+    pub item: Toggleable<Interactive<ContainedLabel>>,
 }
 
 #[derive(Clone, Debug, Deserialize, Default)]
@@ -771,13 +771,13 @@ pub struct InteractiveColor {
 #[derive(Clone, Deserialize, Default)]
 pub struct CodeActions {
     #[serde(default)]
-    pub indicator: Interactive<InteractiveColor>,
+    pub indicator: Toggleable<Interactive<InteractiveColor>>,
     pub vertical_scale: f32,
 }
 
 #[derive(Clone, Deserialize, Default)]
 pub struct Folds {
-    pub indicator: Interactive<InteractiveColor>,
+    pub indicator: Toggleable<Interactive<InteractiveColor>>,
     pub ellipses: FoldEllipses,
     pub fold_background: Color,
     pub icon_margin_scale: f32,
@@ -805,38 +805,46 @@ pub struct DiffStyle {
 #[derive(Debug, Default, Clone, Copy)]
 pub struct Interactive<T> {
     pub default: T,
-    pub hover: Option<T>,
-    pub hover_and_active: Option<T>,
+    pub hovered: Option<T>,
     pub clicked: Option<T>,
-    pub click_and_active: Option<T>,
-    pub active: Option<T>,
     pub disabled: Option<T>,
 }
 
-impl<T> Interactive<T> {
-    pub fn style_for(&self, state: &mut MouseState, active: bool) -> &T {
+#[derive(Clone, Copy, Debug, Default, Deserialize)]
+pub struct Toggleable<T> {
+    active: T,
+    inactive: T,
+}
+
+impl<T> Toggleable<T> {
+    pub fn new(active: T, inactive: T) -> Self {
+        Self { active, inactive }
+    }
+    pub fn in_state(&self, active: bool) -> &T {
         if active {
-            if state.hovered() {
-                self.hover_and_active
-                    .as_ref()
-                    .unwrap_or(self.active.as_ref().unwrap_or(&self.default))
-            } else if state.clicked() == Some(platform::MouseButton::Left) && self.clicked.is_some()
-            {
-                self.click_and_active
-                    .as_ref()
-                    .unwrap_or(self.active.as_ref().unwrap_or(&self.default))
-            } else {
-                self.active.as_ref().unwrap_or(&self.default)
-            }
-        } else if state.clicked() == Some(platform::MouseButton::Left) && self.clicked.is_some() {
+            &self.active
+        } else {
+            &self.inactive
+        }
+    }
+    pub fn active_state(&self) -> &T {
+        self.in_state(true)
+    }
+    pub fn inactive_state(&self) -> &T {
+        self.in_state(false)
+    }
+}
+
+impl<T> Interactive<T> {
+    pub fn style_for(&self, state: &mut MouseState) -> &T {
+        if state.clicked() == Some(platform::MouseButton::Left) && self.clicked.is_some() {
             self.clicked.as_ref().unwrap()
         } else if state.hovered() {
-            self.hover.as_ref().unwrap_or(&self.default)
+            self.hovered.as_ref().unwrap_or(&self.default)
         } else {
             &self.default
         }
     }
-
     pub fn disabled_style(&self) -> &T {
         self.disabled.as_ref().unwrap_or(&self.default)
     }
@@ -849,13 +857,9 @@ impl<'de, T: DeserializeOwned> Deserialize<'de> for Interactive<T> {
     {
         #[derive(Deserialize)]
         struct Helper {
-            #[serde(flatten)]
             default: Value,
-            hover: Option<Value>,
-            hover_and_active: Option<Value>,
+            hovered: Option<Value>,
             clicked: Option<Value>,
-            click_and_active: Option<Value>,
-            active: Option<Value>,
             disabled: Option<Value>,
         }
 
@@ -880,21 +884,15 @@ impl<'de, T: DeserializeOwned> Deserialize<'de> for Interactive<T> {
             }
         };
 
-        let hover = deserialize_state(json.hover)?;
-        let hover_and_active = deserialize_state(json.hover_and_active)?;
+        let hovered = deserialize_state(json.hovered)?;
         let clicked = deserialize_state(json.clicked)?;
-        let click_and_active = deserialize_state(json.click_and_active)?;
-        let active = deserialize_state(json.active)?;
         let disabled = deserialize_state(json.disabled)?;
         let default = serde_json::from_value(json.default).map_err(serde::de::Error::custom)?;
 
         Ok(Interactive {
             default,
-            hover,
-            hover_and_active,
+            hovered,
             clicked,
-            click_and_active,
-            active,
             disabled,
         })
     }

crates/theme/src/ui.rs 🔗

@@ -170,7 +170,7 @@ where
     F: Fn(MouseClick, &mut V, &mut EventContext<V>) + 'static,
 {
     MouseEventHandler::<Tag, V>::new(0, cx, |state, _| {
-        let style = style.style_for(state, false);
+        let style = style.style_for(state);
         Label::new(label, style.text.to_owned())
             .aligned()
             .contained()
@@ -220,13 +220,13 @@ where
                     title,
                     style
                         .title_text
-                        .style_for(&mut MouseState::default(), false)
+                        .style_for(&mut MouseState::default())
                         .clone(),
                 ))
                 .with_child(
                     // FIXME: Get a better tag type
                     MouseEventHandler::<Tag, V>::new(999999, cx, |state, _cx| {
-                        let style = style.close_icon.style_for(state, false);
+                        let style = style.close_icon.style_for(state);
                         icon(style)
                     })
                     .on_click(platform::MouseButton::Left, move |_, _, cx| {

crates/theme_selector/src/theme_selector.rs 🔗

@@ -208,7 +208,7 @@ impl PickerDelegate for ThemeSelectorDelegate {
         cx: &AppContext,
     ) -> AnyElement<Picker<Self>> {
         let theme = theme::current(cx);
-        let style = theme.picker.item.style_for(mouse_state, selected);
+        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
 
         let theme_match = &self.matches[ix];
         Label::new(theme_match.string.clone(), style.label.clone())

crates/theme_testbench/Cargo.toml 🔗

@@ -1,19 +0,0 @@
-[package]
-name = "theme_testbench"
-version = "0.1.0"
-edition = "2021"
-publish = false
-
-[lib]
-path = "src/theme_testbench.rs"
-doctest = false
-
-
-[dependencies]
-gpui = { path = "../gpui" }
-theme = { path = "../theme" }
-settings = { path = "../settings" }
-workspace = { path = "../workspace" }
-project = { path = "../project" }
-
-smallvec.workspace = true

crates/theme_testbench/src/theme_testbench.rs 🔗

@@ -1,300 +0,0 @@
-use gpui::{
-    actions,
-    color::Color,
-    elements::{
-        AnyElement, Canvas, Container, ContainerStyle, Flex, Label, Margin, MouseEventHandler,
-        Padding, ParentElement,
-    },
-    fonts::TextStyle,
-    AppContext, Border, Element, Entity, ModelHandle, Quad, Task, View, ViewContext, ViewHandle,
-    WeakViewHandle,
-};
-use project::Project;
-use theme::{ColorScheme, Layer, Style, StyleSet, ThemeSettings};
-use workspace::{item::Item, register_deserializable_item, Pane, Workspace};
-
-actions!(theme, [DeployThemeTestbench]);
-
-pub fn init(cx: &mut AppContext) {
-    cx.add_action(ThemeTestbench::deploy);
-
-    register_deserializable_item::<ThemeTestbench>(cx)
-}
-
-pub struct ThemeTestbench {}
-
-impl ThemeTestbench {
-    pub fn deploy(
-        workspace: &mut Workspace,
-        _: &DeployThemeTestbench,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        let view = cx.add_view(|_| ThemeTestbench {});
-        workspace.add_item(Box::new(view), cx);
-    }
-
-    fn render_ramps(color_scheme: &ColorScheme) -> Flex<Self> {
-        fn display_ramp(ramp: &Vec<Color>) -> AnyElement<ThemeTestbench> {
-            Flex::row()
-                .with_children(ramp.iter().cloned().map(|color| {
-                    Canvas::new(move |scene, bounds, _, _, _| {
-                        scene.push_quad(Quad {
-                            bounds,
-                            background: Some(color),
-                            ..Default::default()
-                        });
-                    })
-                    .flex(1.0, false)
-                }))
-                .flex(1.0, false)
-                .into_any()
-        }
-
-        Flex::column()
-            .with_child(display_ramp(&color_scheme.ramps.neutral))
-            .with_child(display_ramp(&color_scheme.ramps.red))
-            .with_child(display_ramp(&color_scheme.ramps.orange))
-            .with_child(display_ramp(&color_scheme.ramps.yellow))
-            .with_child(display_ramp(&color_scheme.ramps.green))
-            .with_child(display_ramp(&color_scheme.ramps.cyan))
-            .with_child(display_ramp(&color_scheme.ramps.blue))
-            .with_child(display_ramp(&color_scheme.ramps.violet))
-            .with_child(display_ramp(&color_scheme.ramps.magenta))
-    }
-
-    fn render_layer(
-        layer_index: usize,
-        layer: &Layer,
-        cx: &mut ViewContext<Self>,
-    ) -> Container<Self> {
-        Flex::column()
-            .with_child(
-                Self::render_button_set(0, layer_index, "base", &layer.base, cx).flex(1., false),
-            )
-            .with_child(
-                Self::render_button_set(1, layer_index, "variant", &layer.variant, cx)
-                    .flex(1., false),
-            )
-            .with_child(
-                Self::render_button_set(2, layer_index, "on", &layer.on, cx).flex(1., false),
-            )
-            .with_child(
-                Self::render_button_set(3, layer_index, "accent", &layer.accent, cx)
-                    .flex(1., false),
-            )
-            .with_child(
-                Self::render_button_set(4, layer_index, "positive", &layer.positive, cx)
-                    .flex(1., false),
-            )
-            .with_child(
-                Self::render_button_set(5, layer_index, "warning", &layer.warning, cx)
-                    .flex(1., false),
-            )
-            .with_child(
-                Self::render_button_set(6, layer_index, "negative", &layer.negative, cx)
-                    .flex(1., false),
-            )
-            .contained()
-            .with_style(ContainerStyle {
-                margin: Margin {
-                    top: 10.,
-                    bottom: 10.,
-                    left: 10.,
-                    right: 10.,
-                },
-                background_color: Some(layer.base.default.background),
-                ..Default::default()
-            })
-    }
-
-    fn render_button_set(
-        set_index: usize,
-        layer_index: usize,
-        set_name: &'static str,
-        style_set: &StyleSet,
-        cx: &mut ViewContext<Self>,
-    ) -> Flex<Self> {
-        Flex::row()
-            .with_child(Self::render_button(
-                set_index * 6,
-                layer_index,
-                set_name,
-                &style_set,
-                None,
-                cx,
-            ))
-            .with_child(Self::render_button(
-                set_index * 6 + 1,
-                layer_index,
-                "hovered",
-                &style_set,
-                Some(|style_set| &style_set.hovered),
-                cx,
-            ))
-            .with_child(Self::render_button(
-                set_index * 6 + 2,
-                layer_index,
-                "pressed",
-                &style_set,
-                Some(|style_set| &style_set.pressed),
-                cx,
-            ))
-            .with_child(Self::render_button(
-                set_index * 6 + 3,
-                layer_index,
-                "active",
-                &style_set,
-                Some(|style_set| &style_set.active),
-                cx,
-            ))
-            .with_child(Self::render_button(
-                set_index * 6 + 4,
-                layer_index,
-                "disabled",
-                &style_set,
-                Some(|style_set| &style_set.disabled),
-                cx,
-            ))
-            .with_child(Self::render_button(
-                set_index * 6 + 5,
-                layer_index,
-                "inverted",
-                &style_set,
-                Some(|style_set| &style_set.inverted),
-                cx,
-            ))
-    }
-
-    fn render_button(
-        button_index: usize,
-        layer_index: usize,
-        text: &'static str,
-        style_set: &StyleSet,
-        style_override: Option<fn(&StyleSet) -> &Style>,
-        cx: &mut ViewContext<Self>,
-    ) -> AnyElement<Self> {
-        enum TestBenchButton {}
-        MouseEventHandler::<TestBenchButton, _>::new(layer_index + button_index, cx, |state, cx| {
-            let style = if let Some(style_override) = style_override {
-                style_override(&style_set)
-            } else if state.clicked().is_some() {
-                &style_set.pressed
-            } else if state.hovered() {
-                &style_set.hovered
-            } else {
-                &style_set.default
-            };
-
-            Self::render_label(text.to_string(), style, cx)
-                .contained()
-                .with_style(ContainerStyle {
-                    margin: Margin {
-                        top: 4.,
-                        bottom: 4.,
-                        left: 4.,
-                        right: 4.,
-                    },
-                    padding: Padding {
-                        top: 4.,
-                        bottom: 4.,
-                        left: 4.,
-                        right: 4.,
-                    },
-                    background_color: Some(style.background),
-                    border: Border {
-                        width: 1.,
-                        color: style.border,
-                        overlay: false,
-                        top: true,
-                        bottom: true,
-                        left: true,
-                        right: true,
-                    },
-                    corner_radius: 2.,
-                    ..Default::default()
-                })
-        })
-        .flex(1., true)
-        .into_any()
-    }
-
-    fn render_label(text: String, style: &Style, cx: &mut ViewContext<Self>) -> Label {
-        let settings = settings::get::<ThemeSettings>(cx);
-        let font_cache = cx.font_cache();
-        let family_id = settings.buffer_font_family;
-        let font_size = settings.buffer_font_size(cx);
-        let font_id = font_cache
-            .select_font(family_id, &Default::default())
-            .unwrap();
-
-        let text_style = TextStyle {
-            color: style.foreground,
-            font_family_id: family_id,
-            font_family_name: font_cache.family_name(family_id).unwrap(),
-            font_id,
-            font_size,
-            font_properties: Default::default(),
-            underline: Default::default(),
-        };
-
-        Label::new(text, text_style)
-    }
-}
-
-impl Entity for ThemeTestbench {
-    type Event = ();
-}
-
-impl View for ThemeTestbench {
-    fn ui_name() -> &'static str {
-        "ThemeTestbench"
-    }
-
-    fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> AnyElement<Self> {
-        let color_scheme = &theme::current(cx).clone().color_scheme;
-
-        Flex::row()
-            .with_child(
-                Self::render_ramps(color_scheme)
-                    .contained()
-                    .with_margin_right(10.)
-                    .flex(0.1, false),
-            )
-            .with_child(
-                Flex::column()
-                    .with_child(Self::render_layer(100, &color_scheme.lowest, cx).flex(1., true))
-                    .with_child(Self::render_layer(200, &color_scheme.middle, cx).flex(1., true))
-                    .with_child(Self::render_layer(300, &color_scheme.highest, cx).flex(1., true))
-                    .flex(1., false),
-            )
-            .into_any()
-    }
-}
-
-impl Item for ThemeTestbench {
-    fn tab_content<T: View>(
-        &self,
-        _: Option<usize>,
-        style: &theme::Tab,
-        _: &AppContext,
-    ) -> AnyElement<T> {
-        Label::new("Theme Testbench", style.label.clone())
-            .aligned()
-            .contained()
-            .into_any()
-    }
-
-    fn serialized_item_kind() -> Option<&'static str> {
-        Some("ThemeTestBench")
-    }
-
-    fn deserialize(
-        _project: ModelHandle<Project>,
-        _workspace: WeakViewHandle<Workspace>,
-        _workspace_id: workspace::WorkspaceId,
-        _item_id: workspace::ItemId,
-        cx: &mut ViewContext<Pane>,
-    ) -> Task<gpui::anyhow::Result<ViewHandle<Self>>> {
-        Task::ready(Ok(cx.add_view(|_| Self {})))
-    }
-}

crates/welcome/src/base_keymap_picker.rs 🔗

@@ -141,7 +141,7 @@ impl PickerDelegate for BaseKeymapSelectorDelegate {
     ) -> gpui::AnyElement<Picker<Self>> {
         let theme = &theme::current(cx);
         let keymap_match = &self.matches[ix];
-        let style = theme.picker.item.style_for(mouse_state, selected);
+        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
 
         Label::new(keymap_match.string.clone(), style.label.clone())
             .with_highlights(keymap_match.positions.clone())

crates/workspace/src/dock.rs 🔗

@@ -498,7 +498,9 @@ impl View for PanelButtons {
                     Stack::new()
                         .with_child(
                             MouseEventHandler::<Self, _>::new(panel_ix, cx, |state, cx| {
-                                let style = button_style.style_for(state, is_active);
+                                let style = button_style.in_state(is_active);
+
+                                let style = style.style_for(state);
                                 Flex::row()
                                     .with_child(
                                         Svg::new(view.icon_path(cx))

crates/workspace/src/notifications.rs 🔗

@@ -291,7 +291,7 @@ pub mod simple_message_notification {
                         )
                         .with_child(
                             MouseEventHandler::<Cancel, _>::new(0, cx, |state, _| {
-                                let style = theme.dismiss_button.style_for(state, false);
+                                let style = theme.dismiss_button.style_for(state);
                                 Svg::new("icons/x_mark_8.svg")
                                     .with_color(style.color)
                                     .constrained()
@@ -323,7 +323,7 @@ pub mod simple_message_notification {
                                 0,
                                 cx,
                                 |state, _| {
-                                    let style = theme.action_message.style_for(state, false);
+                                    let style = theme.action_message.style_for(state);
 
                                     Flex::row()
                                         .with_child(

crates/workspace/src/pane.rs 🔗

@@ -1410,7 +1410,7 @@ impl Pane {
     pub fn render_tab_bar_button<F: 'static + Fn(&mut Pane, &mut EventContext<Pane>)>(
         index: usize,
         icon: &'static str,
-        active: bool,
+        is_active: bool,
         tooltip: Option<(String, Option<Box<dyn Action>>)>,
         cx: &mut ViewContext<Pane>,
         on_click: F,
@@ -1420,7 +1420,7 @@ impl Pane {
 
         let mut button = MouseEventHandler::<TabBarButton, _>::new(index, cx, |mouse_state, cx| {
             let theme = &settings::get::<ThemeSettings>(cx).theme.workspace.tab_bar;
-            let style = theme.pane_button.style_for(mouse_state, active);
+            let style = theme.pane_button.in_state(is_active).style_for(mouse_state);
             Svg::new(icon)
                 .with_color(style.color)
                 .constrained()

crates/workspace/src/toolbar.rs 🔗

@@ -231,7 +231,7 @@ fn nav_button<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>
 ) -> AnyElement<Toolbar> {
     MouseEventHandler::<A, _>::new(0, cx, |state, _| {
         let style = if enabled {
-            style.style_for(state, false)
+            style.style_for(state)
         } else {
             style.disabled_style()
         };

crates/zed/Cargo.toml 🔗

@@ -62,7 +62,6 @@ text = { path = "../text" }
 terminal_view = { path = "../terminal_view" }
 theme = { path = "../theme" }
 theme_selector = { path = "../theme_selector" }
-theme_testbench = { path = "../theme_testbench" }
 util = { path = "../util" }
 vim = { path = "../vim" }
 workspace = { path = "../workspace" }

crates/zed/src/main.rs 🔗

@@ -154,7 +154,6 @@ fn main() {
         search::init(cx);
         vim::init(cx);
         terminal_view::init(cx);
-        theme_testbench::init(cx);
         copilot::init(http.clone(), node_runtime, cx);
         ai::init(cx);
 

styles/.zed/settings.json 🔗

@@ -0,0 +1,20 @@
+// Folder-specific settings
+//
+// For a full list of overridable settings, and general information on folder-specific settings,
+// see the documentation: https://docs.zed.dev/configuration/configuring-zed#folder-specific-settings
+{
+    "languages": {
+        "TypeScript": {
+            "tab_size": 4
+        },
+        "TSX": {
+            "tab_size": 4
+        },
+        "JavaScript": {
+            "tab_size": 4
+        },
+        "JSON": {
+            "tab_size": 4
+        }
+    }
+}

styles/package-lock.json 🔗

@@ -18,9 +18,34 @@
                 "chroma-js": "^2.4.2",
                 "deepmerge": "^4.3.0",
                 "toml": "^3.0.0",
-                "ts-node": "^10.9.1"
+                "ts-deepmerge": "^6.0.3",
+                "ts-node": "^10.9.1",
+                "utility-types": "^3.10.0",
+                "vitest": "^0.32.0"
+            },
+            "devDependencies": {
+                "@vitest/coverage-v8": "^0.32.0"
+            }
+        },
+        "node_modules/@ampproject/remapping": {
+            "version": "2.2.1",
+            "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz",
+            "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==",
+            "dev": true,
+            "dependencies": {
+                "@jridgewell/gen-mapping": "^0.3.0",
+                "@jridgewell/trace-mapping": "^0.3.9"
+            },
+            "engines": {
+                "node": ">=6.0.0"
             }
         },
+        "node_modules/@bcoe/v8-coverage": {
+            "version": "0.2.3",
+            "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
+            "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
+            "dev": true
+        },
         "node_modules/@cspotcode/source-map-support": {
             "version": "0.8.1",
             "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -32,6 +57,359 @@
                 "node": ">=12"
             }
         },
+        "node_modules/@esbuild/android-arm": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz",
+            "integrity": "sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==",
+            "cpu": [
+                "arm"
+            ],
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/android-arm64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.19.tgz",
+            "integrity": "sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==",
+            "cpu": [
+                "arm64"
+            ],
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/android-x64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.19.tgz",
+            "integrity": "sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "android"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/darwin-arm64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.19.tgz",
+            "integrity": "sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==",
+            "cpu": [
+                "arm64"
+            ],
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/darwin-x64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.19.tgz",
+            "integrity": "sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/freebsd-arm64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.19.tgz",
+            "integrity": "sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==",
+            "cpu": [
+                "arm64"
+            ],
+            "optional": true,
+            "os": [
+                "freebsd"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/freebsd-x64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.19.tgz",
+            "integrity": "sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "freebsd"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/linux-arm": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.19.tgz",
+            "integrity": "sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==",
+            "cpu": [
+                "arm"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/linux-arm64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.19.tgz",
+            "integrity": "sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==",
+            "cpu": [
+                "arm64"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/linux-ia32": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.19.tgz",
+            "integrity": "sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==",
+            "cpu": [
+                "ia32"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/linux-loong64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.19.tgz",
+            "integrity": "sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==",
+            "cpu": [
+                "loong64"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/linux-mips64el": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.19.tgz",
+            "integrity": "sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==",
+            "cpu": [
+                "mips64el"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/linux-ppc64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.19.tgz",
+            "integrity": "sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==",
+            "cpu": [
+                "ppc64"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/linux-riscv64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.19.tgz",
+            "integrity": "sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==",
+            "cpu": [
+                "riscv64"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/linux-s390x": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.19.tgz",
+            "integrity": "sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==",
+            "cpu": [
+                "s390x"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/linux-x64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.19.tgz",
+            "integrity": "sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "linux"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/netbsd-x64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.19.tgz",
+            "integrity": "sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "netbsd"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/openbsd-x64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.19.tgz",
+            "integrity": "sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "openbsd"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/sunos-x64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.19.tgz",
+            "integrity": "sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "sunos"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/win32-arm64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.19.tgz",
+            "integrity": "sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==",
+            "cpu": [
+                "arm64"
+            ],
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/win32-ia32": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.19.tgz",
+            "integrity": "sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==",
+            "cpu": [
+                "ia32"
+            ],
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@esbuild/win32-x64": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.19.tgz",
+            "integrity": "sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==",
+            "cpu": [
+                "x64"
+            ],
+            "optional": true,
+            "os": [
+                "win32"
+            ],
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/@istanbuljs/schema": {
+            "version": "0.1.3",
+            "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+            "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/@jridgewell/gen-mapping": {
+            "version": "0.3.3",
+            "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz",
+            "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==",
+            "dev": true,
+            "dependencies": {
+                "@jridgewell/set-array": "^1.0.1",
+                "@jridgewell/sourcemap-codec": "^1.4.10",
+                "@jridgewell/trace-mapping": "^0.3.9"
+            },
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
         "node_modules/@jridgewell/resolve-uri": {
             "version": "3.1.0",
             "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
@@ -40,6 +418,15 @@
                 "node": ">=6.0.0"
             }
         },
+        "node_modules/@jridgewell/set-array": {
+            "version": "1.1.2",
+            "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+            "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+            "dev": true,
+            "engines": {
+                "node": ">=6.0.0"
+            }
+        },
         "node_modules/@jridgewell/sourcemap-codec": {
             "version": "1.4.14",
             "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz",
@@ -79,16 +466,124 @@
             "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz",
             "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ=="
         },
+        "node_modules/@types/chai": {
+            "version": "4.3.5",
+            "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz",
+            "integrity": "sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng=="
+        },
+        "node_modules/@types/chai-subset": {
+            "version": "1.3.3",
+            "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.3.tgz",
+            "integrity": "sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==",
+            "dependencies": {
+                "@types/chai": "*"
+            }
+        },
         "node_modules/@types/chroma-js": {
             "version": "2.4.0",
             "resolved": "https://registry.npmjs.org/@types/chroma-js/-/chroma-js-2.4.0.tgz",
             "integrity": "sha512-JklMxityrwjBTjGY2anH8JaTx3yjRU3/sEHSblLH1ba5lqcSh1LnImXJZO5peJfXyqKYWjHTGy4s5Wz++hARrw=="
         },
+        "node_modules/@types/istanbul-lib-coverage": {
+            "version": "2.0.4",
+            "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz",
+            "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==",
+            "dev": true
+        },
         "node_modules/@types/node": {
             "version": "18.14.1",
             "resolved": "https://registry.npmjs.org/@types/node/-/node-18.14.1.tgz",
             "integrity": "sha512-QH+37Qds3E0eDlReeboBxfHbX9omAcBCXEzswCu6jySP642jiM3cYSIkU/REqwhCUqXdonHFuBfJDiAJxMNhaQ=="
         },
+        "node_modules/@vitest/coverage-v8": {
+            "version": "0.32.0",
+            "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-0.32.0.tgz",
+            "integrity": "sha512-VXXlWq9X/NbsoP/l/CHLBjutsFFww1UY1qEhzGjn/DY7Tqe+z0Nu8XKc8im/XUAmjiWsh2XV7sy/F0IKAl4eaw==",
+            "dev": true,
+            "dependencies": {
+                "@ampproject/remapping": "^2.2.1",
+                "@bcoe/v8-coverage": "^0.2.3",
+                "istanbul-lib-coverage": "^3.2.0",
+                "istanbul-lib-report": "^3.0.0",
+                "istanbul-lib-source-maps": "^4.0.1",
+                "istanbul-reports": "^3.1.5",
+                "magic-string": "^0.30.0",
+                "picocolors": "^1.0.0",
+                "std-env": "^3.3.2",
+                "test-exclude": "^6.0.0",
+                "v8-to-istanbul": "^9.1.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            },
+            "peerDependencies": {
+                "vitest": ">=0.32.0 <1"
+            }
+        },
+        "node_modules/@vitest/expect": {
+            "version": "0.32.0",
+            "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.32.0.tgz",
+            "integrity": "sha512-VxVHhIxKw9Lux+O9bwLEEk2gzOUe93xuFHy9SzYWnnoYZFYg1NfBtnfnYWiJN7yooJ7KNElCK5YtA7DTZvtXtg==",
+            "dependencies": {
+                "@vitest/spy": "0.32.0",
+                "@vitest/utils": "0.32.0",
+                "chai": "^4.3.7"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            }
+        },
+        "node_modules/@vitest/runner": {
+            "version": "0.32.0",
+            "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.32.0.tgz",
+            "integrity": "sha512-QpCmRxftHkr72xt5A08xTEs9I4iWEXIOCHWhQQguWOKE4QH7DXSKZSOFibuwEIMAD7G0ERvtUyQn7iPWIqSwmw==",
+            "dependencies": {
+                "@vitest/utils": "0.32.0",
+                "concordance": "^5.0.4",
+                "p-limit": "^4.0.0",
+                "pathe": "^1.1.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            }
+        },
+        "node_modules/@vitest/snapshot": {
+            "version": "0.32.0",
+            "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.32.0.tgz",
+            "integrity": "sha512-yCKorPWjEnzpUxQpGlxulujTcSPgkblwGzAUEL+z01FTUg/YuCDZ8dxr9sHA08oO2EwxzHXNLjQKWJ2zc2a19Q==",
+            "dependencies": {
+                "magic-string": "^0.30.0",
+                "pathe": "^1.1.0",
+                "pretty-format": "^27.5.1"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            }
+        },
+        "node_modules/@vitest/spy": {
+            "version": "0.32.0",
+            "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.32.0.tgz",
+            "integrity": "sha512-MruAPlM0uyiq3d53BkwTeShXY0rYEfhNGQzVO5GHBmmX3clsxcWp79mMnkOVcV244sNTeDcHbcPFWIjOI4tZvw==",
+            "dependencies": {
+                "tinyspy": "^2.1.0"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            }
+        },
+        "node_modules/@vitest/utils": {
+            "version": "0.32.0",
+            "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.32.0.tgz",
+            "integrity": "sha512-53yXunzx47MmbuvcOPpLaVljHaeSu1G2dHdmy7+9ngMnQIkBQcvwOcoclWFnxDMxFbnq8exAfh3aKSZaK71J5A==",
+            "dependencies": {
+                "concordance": "^5.0.4",
+                "loupe": "^2.3.6",
+                "pretty-format": "^27.5.1"
+            },
+            "funding": {
+                "url": "https://opencollective.com/vitest"
+            }
+        },
         "node_modules/acorn": {
             "version": "8.8.2",
             "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz",
@@ -108,11 +603,38 @@
                 "node": ">=0.4.0"
             }
         },
+        "node_modules/ansi-regex": {
+            "version": "5.0.1",
+            "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+            "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/ansi-styles": {
+            "version": "5.2.0",
+            "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+            "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+            "engines": {
+                "node": ">=10"
+            },
+            "funding": {
+                "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+            }
+        },
         "node_modules/arg": {
             "version": "4.1.3",
             "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
             "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA=="
         },
+        "node_modules/assertion-error": {
+            "version": "1.1.0",
+            "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+            "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+            "engines": {
+                "node": "*"
+            }
+        },
         "node_modules/ayu": {
             "version": "8.0.1",
             "resolved": "https://registry.npmjs.org/ayu/-/ayu-8.0.1.tgz",
@@ -123,11 +645,40 @@
                 "nonenumerable": "^1.1.1"
             }
         },
+        "node_modules/balanced-match": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+            "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+            "dev": true
+        },
         "node_modules/bezier-easing": {
             "version": "2.1.0",
             "resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
             "integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig=="
         },
+        "node_modules/blueimp-md5": {
+            "version": "2.19.0",
+            "resolved": "https://registry.npmjs.org/blueimp-md5/-/blueimp-md5-2.19.0.tgz",
+            "integrity": "sha512-DRQrD6gJyy8FbiE4s+bDoXS9hiW3Vbx5uCdwvcCf3zLHL+Iv7LtGHLpr+GZV8rHG8tK766FGYBwRbu8pELTt+w=="
+        },
+        "node_modules/brace-expansion": {
+            "version": "1.1.11",
+            "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+            "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+            "dev": true,
+            "dependencies": {
+                "balanced-match": "^1.0.0",
+                "concat-map": "0.0.1"
+            }
+        },
+        "node_modules/cac": {
+            "version": "6.7.14",
+            "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+            "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+            "engines": {
+                "node": ">=8"
+            }
+        },
         "node_modules/case-anything": {
             "version": "2.1.10",
             "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.10.tgz",
@@ -139,16 +690,109 @@
                 "url": "https://github.com/sponsors/mesqueeb"
             }
         },
+        "node_modules/chai": {
+            "version": "4.3.7",
+            "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz",
+            "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==",
+            "dependencies": {
+                "assertion-error": "^1.1.0",
+                "check-error": "^1.0.2",
+                "deep-eql": "^4.1.2",
+                "get-func-name": "^2.0.0",
+                "loupe": "^2.3.1",
+                "pathval": "^1.1.1",
+                "type-detect": "^4.0.5"
+            },
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/check-error": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
+            "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==",
+            "engines": {
+                "node": "*"
+            }
+        },
         "node_modules/chroma-js": {
             "version": "2.4.2",
             "resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.4.2.tgz",
             "integrity": "sha512-U9eDw6+wt7V8z5NncY2jJfZa+hUH8XEj8FQHgFJTrUFnJfXYf4Ml4adI2vXZOjqRDpFWtYVWypDfZwnJ+HIR4A=="
         },
+        "node_modules/concat-map": {
+            "version": "0.0.1",
+            "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+            "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+            "dev": true
+        },
+        "node_modules/concordance": {
+            "version": "5.0.4",
+            "resolved": "https://registry.npmjs.org/concordance/-/concordance-5.0.4.tgz",
+            "integrity": "sha512-OAcsnTEYu1ARJqWVGwf4zh4JDfHZEaSNlNccFmt8YjB2l/n19/PF2viLINHc57vO4FKIAFl2FWASIGZZWZ2Kxw==",
+            "dependencies": {
+                "date-time": "^3.1.0",
+                "esutils": "^2.0.3",
+                "fast-diff": "^1.2.0",
+                "js-string-escape": "^1.0.1",
+                "lodash": "^4.17.15",
+                "md5-hex": "^3.0.1",
+                "semver": "^7.3.2",
+                "well-known-symbols": "^2.0.0"
+            },
+            "engines": {
+                "node": ">=10.18.0 <11 || >=12.14.0 <13 || >=14"
+            }
+        },
+        "node_modules/convert-source-map": {
+            "version": "1.9.0",
+            "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
+            "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
+            "dev": true
+        },
         "node_modules/create-require": {
             "version": "1.1.1",
             "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
             "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
         },
+        "node_modules/date-time": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/date-time/-/date-time-3.1.0.tgz",
+            "integrity": "sha512-uqCUKXE5q1PNBXjPqvwhwJf9SwMoAHBgWJ6DcrnS5o+W2JOiIILl0JEdVD8SGujrNS02GGxgwAg2PN2zONgtjg==",
+            "dependencies": {
+                "time-zone": "^1.0.0"
+            },
+            "engines": {
+                "node": ">=6"
+            }
+        },
+        "node_modules/debug": {
+            "version": "4.3.4",
+            "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+            "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+            "dependencies": {
+                "ms": "2.1.2"
+            },
+            "engines": {
+                "node": ">=6.0"
+            },
+            "peerDependenciesMeta": {
+                "supports-color": {
+                    "optional": true
+                }
+            }
+        },
+        "node_modules/deep-eql": {
+            "version": "4.1.3",
+            "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz",
+            "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==",
+            "dependencies": {
+                "type-detect": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=6"
+            }
+        },
         "node_modules/deepmerge": {
             "version": "4.3.0",
             "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.0.tgz",
@@ -165,21 +809,577 @@
                 "node": ">=0.3.1"
             }
         },
+        "node_modules/esbuild": {
+            "version": "0.17.19",
+            "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz",
+            "integrity": "sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==",
+            "hasInstallScript": true,
+            "bin": {
+                "esbuild": "bin/esbuild"
+            },
+            "engines": {
+                "node": ">=12"
+            },
+            "optionalDependencies": {
+                "@esbuild/android-arm": "0.17.19",
+                "@esbuild/android-arm64": "0.17.19",
+                "@esbuild/android-x64": "0.17.19",
+                "@esbuild/darwin-arm64": "0.17.19",
+                "@esbuild/darwin-x64": "0.17.19",
+                "@esbuild/freebsd-arm64": "0.17.19",
+                "@esbuild/freebsd-x64": "0.17.19",
+                "@esbuild/linux-arm": "0.17.19",
+                "@esbuild/linux-arm64": "0.17.19",
+                "@esbuild/linux-ia32": "0.17.19",
+                "@esbuild/linux-loong64": "0.17.19",
+                "@esbuild/linux-mips64el": "0.17.19",
+                "@esbuild/linux-ppc64": "0.17.19",
+                "@esbuild/linux-riscv64": "0.17.19",
+                "@esbuild/linux-s390x": "0.17.19",
+                "@esbuild/linux-x64": "0.17.19",
+                "@esbuild/netbsd-x64": "0.17.19",
+                "@esbuild/openbsd-x64": "0.17.19",
+                "@esbuild/sunos-x64": "0.17.19",
+                "@esbuild/win32-arm64": "0.17.19",
+                "@esbuild/win32-ia32": "0.17.19",
+                "@esbuild/win32-x64": "0.17.19"
+            }
+        },
+        "node_modules/esutils": {
+            "version": "2.0.3",
+            "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+            "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/fast-diff": {
+            "version": "1.3.0",
+            "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+            "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="
+        },
+        "node_modules/fs.realpath": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+            "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
+            "dev": true
+        },
+        "node_modules/fsevents": {
+            "version": "2.3.2",
+            "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+            "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+            "hasInstallScript": true,
+            "optional": true,
+            "os": [
+                "darwin"
+            ],
+            "engines": {
+                "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+            }
+        },
+        "node_modules/get-func-name": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz",
+            "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==",
+            "engines": {
+                "node": "*"
+            }
+        },
+        "node_modules/glob": {
+            "version": "7.2.3",
+            "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
+            "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
+            "dev": true,
+            "dependencies": {
+                "fs.realpath": "^1.0.0",
+                "inflight": "^1.0.4",
+                "inherits": "2",
+                "minimatch": "^3.1.1",
+                "once": "^1.3.0",
+                "path-is-absolute": "^1.0.0"
+            },
+            "engines": {
+                "node": "*"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/isaacs"
+            }
+        },
+        "node_modules/has-flag": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+            "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/html-escaper": {
+            "version": "2.0.2",
+            "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+            "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+            "dev": true
+        },
+        "node_modules/inflight": {
+            "version": "1.0.6",
+            "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+            "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
+            "dev": true,
+            "dependencies": {
+                "once": "^1.3.0",
+                "wrappy": "1"
+            }
+        },
+        "node_modules/inherits": {
+            "version": "2.0.4",
+            "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+            "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+            "dev": true
+        },
+        "node_modules/istanbul-lib-coverage": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
+            "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
+            "dev": true,
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/istanbul-lib-report": {
+            "version": "3.0.0",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+            "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==",
+            "dev": true,
+            "dependencies": {
+                "istanbul-lib-coverage": "^3.0.0",
+                "make-dir": "^3.0.0",
+                "supports-color": "^7.1.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/istanbul-lib-source-maps": {
+            "version": "4.0.1",
+            "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
+            "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
+            "dev": true,
+            "dependencies": {
+                "debug": "^4.1.1",
+                "istanbul-lib-coverage": "^3.0.0",
+                "source-map": "^0.6.1"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/istanbul-reports": {
+            "version": "3.1.5",
+            "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz",
+            "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==",
+            "dev": true,
+            "dependencies": {
+                "html-escaper": "^2.0.0",
+                "istanbul-lib-report": "^3.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/js-string-escape": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz",
+            "integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==",
+            "engines": {
+                "node": ">= 0.8"
+            }
+        },
+        "node_modules/jsonc-parser": {
+            "version": "3.2.0",
+            "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.0.tgz",
+            "integrity": "sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w=="
+        },
+        "node_modules/local-pkg": {
+            "version": "0.4.3",
+            "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz",
+            "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==",
+            "engines": {
+                "node": ">=14"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/antfu"
+            }
+        },
+        "node_modules/lodash": {
+            "version": "4.17.21",
+            "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+            "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
+        },
+        "node_modules/loupe": {
+            "version": "2.3.6",
+            "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz",
+            "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==",
+            "dependencies": {
+                "get-func-name": "^2.0.0"
+            }
+        },
+        "node_modules/lru-cache": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+            "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+            "dependencies": {
+                "yallist": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/magic-string": {
+            "version": "0.30.0",
+            "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz",
+            "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==",
+            "dependencies": {
+                "@jridgewell/sourcemap-codec": "^1.4.13"
+            },
+            "engines": {
+                "node": ">=12"
+            }
+        },
+        "node_modules/make-dir": {
+            "version": "3.1.0",
+            "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+            "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+            "dev": true,
+            "dependencies": {
+                "semver": "^6.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/make-dir/node_modules/semver": {
+            "version": "6.3.0",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+            "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+            "dev": true,
+            "bin": {
+                "semver": "bin/semver.js"
+            }
+        },
         "node_modules/make-error": {
             "version": "1.3.6",
             "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
             "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="
         },
+        "node_modules/md5-hex": {
+            "version": "3.0.1",
+            "resolved": "https://registry.npmjs.org/md5-hex/-/md5-hex-3.0.1.tgz",
+            "integrity": "sha512-BUiRtTtV39LIJwinWBjqVsU9xhdnz7/i889V859IBFpuqGAj6LuOvHv5XLbgZ2R7ptJoJaEcxkv88/h25T7Ciw==",
+            "dependencies": {
+                "blueimp-md5": "^2.10.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/minimatch": {
+            "version": "3.1.2",
+            "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+            "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+            "dev": true,
+            "dependencies": {
+                "brace-expansion": "^1.1.7"
+            },
+            "engines": {
+                "node": "*"
+            }
+        },
+        "node_modules/mlly": {
+            "version": "1.3.0",
+            "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.3.0.tgz",
+            "integrity": "sha512-HT5mcgIQKkOrZecOjOX3DJorTikWXwsBfpcr/MGBkhfWcjiqvnaL/9ppxvIUXfjT6xt4DVIAsN9fMUz1ev4bIw==",
+            "dependencies": {
+                "acorn": "^8.8.2",
+                "pathe": "^1.1.0",
+                "pkg-types": "^1.0.3",
+                "ufo": "^1.1.2"
+            }
+        },
+        "node_modules/ms": {
+            "version": "2.1.2",
+            "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+            "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+        },
+        "node_modules/nanoid": {
+            "version": "3.3.6",
+            "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
+            "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+            "funding": [
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/ai"
+                }
+            ],
+            "bin": {
+                "nanoid": "bin/nanoid.cjs"
+            },
+            "engines": {
+                "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+            }
+        },
         "node_modules/nonenumerable": {
             "version": "1.1.1",
             "resolved": "https://registry.npmjs.org/nonenumerable/-/nonenumerable-1.1.1.tgz",
             "integrity": "sha512-ptUD9w9D8WqW6fuJJkZNCImkf+0vdbgUTbRK3i7jsy3olqtH96hYE6Q/S3Tx9NWbcB/ocAjYshXCAUP0lZ9B4Q=="
         },
+        "node_modules/once": {
+            "version": "1.4.0",
+            "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+            "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+            "dev": true,
+            "dependencies": {
+                "wrappy": "1"
+            }
+        },
+        "node_modules/p-limit": {
+            "version": "4.0.0",
+            "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz",
+            "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==",
+            "dependencies": {
+                "yocto-queue": "^1.0.0"
+            },
+            "engines": {
+                "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/sindresorhus"
+            }
+        },
+        "node_modules/path-is-absolute": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+            "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/pathe": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.1.tgz",
+            "integrity": "sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q=="
+        },
+        "node_modules/pathval": {
+            "version": "1.1.1",
+            "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+            "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+            "engines": {
+                "node": "*"
+            }
+        },
+        "node_modules/picocolors": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+            "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
+        },
+        "node_modules/pkg-types": {
+            "version": "1.0.3",
+            "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.0.3.tgz",
+            "integrity": "sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==",
+            "dependencies": {
+                "jsonc-parser": "^3.2.0",
+                "mlly": "^1.2.0",
+                "pathe": "^1.1.0"
+            }
+        },
+        "node_modules/postcss": {
+            "version": "8.4.24",
+            "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.24.tgz",
+            "integrity": "sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==",
+            "funding": [
+                {
+                    "type": "opencollective",
+                    "url": "https://opencollective.com/postcss/"
+                },
+                {
+                    "type": "tidelift",
+                    "url": "https://tidelift.com/funding/github/npm/postcss"
+                },
+                {
+                    "type": "github",
+                    "url": "https://github.com/sponsors/ai"
+                }
+            ],
+            "dependencies": {
+                "nanoid": "^3.3.6",
+                "picocolors": "^1.0.0",
+                "source-map-js": "^1.0.2"
+            },
+            "engines": {
+                "node": "^10 || ^12 || >=14"
+            }
+        },
+        "node_modules/pretty-format": {
+            "version": "27.5.1",
+            "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+            "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+            "dependencies": {
+                "ansi-regex": "^5.0.1",
+                "ansi-styles": "^5.0.0",
+                "react-is": "^17.0.1"
+            },
+            "engines": {
+                "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+            }
+        },
+        "node_modules/react-is": {
+            "version": "17.0.2",
+            "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+            "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
+        },
+        "node_modules/rollup": {
+            "version": "3.25.1",
+            "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.25.1.tgz",
+            "integrity": "sha512-tywOR+rwIt5m2ZAWSe5AIJcTat8vGlnPFAv15ycCrw33t6iFsXZ6mzHVFh2psSjxQPmI+xgzMZZizUAukBI4aQ==",
+            "bin": {
+                "rollup": "dist/bin/rollup"
+            },
+            "engines": {
+                "node": ">=14.18.0",
+                "npm": ">=8.0.0"
+            },
+            "optionalDependencies": {
+                "fsevents": "~2.3.2"
+            }
+        },
+        "node_modules/semver": {
+            "version": "7.5.2",
+            "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.2.tgz",
+            "integrity": "sha512-SoftuTROv/cRjCze/scjGyiDtcUyxw1rgYQSZY7XTmtR5hX+dm76iDbTH8TkLPHCQmlbQVSSbNZCPM2hb0knnQ==",
+            "dependencies": {
+                "lru-cache": "^6.0.0"
+            },
+            "bin": {
+                "semver": "bin/semver.js"
+            },
+            "engines": {
+                "node": ">=10"
+            }
+        },
+        "node_modules/siginfo": {
+            "version": "2.0.0",
+            "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+            "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="
+        },
+        "node_modules/source-map": {
+            "version": "0.6.1",
+            "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+            "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+            "dev": true,
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/source-map-js": {
+            "version": "1.0.2",
+            "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+            "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+            "engines": {
+                "node": ">=0.10.0"
+            }
+        },
+        "node_modules/stackback": {
+            "version": "0.0.2",
+            "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+            "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="
+        },
+        "node_modules/std-env": {
+            "version": "3.3.3",
+            "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.3.3.tgz",
+            "integrity": "sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg=="
+        },
+        "node_modules/strip-literal": {
+            "version": "1.0.1",
+            "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.0.1.tgz",
+            "integrity": "sha512-QZTsipNpa2Ppr6v1AmJHESqJ3Uz247MUS0OjrnnZjFAvEoWqxuyFuXn2xLgMtRnijJShAa1HL0gtJyUs7u7n3Q==",
+            "dependencies": {
+                "acorn": "^8.8.2"
+            },
+            "funding": {
+                "url": "https://github.com/sponsors/antfu"
+            }
+        },
+        "node_modules/supports-color": {
+            "version": "7.2.0",
+            "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+            "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+            "dev": true,
+            "dependencies": {
+                "has-flag": "^4.0.0"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/test-exclude": {
+            "version": "6.0.0",
+            "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+            "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+            "dev": true,
+            "dependencies": {
+                "@istanbuljs/schema": "^0.1.2",
+                "glob": "^7.1.4",
+                "minimatch": "^3.0.4"
+            },
+            "engines": {
+                "node": ">=8"
+            }
+        },
+        "node_modules/time-zone": {
+            "version": "1.0.0",
+            "resolved": "https://registry.npmjs.org/time-zone/-/time-zone-1.0.0.tgz",
+            "integrity": "sha512-TIsDdtKo6+XrPtiTm1ssmMngN1sAhyKnTO2kunQWqNPWIVvCm15Wmw4SWInwTVgJ5u/Tr04+8Ei9TNcw4x4ONA==",
+            "engines": {
+                "node": ">=4"
+            }
+        },
+        "node_modules/tinybench": {
+            "version": "2.5.0",
+            "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.5.0.tgz",
+            "integrity": "sha512-kRwSG8Zx4tjF9ZiyH4bhaebu+EDz1BOx9hOigYHlUW4xxI/wKIUQUqo018UlU4ar6ATPBsaMrdbKZ+tmPdohFA=="
+        },
+        "node_modules/tinypool": {
+            "version": "0.5.0",
+            "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.5.0.tgz",
+            "integrity": "sha512-paHQtnrlS1QZYKF/GnLoOM/DN9fqaGOFbCbxzAhwniySnzl9Ebk8w73/dd34DAhe/obUbPAOldTyYXQZxnPBPQ==",
+            "engines": {
+                "node": ">=14.0.0"
+            }
+        },
+        "node_modules/tinyspy": {
+            "version": "2.1.1",
+            "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.1.1.tgz",
+            "integrity": "sha512-XPJL2uSzcOyBMky6OFrusqWlzfFrXtE0hPuMgW8A2HmaqrPo4ZQHRN/V0QXN3FSjKxpsbRrFc5LI7KOwBsT1/w==",
+            "engines": {
+                "node": ">=14.0.0"
+            }
+        },
         "node_modules/toml": {
             "version": "3.0.0",
             "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz",
             "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="
         },
+        "node_modules/ts-deepmerge": {
+            "version": "6.0.3",
+            "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-6.0.3.tgz",
+            "integrity": "sha512-MBBJL0UK/mMnZRONMz4J1CRu5NsGtsh+gR1nkn8KLE9LXo/PCzeHhQduhNary8m5/m9ryOOyFwVKxq81cPlaow==",
+            "engines": {
+                "node": ">=14.13.1"
+            }
+        },
         "node_modules/ts-node": {
             "version": "10.9.1",
             "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz",

styles/package.json 🔗

@@ -6,7 +6,8 @@
     "scripts": {
         "build": "ts-node ./src/buildThemes.ts",
         "build-licenses": "ts-node ./src/buildLicenses.ts",
-        "build-tokens": "ts-node ./src/buildTokens.ts"
+        "build-tokens": "ts-node ./src/buildTokens.ts",
+        "test": "vitest"
     },
     "author": "",
     "license": "ISC",
@@ -20,12 +21,18 @@
         "chroma-js": "^2.4.2",
         "deepmerge": "^4.3.0",
         "toml": "^3.0.0",
-        "ts-node": "^10.9.1"
+        "ts-deepmerge": "^6.0.3",
+        "ts-node": "^10.9.1",
+        "utility-types": "^3.10.0",
+        "vitest": "^0.32.0"
     },
     "prettier": {
         "semi": false,
         "printWidth": 80,
         "htmlWhitespaceSensitivity": "strict",
         "tabWidth": 4
+    },
+    "devDependencies": {
+        "@vitest/coverage-v8": "^0.32.0"
     }
 }

styles/src/buildTokens.ts 🔗

@@ -1,13 +1,13 @@
-import * as fs from "fs";
-import * as path from "path";
-import { ColorScheme, createColorScheme } from "./common";
-import { themes } from "./themes";
-import { slugify } from "./utils/slugify";
-import { colorSchemeTokens } from "./theme/tokens/colorScheme";
+import * as fs from "fs"
+import * as path from "path"
+import { ColorScheme, createColorScheme } from "./common"
+import { themes } from "./themes"
+import { slugify } from "./utils/slugify"
+import { colorSchemeTokens } from "./theme/tokens/colorScheme"
 
-const TOKENS_DIRECTORY = path.join(__dirname, "..", "target", "tokens");
-const TOKENS_FILE = path.join(TOKENS_DIRECTORY, "$themes.json");
-const METADATA_FILE = path.join(TOKENS_DIRECTORY, "$metadata.json");
+const TOKENS_DIRECTORY = path.join(__dirname, "..", "target", "tokens")
+const TOKENS_FILE = path.join(TOKENS_DIRECTORY, "$themes.json")
+const METADATA_FILE = path.join(TOKENS_DIRECTORY, "$metadata.json")
 
 function clearTokens(tokensDirectory: string) {
     if (!fs.existsSync(tokensDirectory)) {
@@ -22,64 +22,66 @@ function clearTokens(tokensDirectory: string) {
 }
 
 type TokenSet = {
-    id: string;
-    name: string;
-    selectedTokenSets: { [key: string]: "enabled" };
-};
+    id: string
+    name: string
+    selectedTokenSets: { [key: string]: "enabled" }
+}
 
-function buildTokenSetOrder(colorSchemes: ColorScheme[]): { tokenSetOrder: string[] } {
-    const tokenSetOrder: string[] = colorSchemes.map(
-        (scheme) => scheme.name.toLowerCase().replace(/\s+/g, "_")
-    );
-    return { tokenSetOrder };
+function buildTokenSetOrder(colorSchemes: ColorScheme[]): {
+    tokenSetOrder: string[]
+} {
+    const tokenSetOrder: string[] = colorSchemes.map((scheme) =>
+        scheme.name.toLowerCase().replace(/\s+/g, "_")
+    )
+    return { tokenSetOrder }
 }
 
 function buildThemesIndex(colorSchemes: ColorScheme[]): TokenSet[] {
     const themesIndex: TokenSet[] = colorSchemes.map((scheme, index) => {
         const id = `${scheme.isLight ? "light" : "dark"}_${scheme.name
             .toLowerCase()
-            .replace(/\s+/g, "_")}_${index}`;
-        const selectedTokenSets: { [key: string]: "enabled" } = {};
-        const tokenSet = scheme.name.toLowerCase().replace(/\s+/g, "_");
-        selectedTokenSets[tokenSet] = "enabled";
+            .replace(/\s+/g, "_")}_${index}`
+        const selectedTokenSets: { [key: string]: "enabled" } = {}
+        const tokenSet = scheme.name.toLowerCase().replace(/\s+/g, "_")
+        selectedTokenSets[tokenSet] = "enabled"
 
         return {
             id,
             name: `${scheme.name} - ${scheme.isLight ? "Light" : "Dark"}`,
             selectedTokenSets,
-        };
-    });
+        }
+    })
 
-    return themesIndex;
+    return themesIndex
 }
 
 function writeTokens(colorSchemes: ColorScheme[], tokensDirectory: string) {
-    clearTokens(tokensDirectory);
+    clearTokens(tokensDirectory)
 
     for (const colorScheme of colorSchemes) {
-        const fileName = slugify(colorScheme.name) + ".json";
-        const tokens = colorSchemeTokens(colorScheme);
-        const tokensJSON = JSON.stringify(tokens, null, 2);
-        const outPath = path.join(tokensDirectory, fileName);
-        fs.writeFileSync(outPath, tokensJSON, { mode: 0o644 });
-        console.log(`- ${outPath} created`);
+        const fileName = slugify(colorScheme.name) + ".json"
+        const tokens = colorSchemeTokens(colorScheme)
+        const tokensJSON = JSON.stringify(tokens, null, 2)
+        const outPath = path.join(tokensDirectory, fileName)
+        fs.writeFileSync(outPath, tokensJSON, { mode: 0o644 })
+        console.log(`- ${outPath} created`)
     }
 
-    const themeIndexData = buildThemesIndex(colorSchemes);
+    const themeIndexData = buildThemesIndex(colorSchemes)
 
-    const themesJSON = JSON.stringify(themeIndexData, null, 2);
-    fs.writeFileSync(TOKENS_FILE, themesJSON, { mode: 0o644 });
-    console.log(`- ${TOKENS_FILE} created`);
+    const themesJSON = JSON.stringify(themeIndexData, null, 2)
+    fs.writeFileSync(TOKENS_FILE, themesJSON, { mode: 0o644 })
+    console.log(`- ${TOKENS_FILE} created`)
 
-    const tokenSetOrderData = buildTokenSetOrder(colorSchemes);
+    const tokenSetOrderData = buildTokenSetOrder(colorSchemes)
 
-    const metadataJSON = JSON.stringify(tokenSetOrderData, null, 2);
-    fs.writeFileSync(METADATA_FILE, metadataJSON, { mode: 0o644 });
-    console.log(`- ${METADATA_FILE} created`);
+    const metadataJSON = JSON.stringify(tokenSetOrderData, null, 2)
+    fs.writeFileSync(METADATA_FILE, metadataJSON, { mode: 0o644 })
+    console.log(`- ${METADATA_FILE} created`)
 }
 
 const colorSchemes: ColorScheme[] = themes.map((theme) =>
     createColorScheme(theme)
-);
+)
 
-writeTokens(colorSchemes, TOKENS_DIRECTORY);
+writeTokens(colorSchemes, TOKENS_DIRECTORY)

styles/src/element/index.ts 🔗

@@ -0,0 +1,4 @@
+import { interactive } from "./interactive"
+import { toggleable } from "./toggle"
+
+export { interactive, toggleable }

styles/src/element/interactive.test.ts 🔗

@@ -0,0 +1,56 @@
+import {
+    NOT_ENOUGH_STATES_ERROR,
+    NO_DEFAULT_OR_BASE_ERROR,
+    interactive,
+} from "./interactive"
+import { describe, it, expect } from "vitest"
+
+describe("interactive", () => {
+    it("creates an Interactive<Element> with base properties and states", () => {
+        const result = interactive({
+            base: { fontSize: 10, color: "#FFFFFF" },
+            state: {
+                hovered: { color: "#EEEEEE" },
+                clicked: { color: "#CCCCCC" },
+            },
+        })
+
+        expect(result).toEqual({
+            default: { color: "#FFFFFF", fontSize: 10 },
+            hovered: { color: "#EEEEEE", fontSize: 10 },
+            clicked: { color: "#CCCCCC", fontSize: 10 },
+        })
+    })
+
+    it("creates an Interactive<Element> with no base properties", () => {
+        const result = interactive({
+            state: {
+                default: { color: "#FFFFFF", fontSize: 10 },
+                hovered: { color: "#EEEEEE" },
+                clicked: { color: "#CCCCCC" },
+            },
+        })
+
+        expect(result).toEqual({
+            default: { color: "#FFFFFF", fontSize: 10 },
+            hovered: { color: "#EEEEEE", fontSize: 10 },
+            clicked: { color: "#CCCCCC", fontSize: 10 },
+        })
+    })
+
+    it("throws error when both default and base are missing", () => {
+        const state = {
+            hovered: { color: "blue" },
+        }
+
+        expect(() => interactive({ state })).toThrow(NO_DEFAULT_OR_BASE_ERROR)
+    })
+
+    it("throws error when no other state besides default is present", () => {
+        const state = {
+            default: { fontSize: 10 },
+        }
+
+        expect(() => interactive({ state })).toThrow(NOT_ENOUGH_STATES_ERROR)
+    })
+})

styles/src/element/interactive.ts 🔗

@@ -0,0 +1,97 @@
+import merge from "ts-deepmerge"
+import { DeepPartial } from "utility-types"
+
+type InteractiveState =
+    | "default"
+    | "hovered"
+    | "clicked"
+    | "selected"
+    | "disabled"
+
+type Interactive<T> = {
+    default: T
+    hovered?: T
+    clicked?: T
+    selected?: T
+    disabled?: T
+}
+
+export const NO_DEFAULT_OR_BASE_ERROR =
+    "An interactive object must have a default state, or a base property."
+export const NOT_ENOUGH_STATES_ERROR =
+    "An interactive object must have a default and at least one other state."
+
+interface InteractiveProps<T> {
+    base?: T
+    state: Partial<Record<InteractiveState, DeepPartial<T>>>
+}
+
+/**
+ * Helper function for creating Interactive<T> objects that works with Toggle<T>-like behavior.
+ * It takes a default object to be used as the value for `default` field and fills out other fields
+ * with fields from either `base` or from the `state` object which contains values for specific states.
+ * Notably, it does not touch `hover`, `clicked`, `selected` and `disabled` states if there are no modifications for them.
+ *
+ * @param defaultObj Object to be used as the value for the `default` field.
+ * @param base Optional object containing base fields to be included in the resulting object.
+ * @param state Object containing optional modified fields to be included in the resulting object for each state.
+ * @returns Interactive<T> object with fields from `base` and `state`.
+ */
+export function interactive<T extends Object>({
+    base,
+    state,
+}: InteractiveProps<T>): Interactive<T> {
+    if (!base && !state.default) throw new Error(NO_DEFAULT_OR_BASE_ERROR)
+
+    let defaultState: T
+
+    if (state.default && base) {
+        defaultState = merge(base, state.default) as T
+    } else {
+        defaultState = base ? base : (state.default as T)
+    }
+
+    let interactiveObj: Interactive<T> = {
+        default: defaultState,
+    }
+
+    let stateCount = 0
+
+    if (state.hovered !== undefined) {
+        interactiveObj.hovered = merge(
+            interactiveObj.default,
+            state.hovered
+        ) as T
+        stateCount++
+    }
+
+    if (state.clicked !== undefined) {
+        interactiveObj.clicked = merge(
+            interactiveObj.default,
+            state.clicked
+        ) as T
+        stateCount++
+    }
+
+    if (state.selected !== undefined) {
+        interactiveObj.selected = merge(
+            interactiveObj.default,
+            state.selected
+        ) as T
+        stateCount++
+    }
+
+    if (state.disabled !== undefined) {
+        interactiveObj.disabled = merge(
+            interactiveObj.default,
+            state.disabled
+        ) as T
+        stateCount++
+    }
+
+    if (stateCount < 1) {
+        throw new Error(NOT_ENOUGH_STATES_ERROR)
+    }
+
+    return interactiveObj
+}

styles/src/element/toggle.test.ts 🔗

@@ -0,0 +1,52 @@
+import {
+    NO_ACTIVE_ERROR,
+    NO_INACTIVE_OR_BASE_ERROR,
+    toggleable,
+} from "./toggle"
+import { describe, it, expect } from "vitest"
+
+describe("toggleable", () => {
+    it("creates a Toggleable<Element> with base properties and states", () => {
+        const result = toggleable({
+            base: { background: "#000000", color: "#CCCCCC" },
+            state: {
+                active: { color: "#FFFFFF" },
+            },
+        })
+
+        expect(result).toEqual({
+            inactive: { background: "#000000", color: "#CCCCCC" },
+            active: { background: "#000000", color: "#FFFFFF" },
+        })
+    })
+
+    it("creates a Toggleable<Element> with no base properties", () => {
+        const result = toggleable({
+            state: {
+                inactive: { background: "#000000", color: "#CCCCCC" },
+                active: { background: "#000000", color: "#FFFFFF" },
+            },
+        })
+
+        expect(result).toEqual({
+            inactive: { background: "#000000", color: "#CCCCCC" },
+            active: { background: "#000000", color: "#FFFFFF" },
+        })
+    })
+
+    it("throws error when both inactive and base are missing", () => {
+        const state = {
+            active: { background: "#000000", color: "#FFFFFF" },
+        }
+
+        expect(() => toggleable({ state })).toThrow(NO_INACTIVE_OR_BASE_ERROR)
+    })
+
+    it("throws error when no active state is present", () => {
+        const state = {
+            inactive: { background: "#000000", color: "#CCCCCC" },
+        }
+
+        expect(() => toggleable({ state })).toThrow(NO_ACTIVE_ERROR)
+    })
+})

styles/src/element/toggle.ts 🔗

@@ -0,0 +1,47 @@
+import merge from "ts-deepmerge"
+import { DeepPartial } from "utility-types"
+
+type ToggleState = "inactive" | "active"
+
+type Toggleable<T> = Record<ToggleState, T>
+
+export const NO_INACTIVE_OR_BASE_ERROR =
+    "A toggleable object must have an inactive state, or a base property."
+export const NO_ACTIVE_ERROR = "A toggleable object must have an active state."
+
+interface ToggleableProps<T> {
+    base?: T
+    state: Partial<Record<ToggleState, DeepPartial<T>>>
+}
+
+/**
+ * Helper function for creating Toggleable objects.
+ * @template T The type of the object being toggled.
+ * @param props Object containing the base (inactive) state and state modifications to create the active state.
+ * @returns A Toggleable object containing both the inactive and active states.
+ * @example
+ * ```
+ * toggleable({
+ *   base: { background: "#000000", text: "#CCCCCC" },
+ *   state: { active: { text: "#CCCCCC" } },
+ * })
+ * ```
+ */
+export function toggleable<T extends object>(
+    props: ToggleableProps<T>
+): Toggleable<T> {
+    const { base, state } = props
+
+    if (!base && !state.inactive) throw new Error(NO_INACTIVE_OR_BASE_ERROR)
+    if (!state.active) throw new Error(NO_ACTIVE_ERROR)
+
+    const inactiveState = base
+        ? ((state.inactive ? merge(base, state.inactive) : base) as T)
+        : (state.inactive as T)
+
+    const toggleObj: Toggleable<T> = {
+        inactive: inactiveState,
+        active: merge(base ?? {}, state.active) as T,
+    }
+    return toggleObj
+}

styles/src/styleTree/app.ts 🔗

@@ -1,4 +1,3 @@
-import { text } from "./components"
 import contactFinder from "./contactFinder"
 import contactsPopover from "./contactsPopover"
 import commandPalette from "./commandPalette"

styles/src/styleTree/assistant.ts 🔗

@@ -1,6 +1,7 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { text, border, background, foreground } from "./components"
 import editor from "./editor"
+import { interactive } from "../element"
 
 export default function assistant(colorScheme: ColorScheme) {
     const layer = colorScheme.highest
@@ -15,13 +16,28 @@ export default function assistant(colorScheme: ColorScheme) {
             background: editor(colorScheme).background,
         },
         userSender: {
-            ...text(layer, "sans", "default", { size: "sm", weight: "bold" }),
+            default: {
+                ...text(layer, "sans", "default", {
+                    size: "sm",
+                    weight: "bold",
+                }),
+            },
         },
         assistantSender: {
-            ...text(layer, "sans", "accent", { size: "sm", weight: "bold" }),
+            default: {
+                ...text(layer, "sans", "accent", {
+                    size: "sm",
+                    weight: "bold",
+                }),
+            },
         },
         systemSender: {
-            ...text(layer, "sans", "variant", { size: "sm", weight: "bold" }),
+            default: {
+                ...text(layer, "sans", "variant", {
+                    size: "sm",
+                    weight: "bold",
+                }),
+            },
         },
         sentAt: {
             margin: { top: 2, left: 8 },
@@ -30,16 +46,20 @@ export default function assistant(colorScheme: ColorScheme) {
         modelInfoContainer: {
             margin: { right: 16, top: 4 },
         },
-        model: {
-            background: background(layer, "on"),
-            border: border(layer, "on", { overlay: true }),
-            padding: 4,
-            cornerRadius: 4,
-            ...text(layer, "sans", "default", { size: "xs" }),
-            hover: {
-                background: background(layer, "on", "hovered"),
+        model: interactive({
+            base: {
+                background: background(layer, "on"),
+                border: border(layer, "on", { overlay: true }),
+                padding: 4,
+                cornerRadius: 4,
+                ...text(layer, "sans", "default", { size: "xs" }),
             },
-        },
+            state: {
+                hovered: {
+                    background: background(layer, "on", "hovered"),
+                },
+            },
+        }),
         remainingTokens: {
             background: background(layer, "on"),
             border: border(layer, "on", { overlay: true }),

styles/src/styleTree/commandPalette.ts 🔗

@@ -1,12 +1,13 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { withOpacity } from "../theme/color"
 import { text, background } from "./components"
+import { toggleable } from "../element"
 
 export default function commandPalette(colorScheme: ColorScheme) {
     let layer = colorScheme.highest
-    return {
-        keystrokeSpacing: 8,
-        key: {
+
+    const key = toggleable({
+        base: {
             text: text(layer, "mono", "variant", "default", { size: "xs" }),
             cornerRadius: 2,
             background: background(layer, "on"),
@@ -21,10 +22,21 @@ export default function commandPalette(colorScheme: ColorScheme) {
                 bottom: 1,
                 left: 2,
             },
+        },
+        state: {
             active: {
                 text: text(layer, "mono", "on", "default", { size: "xs" }),
                 background: withOpacity(background(layer, "on"), 0.2),
             },
         },
+    })
+
+    return {
+        keystrokeSpacing: 8,
+        // TODO: This should be a Toggle<ContainedText> on the rust side so we don't have to do this
+        key: {
+            inactive: { ...key.inactive },
+            active: key.active,
+        },
     }
 }

styles/src/styleTree/components.ts 🔗

@@ -85,7 +85,7 @@ export function foreground(
     return getStyle(layer, styleSetOrStyles, style).foreground
 }
 
-interface Text {
+interface Text extends Object {
     family: keyof typeof fontFamilies
     color: string
     size: number

styles/src/styleTree/contactList.ts 🔗

@@ -1,6 +1,6 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { background, border, borderColor, foreground, text } from "./components"
-
+import { interactive, toggleable } from "../element"
 export default function contactsPanel(colorScheme: ColorScheme) {
     const nameMargin = 8
     const sidePadding = 12
@@ -71,47 +71,85 @@ export default function contactsPanel(colorScheme: ColorScheme) {
         },
         rowHeight: 28,
         sectionIconSize: 8,
-        headerRow: {
-            ...text(layer, "mono", { size: "sm" }),
-            margin: { top: 14 },
-            padding: {
-                left: sidePadding,
-                right: sidePadding,
-            },
-            active: {
-                ...text(layer, "mono", "active", { size: "sm" }),
-                background: background(layer, "active"),
-            },
-        },
-        leaveCall: {
-            background: background(layer),
-            border: border(layer),
-            cornerRadius: 6,
-            margin: {
-                top: 1,
-            },
-            padding: {
-                top: 1,
-                bottom: 1,
-                left: 7,
-                right: 7,
-            },
-            ...text(layer, "sans", "variant", { size: "xs" }),
-            hover: {
-                ...text(layer, "sans", "hovered", { size: "xs" }),
-                background: background(layer, "hovered"),
-                border: border(layer, "hovered"),
-            },
-        },
+        headerRow: toggleable({
+            base: interactive({
+                base: {
+                    ...text(layer, "mono", { size: "sm" }),
+                    margin: { top: 14 },
+                    padding: {
+                        left: sidePadding,
+                        right: sidePadding,
+                    },
+                    background: background(layer, "default"), // posiewic: breaking change
+                },
+                state: {
+                    hovered: {
+                        background: background(layer, "hovered"),
+                    },
+                    clicked: {
+                        background: background(layer, "pressed"),
+                    },
+                }, // hack, we want headerRow to be interactive for whatever reason. It probably shouldn't be interactive in the first place.
+            }),
+            state: {
+                active: {
+                    default: {
+                        ...text(layer, "mono", "active", { size: "sm" }),
+                        background: background(layer, "active"),
+                    },
+                    hovered: {
+                        background: background(layer, "hovered"),
+                    },
+                    clicked: {
+                        background: background(layer, "pressed"),
+                    },
+                },
+            },
+        }),
+        leaveCall: interactive({
+            base: {
+                background: background(layer),
+                border: border(layer),
+                cornerRadius: 6,
+                margin: {
+                    top: 1,
+                },
+                padding: {
+                    top: 1,
+                    bottom: 1,
+                    left: 7,
+                    right: 7,
+                },
+                ...text(layer, "sans", "variant", { size: "xs" }),
+            },
+            state: {
+                hovered: {
+                    ...text(layer, "sans", "hovered", { size: "xs" }),
+                    background: background(layer, "hovered"),
+                    border: border(layer, "hovered"),
+                },
+            },
+        }),
         contactRow: {
-            padding: {
-                left: sidePadding,
-                right: sidePadding,
+            inactive: {
+                default: {
+                    padding: {
+                        left: sidePadding,
+                        right: sidePadding,
+                    },
+                },
             },
             active: {
-                background: background(layer, "active"),
+                default: {
+                    background: background(layer, "active"),
+                    padding: {
+                        left: sidePadding,
+                        right: sidePadding,
+                    },
+                },
             },
         },
+
         contactAvatar: {
             cornerRadius: 10,
             width: 18,
@@ -135,12 +173,14 @@ export default function contactsPanel(colorScheme: ColorScheme) {
             },
         },
         contactButtonSpacing: nameMargin,
-        contactButton: {
-            ...contactButton,
-            hover: {
-                background: background(layer, "hovered"),
-            },
-        },
+        contactButton: interactive({
+            base: { ...contactButton },
+            state: {
+                hovered: {
+                    background: background(layer, "hovered"),
+                },
+            },
+        }),
         disabledButton: {
             ...contactButton,
             background: background(layer, "on"),
@@ -149,34 +189,52 @@ export default function contactsPanel(colorScheme: ColorScheme) {
         callingIndicator: {
             ...text(layer, "mono", "variant", { size: "xs" }),
         },
-        treeBranch: {
-            color: borderColor(layer),
-            width: 1,
-            hover: {
-                color: borderColor(layer),
-            },
-            active: {
-                color: borderColor(layer),
-            },
-        },
-        projectRow: {
-            ...projectRow,
-            background: background(layer),
-            icon: {
-                margin: { left: nameMargin },
-                color: foreground(layer, "variant"),
-                width: 12,
-            },
-            name: {
-                ...projectRow.name,
-                ...text(layer, "mono", { size: "sm" }),
-            },
-            hover: {
-                background: background(layer, "hovered"),
-            },
-            active: {
-                background: background(layer, "active"),
+        treeBranch: toggleable({
+            base: interactive({
+                base: {
+                    color: borderColor(layer),
+                    width: 1,
+                },
+                state: {
+                    hovered: {
+                        color: borderColor(layer),
+                    },
+                },
+            }),
+            state: {
+                active: {
+                    default: {
+                        color: borderColor(layer),
+                    },
+                },
+            },
+        }),
+        projectRow: toggleable({
+            base: interactive({
+                base: {
+                    ...projectRow,
+                    background: background(layer),
+                    icon: {
+                        margin: { left: nameMargin },
+                        color: foreground(layer, "variant"),
+                        width: 12,
+                    },
+                    name: {
+                        ...projectRow.name,
+                        ...text(layer, "mono", { size: "sm" }),
+                    },
+                },
+                state: {
+                    hovered: {
+                        background: background(layer, "hovered"),
+                    },
+                },
+            }),
+            state: {
+                active: {
+                    default: { background: background(layer, "active") },
+                },
             },
-        },
+        }),
     }
 }

styles/src/styleTree/contactNotification.ts 🔗

@@ -1,6 +1,6 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { background, foreground, text } from "./components"
-
+import { interactive } from "../element"
 const avatarSize = 12
 const headerPadding = 8
 
@@ -21,24 +21,32 @@ export default function contactNotification(colorScheme: ColorScheme): Object {
             ...text(layer, "sans", { size: "xs" }),
             margin: { left: avatarSize + headerPadding, top: 6, bottom: 6 },
         },
-        button: {
-            ...text(layer, "sans", "on", { size: "xs" }),
-            background: background(layer, "on"),
-            padding: 4,
-            cornerRadius: 6,
-            margin: { left: 6 },
-            hover: {
-                background: background(layer, "on", "hovered"),
+        button: interactive({
+            base: {
+                ...text(layer, "sans", "on", { size: "xs" }),
+                background: background(layer, "on"),
+                padding: 4,
+                cornerRadius: 6,
+                margin: { left: 6 },
             },
-        },
+
+            state: {
+                hovered: {
+                    background: background(layer, "on", "hovered"),
+                },
+            },
+        }),
+
         dismissButton: {
-            color: foreground(layer, "variant"),
-            iconWidth: 8,
-            iconHeight: 8,
-            buttonWidth: 8,
-            buttonHeight: 8,
-            hover: {
-                color: foreground(layer, "hovered"),
+            default: {
+                color: foreground(layer, "variant"),
+                iconWidth: 8,
+                iconHeight: 8,
+                buttonWidth: 8,
+                buttonHeight: 8,
+                hover: {
+                    color: foreground(layer, "hovered"),
+                },
             },
         },
     }

styles/src/styleTree/contextMenu.ts 🔗

@@ -1,5 +1,6 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { background, border, borderColor, text } from "./components"
+import { interactive, toggleable } from "../element"
 
 export default function contextMenu(colorScheme: ColorScheme) {
     let layer = colorScheme.middle
@@ -10,37 +11,54 @@ export default function contextMenu(colorScheme: ColorScheme) {
         shadow: colorScheme.popoverShadow,
         border: border(layer),
         keystrokeMargin: 30,
-        item: {
-            iconSpacing: 8,
-            iconWidth: 14,
-            padding: { left: 6, right: 6, top: 2, bottom: 2 },
-            cornerRadius: 6,
-            label: text(layer, "sans", { size: "sm" }),
-            keystroke: {
-                ...text(layer, "sans", "variant", {
-                    size: "sm",
-                    weight: "bold",
-                }),
-                padding: { left: 3, right: 3 },
-            },
-            hover: {
-                background: background(layer, "hovered"),
-                label: text(layer, "sans", "hovered", { size: "sm" }),
-                keystroke: {
-                    ...text(layer, "sans", "hovered", {
-                        size: "sm",
-                        weight: "bold",
-                    }),
-                    padding: { left: 3, right: 3 },
+        item: toggleable({
+            base: interactive({
+                base: {
+                    iconSpacing: 8,
+                    iconWidth: 14,
+                    padding: { left: 6, right: 6, top: 2, bottom: 2 },
+                    cornerRadius: 6,
+                    label: text(layer, "sans", { size: "sm" }),
+                    keystroke: {
+                        ...text(layer, "sans", "variant", {
+                            size: "sm",
+                            weight: "bold",
+                        }),
+                        padding: { left: 3, right: 3 },
+                    },
+                },
+                state: {
+                    hovered: {
+                        background: background(layer, "hovered"),
+                        label: text(layer, "sans", "hovered", { size: "sm" }),
+                        keystroke: {
+                            ...text(layer, "sans", "hovered", {
+                                size: "sm",
+                                weight: "bold",
+                            }),
+                            padding: { left: 3, right: 3 },
+                        },
+                    },
+                    clicked: {
+                        background: background(layer, "pressed"),
+                    },
+                },
+            }),
+            state: {
+                active: {
+                    default: {
+                        background: background(layer, "active"),
+                    },
+                    hovered: {
+                        background: background(layer, "hovered"),
+                    },
+                    clicked: {
+                        background: background(layer, "pressed"),
+                    },
                 },
             },
-            active: {
-                background: background(layer, "active"),
-            },
-            activeHover: {
-                background: background(layer, "active"),
-            },
-        },
+        }),
+
         separator: {
             background: borderColor(layer),
             margin: { top: 2, bottom: 2 },

styles/src/styleTree/copilot.ts 🔗

@@ -1,60 +1,69 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { background, border, foreground, svg, text } from "./components"
-
+import { interactive } from "../element"
 export default function copilot(colorScheme: ColorScheme) {
     let layer = colorScheme.middle
 
     let content_width = 264
 
-    let ctaButton = {
+    let ctaButton =
         // Copied from welcome screen. FIXME: Move this into a ZDS component
-        background: background(layer),
-        border: border(layer, "default"),
-        cornerRadius: 4,
-        margin: {
-            top: 4,
-            bottom: 4,
-            left: 8,
-            right: 8,
-        },
-        padding: {
-            top: 3,
-            bottom: 3,
-            left: 7,
-            right: 7,
-        },
-        ...text(layer, "sans", "default", { size: "sm" }),
-        hover: {
-            ...text(layer, "sans", "default", { size: "sm" }),
-            background: background(layer, "hovered"),
-            border: border(layer, "active"),
-        },
-    }
+        interactive({
+            base: {
+                background: background(layer),
+                border: border(layer, "default"),
+                cornerRadius: 4,
+                margin: {
+                    top: 4,
+                    bottom: 4,
+                    left: 8,
+                    right: 8,
+                },
+                padding: {
+                    top: 3,
+                    bottom: 3,
+                    left: 7,
+                    right: 7,
+                },
+                ...text(layer, "sans", "default", { size: "sm" }),
+            },
+            state: {
+                hovered: {
+                    ...text(layer, "sans", "default", { size: "sm" }),
+                    background: background(layer, "hovered"),
+                    border: border(layer, "active"),
+                },
+            },
+        })
 
     return {
-        outLinkIcon: {
-            icon: svg(
-                foreground(layer, "variant"),
-                "icons/link_out_12.svg",
-                12,
-                12
-            ),
-            container: {
-                cornerRadius: 6,
-                padding: { left: 6 },
-            },
-            hover: {
+        outLinkIcon: interactive({
+            base: {
                 icon: svg(
-                    foreground(layer, "hovered"),
+                    foreground(layer, "variant"),
                     "icons/link_out_12.svg",
                     12,
                     12
                 ),
+                container: {
+                    cornerRadius: 6,
+                    padding: { left: 6 },
+                },
             },
-        },
+            state: {
+                hovered: {
+                    icon: {
+                        color: foreground(layer, "hovered"),
+                    },
+                },
+            },
+        }),
+
         modal: {
             titleText: {
-                ...text(layer, "sans", { size: "xs", weight: "bold" }),
+                default: {
+                    ...text(layer, "sans", { size: "xs", weight: "bold" }),
+                },
             },
             titlebar: {
                 background: background(colorScheme.lowest),
@@ -75,42 +84,46 @@ export default function copilot(colorScheme: ColorScheme) {
                     bottom: 8,
                 },
             },
-            closeIcon: {
-                icon: svg(
-                    foreground(layer, "variant"),
-                    "icons/x_mark_8.svg",
-                    8,
-                    8
-                ),
-                container: {
-                    cornerRadius: 2,
-                    padding: {
-                        top: 4,
-                        bottom: 4,
-                        left: 4,
-                        right: 4,
-                    },
-                    margin: {
-                        right: 0,
-                    },
-                },
-                hover: {
+            closeIcon: interactive({
+                base: {
                     icon: svg(
-                        foreground(layer, "on"),
+                        foreground(layer, "variant"),
                         "icons/x_mark_8.svg",
                         8,
                         8
                     ),
+                    container: {
+                        cornerRadius: 2,
+                        padding: {
+                            top: 4,
+                            bottom: 4,
+                            left: 4,
+                            right: 4,
+                        },
+                        margin: {
+                            right: 0,
+                        },
+                    },
                 },
-                clicked: {
-                    icon: svg(
-                        foreground(layer, "base"),
-                        "icons/x_mark_8.svg",
-                        8,
-                        8
-                    ),
+                state: {
+                    hovered: {
+                        icon: svg(
+                            foreground(layer, "on"),
+                            "icons/x_mark_8.svg",
+                            8,
+                            8
+                        ),
+                    },
+                    clicked: {
+                        icon: svg(
+                            foreground(layer, "base"),
+                            "icons/x_mark_8.svg",
+                            8,
+                            8
+                        ),
+                    },
                 },
-            },
+            }),
             dimensions: {
                 width: 280,
                 height: 280,
@@ -185,28 +198,32 @@ export default function copilot(colorScheme: ColorScheme) {
                         },
                     },
                     right: (content_width * 1) / 3,
-                    rightContainer: {
-                        border: border(colorScheme.lowest, "inverted", {
-                            bottom: false,
-                            right: false,
-                            top: false,
-                            left: true,
-                        }),
-                        padding: {
-                            top: 3,
-                            bottom: 5,
-                            left: 8,
-                            right: 0,
-                        },
-                        hover: {
-                            border: border(layer, "active", {
+                    rightContainer: interactive({
+                        base: {
+                            border: border(colorScheme.lowest, "inverted", {
                                 bottom: false,
                                 right: false,
                                 top: false,
                                 left: true,
                             }),
+                            padding: {
+                                top: 3,
+                                bottom: 5,
+                                left: 8,
+                                right: 0,
+                            },
                         },
-                    },
+                        state: {
+                            hovered: {
+                                border: border(layer, "active", {
+                                    bottom: false,
+                                    right: false,
+                                    top: false,
+                                    left: true,
+                                }),
+                            },
+                        },
+                    }),
                 },
             },
 

styles/src/styleTree/editor.ts 🔗

@@ -4,6 +4,7 @@ import { background, border, borderColor, foreground, text } from "./components"
 import hoverPopover from "./hoverPopover"
 
 import { buildSyntax } from "../theme/syntax"
+import { interactive, toggleable } from "../element"
 
 export default function editor(colorScheme: ColorScheme) {
     const { isLight } = colorScheme
@@ -48,46 +49,76 @@ export default function editor(colorScheme: ColorScheme) {
         // Inline autocomplete suggestions, Co-pilot suggestions, etc.
         suggestion: syntax.predictive,
         codeActions: {
-            indicator: {
-                color: foreground(layer, "variant"),
-
-                clicked: {
-                    color: foreground(layer, "base"),
-                },
-                hover: {
-                    color: foreground(layer, "on"),
-                },
-                active: {
-                    color: foreground(layer, "on"),
+            indicator: toggleable({
+                base: interactive({
+                    base: {
+                        color: foreground(layer, "variant"),
+                    },
+                    state: {
+                        hovered: {
+                            color: foreground(layer, "variant", "hovered"),
+                        },
+                        clicked: {
+                            color: foreground(layer, "variant", "pressed"),
+                        },
+                    },
+                }),
+                state: {
+                    active: {
+                        default: {
+                            color: foreground(layer, "accent"),
+                        },
+                        hovered: {
+                            color: foreground(layer, "accent", "hovered"),
+                        },
+                        clicked: {
+                            color: foreground(layer, "accent", "pressed"),
+                        },
+                    },
                 },
-            },
+            }),
+
             verticalScale: 0.55,
         },
         folds: {
             iconMarginScale: 2.5,
             foldedIcon: "icons/chevron_right_8.svg",
             foldableIcon: "icons/chevron_down_8.svg",
-            indicator: {
-                color: foreground(layer, "variant"),
-
-                clicked: {
-                    color: foreground(layer, "base"),
-                },
-                hover: {
-                    color: foreground(layer, "on"),
-                },
-                active: {
-                    color: foreground(layer, "on"),
+            indicator: toggleable({
+                base: interactive({
+                    base: {
+                        color: foreground(layer, "variant"),
+                    },
+                    state: {
+                        hovered: {
+                            color: foreground(layer, "on"),
+                        },
+                        clicked: {
+                            color: foreground(layer, "base"),
+                        },
+                    },
+                }),
+                state: {
+                    active: {
+                        default: {
+                            color: foreground(layer, "default"),
+                        },
+                        hovered: {
+                            color: foreground(layer, "variant"),
+                        },
+                    },
                 },
-            },
+            }),
             ellipses: {
                 textColor: colorScheme.ramps.neutral(0.71).hex(),
                 cornerRadiusFactor: 0.15,
                 background: {
                     // Copied from hover_popover highlight
-                    color: colorScheme.ramps.neutral(0.5).alpha(0.0).hex(),
+                    default: {
+                        color: colorScheme.ramps.neutral(0.5).alpha(0.0).hex(),
+                    },
 
-                    hover: {
+                    hovered: {
                         color: colorScheme.ramps.neutral(0.5).alpha(0.5).hex(),
                     },
 
@@ -223,21 +254,26 @@ export default function editor(colorScheme: ColorScheme) {
             color: syntax.linkUri.color,
             underline: syntax.linkUri.underline,
         },
-        jumpIcon: {
-            color: foreground(layer, "on"),
-            iconWidth: 20,
-            buttonWidth: 20,
-            cornerRadius: 6,
-            padding: {
-                top: 6,
-                bottom: 6,
-                left: 6,
-                right: 6,
+        jumpIcon: interactive({
+            base: {
+                color: foreground(layer, "on"),
+                iconWidth: 20,
+                buttonWidth: 20,
+                cornerRadius: 6,
+                padding: {
+                    top: 6,
+                    bottom: 6,
+                    left: 6,
+                    right: 6,
+                },
             },
-            hover: {
-                background: background(layer, "on", "hovered"),
+            state: {
+                hovered: {
+                    background: background(layer, "on", "hovered"),
+                },
             },
-        },
+        }),
+
         scrollbar: {
             width: 12,
             minHeightFactor: 1.0,

styles/src/styleTree/feedback.ts 🔗

@@ -1,35 +1,40 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { background, border, text } from "./components"
+import { interactive } from "../element"
 
 export default function feedback(colorScheme: ColorScheme) {
     let layer = colorScheme.highest
 
     return {
-        submit_button: {
-            ...text(layer, "mono", "on"),
-            background: background(layer, "on"),
-            cornerRadius: 6,
-            border: border(layer, "on"),
-            margin: {
-                right: 4,
+        submit_button: interactive({
+            base: {
+                ...text(layer, "mono", "on"),
+                background: background(layer, "on"),
+                cornerRadius: 6,
+                border: border(layer, "on"),
+                margin: {
+                    right: 4,
+                },
+                padding: {
+                    bottom: 2,
+                    left: 10,
+                    right: 10,
+                    top: 2,
+                },
             },
-            padding: {
-                bottom: 2,
-                left: 10,
-                right: 10,
-                top: 2,
+            state: {
+                clicked: {
+                    ...text(layer, "mono", "on", "pressed"),
+                    background: background(layer, "on", "pressed"),
+                    border: border(layer, "on", "pressed"),
+                },
+                hovered: {
+                    ...text(layer, "mono", "on", "hovered"),
+                    background: background(layer, "on", "hovered"),
+                    border: border(layer, "on", "hovered"),
+                },
             },
-            clicked: {
-                ...text(layer, "mono", "on", "pressed"),
-                background: background(layer, "on", "pressed"),
-                border: border(layer, "on", "pressed"),
-            },
-            hover: {
-                ...text(layer, "mono", "on", "hovered"),
-                background: background(layer, "on", "hovered"),
-                border: border(layer, "on", "hovered"),
-            },
-        },
+        }),
         button_margin: 8,
         info_text_default: text(layer, "sans", "default", { size: "xs" }),
         link_text_default: text(layer, "sans", "default", {

styles/src/styleTree/picker.ts 🔗

@@ -1,6 +1,7 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { withOpacity } from "../theme/color"
 import { background, border, text } from "./components"
+import { interactive, toggleable } from "../element"
 
 export default function picker(colorScheme: ColorScheme): any {
     let layer = colorScheme.lowest
@@ -38,35 +39,65 @@ export default function picker(colorScheme: ColorScheme): any {
             ...container,
             padding: {},
         },
-        item: {
-            padding: {
-                bottom: 4,
-                left: 12,
-                right: 12,
-                top: 4,
-            },
-            margin: {
-                top: 1,
-                left: 4,
-                right: 4,
-            },
-            cornerRadius: 8,
-            text: text(layer, "sans", "variant"),
-            highlightText: text(layer, "sans", "accent", { weight: "bold" }),
-            active: {
-                background: withOpacity(
-                    background(layer, "base", "active"),
-                    0.5
-                ),
-                text: text(layer, "sans", "base", "active"),
-                highlightText: text(layer, "sans", "accent", {
-                    weight: "bold",
-                }),
+        item: toggleable({
+            base: interactive({
+                base: {
+                    padding: {
+                        bottom: 4,
+                        left: 12,
+                        right: 12,
+                        top: 4,
+                    },
+                    margin: {
+                        top: 1,
+                        left: 4,
+                        right: 4,
+                    },
+                    cornerRadius: 8,
+                    text: text(layer, "sans", "variant"),
+                    highlightText: text(layer, "sans", "accent", {
+                        weight: "bold",
+                    }),
+                },
+                state: {
+                    hovered: {
+                        background: withOpacity(
+                            background(layer, "hovered"),
+                            0.5
+                        ),
+                    },
+                    clicked: {
+                        background: withOpacity(
+                            background(layer, "pressed"),
+                            0.5
+                        ),
+                    },
+                },
+            }),
+            state: {
+                active: {
+                    default: {
+                        background: withOpacity(
+                            background(layer, "base", "active"),
+                            0.5
+                        ),
+                    },
+                    hovered: {
+                        background: withOpacity(
+                            background(layer, "hovered"),
+                            0.5
+                        ),
+                    },
+                    clicked: {
+                        background: withOpacity(
+                            background(layer, "pressed"),
+                            0.5
+                        ),
+                    },
+                },
             },
-            hover: {
-                background: withOpacity(background(layer, "hovered"), 0.5),
-            },
-        },
+        }),
+
         inputEditor,
         emptyInputEditor,
         noMatches: {

styles/src/styleTree/projectPanel.ts 🔗

@@ -1,7 +1,7 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { withOpacity } from "../theme/color"
 import { background, border, foreground, text } from "./components"
-
+import { interactive, toggleable } from "../element"
 export default function projectPanel(colorScheme: ColorScheme) {
     const { isLight } = colorScheme
 
@@ -28,48 +28,79 @@ export default function projectPanel(colorScheme: ColorScheme) {
         },
     }
 
-    let entry = {
-        ...baseEntry,
-        text: text(layer, "mono", "variant", { size: "sm" }),
-        hover: {
-            background: background(layer, "variant", "hovered"),
+    const default_entry = interactive({
+        base: {
+            ...baseEntry,
+            text: text(layer, "mono", "variant", { size: "sm" }),
+            status,
         },
-        active: {
-            background: colorScheme.isLight
-                ? withOpacity(background(layer, "active"), 0.5)
-                : background(layer, "active"),
-            text: text(layer, "mono", "active", { size: "sm" }),
+        state: {
+            default: {
+                background: background(layer),
+            },
+            hovered: {
+                background: background(layer, "variant", "hovered"),
+            },
+            clicked: {
+                background: background(layer, "variant", "pressed"),
+            },
         },
-        activeHover: {
-            background: background(layer, "active"),
-            text: text(layer, "mono", "active", { size: "sm" }),
+    })
+
+    let entry = toggleable({
+        base: default_entry,
+        state: {
+            active: interactive({
+                base: {
+                    ...default_entry,
+                },
+                state: {
+                    default: {
+                        background: background(colorScheme.lowest),
+                    },
+                    hovered: {
+                        background: background(colorScheme.lowest, "hovered"),
+                    },
+                    clicked: {
+                        background: background(colorScheme.lowest, "pressed"),
+                    },
+                },
+            }),
         },
-        status,
-    }
+    })
 
     return {
-        openProjectButton: {
-            background: background(layer),
-            border: border(layer, "active"),
-            cornerRadius: 4,
-            margin: {
-                top: 16,
-                left: 16,
-                right: 16,
-            },
-            padding: {
-                top: 3,
-                bottom: 3,
-                left: 7,
-                right: 7,
-            },
-            ...text(layer, "sans", "default", { size: "sm" }),
-            hover: {
-                ...text(layer, "sans", "default", { size: "sm" }),
-                background: background(layer, "hovered"),
+        openProjectButton: interactive({
+            base: {
+                background: background(layer),
                 border: border(layer, "active"),
+                cornerRadius: 4,
+                margin: {
+                    top: 16,
+                    left: 16,
+                    right: 16,
+                },
+                padding: {
+                    top: 3,
+                    bottom: 3,
+                    left: 7,
+                    right: 7,
+                },
+                ...text(layer, "sans", "default", { size: "sm" }),
             },
-        },
+            state: {
+                hovered: {
+                    ...text(layer, "sans", "default", { size: "sm" }),
+                    background: background(layer, "hovered"),
+                    border: border(layer, "active"),
+                },
+                clicked: {
+                    ...text(layer, "sans", "default", { size: "sm" }),
+                    background: background(layer, "pressed"),
+                    border: border(layer, "active"),
+                },
+            },
+        }),
         background: background(layer),
         padding: { left: 6, right: 6, top: 0, bottom: 6 },
         indentWidth: 12,
@@ -94,8 +125,12 @@ export default function projectPanel(colorScheme: ColorScheme) {
             ...entry,
             text: text(layer, "mono", "disabled"),
             active: {
-                background: background(layer, "active"),
-                text: text(layer, "mono", "disabled", { size: "sm" }),
+                ...entry.active,
+                default: {
+                    ...entry.active.default,
+                    background: background(layer, "active"),
+                    text: text(layer, "mono", "disabled", { size: "sm" }),
+                },
             },
         },
         filenameEditor: {

styles/src/styleTree/search.ts 🔗

@@ -1,6 +1,7 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { withOpacity } from "../theme/color"
 import { background, border, foreground, text } from "./components"
+import { interactive, toggleable } from "../element"
 
 export default function search(colorScheme: ColorScheme) {
     let layer = colorScheme.highest
@@ -35,36 +36,50 @@ export default function search(colorScheme: ColorScheme) {
     return {
         // TODO: Add an activeMatchBackground on the rust side to differentiate between active and inactive
         matchBackground: withOpacity(foreground(layer, "accent"), 0.4),
-        optionButton: {
-            ...text(layer, "mono", "on"),
-            background: background(layer, "on"),
-            cornerRadius: 6,
-            border: border(layer, "on"),
-            margin: {
-                right: 4,
-            },
-            padding: {
-                bottom: 2,
-                left: 10,
-                right: 10,
-                top: 2,
-            },
-            active: {
-                ...text(layer, "mono", "on", "inverted"),
-                background: background(layer, "on", "inverted"),
-                border: border(layer, "on", "inverted"),
-            },
-            clicked: {
-                ...text(layer, "mono", "on", "pressed"),
-                background: background(layer, "on", "pressed"),
-                border: border(layer, "on", "pressed"),
+        optionButton: toggleable({
+            base: interactive({
+                base: {
+                    ...text(layer, "mono", "on"),
+                    background: background(layer, "on"),
+                    cornerRadius: 6,
+                    border: border(layer, "on"),
+                    margin: {
+                        right: 4,
+                    },
+                    padding: {
+                        bottom: 2,
+                        left: 10,
+                        right: 10,
+                        top: 2,
+                    },
+                },
+                state: {
+                    hovered: {
+                        ...text(layer, "mono", "on", "hovered"),
+                        background: background(layer, "on", "hovered"),
+                        border: border(layer, "on", "hovered"),
+                    },
+                    clicked: {
+                        ...text(layer, "mono", "on", "pressed"),
+                        background: background(layer, "on", "pressed"),
+                        border: border(layer, "on", "pressed"),
+                    },
+                },
+            }),
+            state: {
+                active: {
+                    default: {
+                        ...text(layer, "mono", "accent"),
+                    },
+                    hovered: {
+                        ...text(layer, "mono", "accent", "hovered"),
+                    },
+                    clicked: {
+                        ...text(layer, "mono", "accent", "pressed"),
+                    },
+                },
             },
-            hover: {
-                ...text(layer, "mono", "on", "hovered"),
-                background: background(layer, "on", "hovered"),
-                border: border(layer, "on", "hovered"),
-            },
-        },
+        }),
         editor,
         invalidEditor: {
             ...editor,
@@ -97,17 +112,24 @@ export default function search(colorScheme: ColorScheme) {
             ...text(layer, "mono", "on"),
             size: 18,
         },
-        dismissButton: {
-            color: foreground(layer, "variant"),
-            iconWidth: 12,
-            buttonWidth: 14,
-            padding: {
-                left: 10,
-                right: 10,
+        dismissButton: interactive({
+            base: {
+                color: foreground(layer, "variant"),
+                iconWidth: 12,
+                buttonWidth: 14,
+                padding: {
+                    left: 10,
+                    right: 10,
+                },
             },
-            hover: {
-                color: foreground(layer, "hovered"),
+            state: {
+                hovered: {
+                    color: foreground(layer, "hovered"),
+                },
+                clicked: {
+                    color: foreground(layer, "pressed"),
+                },
             },
-        },
+        }),
     }
 }

styles/src/styleTree/simpleMessageNotification.ts 🔗

@@ -1,5 +1,6 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { background, border, foreground, text } from "./components"
+import { interactive } from "../element"
 
 const headerPadding = 8
 
@@ -12,33 +13,41 @@ export default function simpleMessageNotification(
             ...text(layer, "sans", { size: "xs" }),
             margin: { left: headerPadding, right: headerPadding },
         },
-        actionMessage: {
-            ...text(layer, "sans", { size: "xs" }),
-            border: border(layer, "active"),
-            cornerRadius: 4,
-            padding: {
-                top: 3,
-                bottom: 3,
-                left: 7,
-                right: 7,
-            },
-
-            margin: { left: headerPadding, top: 6, bottom: 6 },
-            hover: {
-                ...text(layer, "sans", "default", { size: "xs" }),
-                background: background(layer, "hovered"),
+        actionMessage: interactive({
+            base: {
+                ...text(layer, "sans", { size: "xs" }),
                 border: border(layer, "active"),
+                cornerRadius: 4,
+                padding: {
+                    top: 3,
+                    bottom: 3,
+                    left: 7,
+                    right: 7,
+                },
+
+                margin: { left: headerPadding, top: 6, bottom: 6 },
             },
-        },
-        dismissButton: {
-            color: foreground(layer),
-            iconWidth: 8,
-            iconHeight: 8,
-            buttonWidth: 8,
-            buttonHeight: 8,
-            hover: {
-                color: foreground(layer, "hovered"),
+            state: {
+                hovered: {
+                    ...text(layer, "sans", "default", { size: "xs" }),
+                    background: background(layer, "hovered"),
+                    border: border(layer, "active"),
+                },
             },
-        },
+        }),
+        dismissButton: interactive({
+            base: {
+                color: foreground(layer),
+                iconWidth: 8,
+                iconHeight: 8,
+                buttonWidth: 8,
+                buttonHeight: 8,
+            },
+            state: {
+                hovered: {
+                    color: foreground(layer, "hovered"),
+                },
+            },
+        }),
     }
 }

styles/src/styleTree/statusBar.ts 🔗

@@ -1,6 +1,6 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { background, border, foreground, text } from "./components"
-
+import { interactive, toggleable } from "../element"
 export default function statusBar(colorScheme: ColorScheme) {
     let layer = colorScheme.lowest
 
@@ -25,95 +25,123 @@ export default function statusBar(colorScheme: ColorScheme) {
         },
         border: border(layer, { top: true, overlay: true }),
         cursorPosition: text(layer, "sans", "variant"),
-        activeLanguage: {
-            padding: { left: 6, right: 6 },
-            ...text(layer, "sans", "variant"),
-            hover: {
-                ...text(layer, "sans", "on"),
+        activeLanguage: interactive({
+            base: {
+                padding: { left: 6, right: 6 },
+                ...text(layer, "sans", "variant"),
             },
-        },
+            state: {
+                hovered: {
+                    ...text(layer, "sans", "on"),
+                },
+            },
+        }),
         autoUpdateProgressMessage: text(layer, "sans", "variant"),
         autoUpdateDoneMessage: text(layer, "sans", "variant"),
-        lspStatus: {
-            ...diagnosticStatusContainer,
-            iconSpacing: 4,
-            iconWidth: 14,
-            height: 18,
-            message: text(layer, "sans"),
-            iconColor: foreground(layer),
-            hover: {
+        lspStatus: interactive({
+            base: {
+                ...diagnosticStatusContainer,
+                iconSpacing: 4,
+                iconWidth: 14,
+                height: 18,
                 message: text(layer, "sans"),
                 iconColor: foreground(layer),
-                background: background(layer, "hovered"),
             },
-        },
-        diagnosticMessage: {
-            ...text(layer, "sans"),
-            hover: text(layer, "sans", "hovered"),
-        },
-        diagnosticSummary: {
-            height: 20,
-            iconWidth: 16,
-            iconSpacing: 2,
-            summarySpacing: 6,
-            text: text(layer, "sans", { size: "sm" }),
-            iconColorOk: foreground(layer, "variant"),
-            iconColorWarning: foreground(layer, "warning"),
-            iconColorError: foreground(layer, "negative"),
-            containerOk: {
-                cornerRadius: 6,
-                padding: { top: 3, bottom: 3, left: 7, right: 7 },
-            },
-            containerWarning: {
-                ...diagnosticStatusContainer,
-                background: background(layer, "warning"),
-                border: border(layer, "warning"),
+            state: {
+                hovered: {
+                    message: text(layer, "sans"),
+                    iconColor: foreground(layer),
+                    background: background(layer, "hovered"),
+                },
             },
-            containerError: {
-                ...diagnosticStatusContainer,
-                background: background(layer, "negative"),
-                border: border(layer, "negative"),
+        }),
+        diagnosticMessage: interactive({
+            base: {
+                ...text(layer, "sans"),
             },
-            hover: {
-                iconColorOk: foreground(layer, "on"),
+            state: { hovered: text(layer, "sans", "hovered") },
+        }),
+        diagnosticSummary: interactive({
+            base: {
+                height: 20,
+                iconWidth: 16,
+                iconSpacing: 2,
+                summarySpacing: 6,
+                text: text(layer, "sans", { size: "sm" }),
+                iconColorOk: foreground(layer, "variant"),
+                iconColorWarning: foreground(layer, "warning"),
+                iconColorError: foreground(layer, "negative"),
                 containerOk: {
                     cornerRadius: 6,
                     padding: { top: 3, bottom: 3, left: 7, right: 7 },
-                    background: background(layer, "on", "hovered"),
                 },
                 containerWarning: {
                     ...diagnosticStatusContainer,
-                    background: background(layer, "warning", "hovered"),
-                    border: border(layer, "warning", "hovered"),
+                    background: background(layer, "warning"),
+                    border: border(layer, "warning"),
                 },
                 containerError: {
                     ...diagnosticStatusContainer,
-                    background: background(layer, "negative", "hovered"),
-                    border: border(layer, "negative", "hovered"),
+                    background: background(layer, "negative"),
+                    border: border(layer, "negative"),
                 },
             },
-        },
+            state: {
+                hovered: {
+                    iconColorOk: foreground(layer, "on"),
+                    containerOk: {
+                        background: background(layer, "on", "hovered"),
+                    },
+                    containerWarning: {
+                        background: background(layer, "warning", "hovered"),
+                        border: border(layer, "warning", "hovered"),
+                    },
+                    containerError: {
+                        background: background(layer, "negative", "hovered"),
+                        border: border(layer, "negative", "hovered"),
+                    },
+                },
+            },
+        }),
         panelButtons: {
             groupLeft: {},
             groupBottom: {},
             groupRight: {},
-            button: {
-                ...statusContainer,
-                iconSize: 16,
-                iconColor: foreground(layer, "variant"),
-                label: {
-                    margin: { left: 6 },
-                    ...text(layer, "sans", { size: "sm" }),
-                },
-                hover: {
-                    iconColor: foreground(layer, "hovered"),
-                    background: background(layer, "variant"),
+            button: toggleable({
+                base: interactive({
+                    base: {
+                        ...statusContainer,
+                        iconSize: 16,
+                        iconColor: foreground(layer, "variant"),
+                        label: {
+                            margin: { left: 6 },
+                            ...text(layer, "sans", { size: "sm" }),
+                        },
+                    },
+                    state: {
+                        hovered: {
+                            iconColor: foreground(layer, "hovered"),
+                            background: background(layer, "variant"),
+                        },
+                    },
+                }),
+                state: {
+                    active: {
+                        default: {
+                            iconColor: foreground(layer, "active"),
+                            background: background(layer, "active"),
+                        },
+                        hovered: {
+                            iconColor: foreground(layer, "hovered"),
+                            background: background(layer, "hovered"),
+                        },
+                        clicked: {
+                            iconColor: foreground(layer, "pressed"),
+                            background: background(layer, "pressed"),
+                        },
+                    },
                 },
-                active: {
-                    iconColor: foreground(layer, "active"),
-                    background: background(layer, "active"),
-                },
-            },
+            }),
             badge: {
                 cornerRadius: 3,
                 padding: 2,

styles/src/styleTree/tabBar.ts 🔗

@@ -1,6 +1,7 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { withOpacity } from "../theme/color"
 import { text, border, background, foreground } from "./components"
+import { interactive, toggleable } from "../element"
 
 export default function tabBar(colorScheme: ColorScheme) {
     const height = 32
@@ -87,17 +88,36 @@ export default function tabBar(colorScheme: ColorScheme) {
             inactiveTab: inactivePaneInactiveTab,
         },
         draggedTab,
-        paneButton: {
-            color: foreground(layer, "variant"),
-            iconWidth: 12,
-            buttonWidth: activePaneActiveTab.height,
-            hover: {
-                color: foreground(layer, "hovered"),
+        paneButton: toggleable({
+            base: interactive({
+                base: {
+                    color: foreground(layer, "variant"),
+                    iconWidth: 12,
+                    buttonWidth: activePaneActiveTab.height,
+                },
+                state: {
+                    hovered: {
+                        color: foreground(layer, "hovered"),
+                    },
+                    clicked: {
+                        color: foreground(layer, "pressed"),
+                    },
+                },
+            }),
+            state: {
+                active: {
+                    default: {
+                        color: foreground(layer, "accent"),
+                    },
+                    hovered: {
+                        color: foreground(layer, "hovered"),
+                    },
+                    clicked: {
+                        color: foreground(layer, "pressed"),
+                    },
+                },
             },
-            active: {
-                color: foreground(layer, "accent"),
-            },
-        },
+        }),
         paneButtonContainer: {
             background: tab.background,
             border: {

styles/src/styleTree/toggle.ts 🔗

@@ -0,0 +1,47 @@
+import merge from "ts-deepmerge"
+
+type ToggleState = "inactive" | "active"
+
+type Toggleable<T> = Record<ToggleState, T>
+
+const NO_INACTIVE_OR_BASE_ERROR =
+    "A toggleable object must have an inactive state, or a base property."
+const NO_ACTIVE_ERROR = "A toggleable object must have an active state."
+
+interface ToggleableProps<T> {
+    base?: T
+    state: Partial<Record<ToggleState, T>>
+}
+
+/**
+ * Helper function for creating Toggleable objects.
+ * @template T The type of the object being toggled.
+ * @param props Object containing the base (inactive) state and state modifications to create the active state.
+ * @returns A Toggleable object containing both the inactive and active states.
+ * @example
+ * ```
+ * toggleable({
+ *   base: { background: "#000000", text: "#CCCCCC" },
+ *   state: { active: { text: "#CCCCCC" } },
+ * })
+ * ```
+ */
+export function toggleable<T extends object>(
+    props: ToggleableProps<T>
+): Toggleable<T> {
+    const { base, state } = props
+
+    if (!base && !state.inactive) throw new Error(NO_INACTIVE_OR_BASE_ERROR)
+    if (!state.active) throw new Error(NO_ACTIVE_ERROR)
+
+    const inactiveState = base
+        ? ((state.inactive ? merge(base, state.inactive) : base) as T)
+        : (state.inactive as T)
+
+    const toggleObj: Toggleable<T> = {
+        inactive: inactiveState,
+        active: merge(base ?? {}, state.active) as T,
+    }
+
+    return toggleObj
+}

styles/src/styleTree/toolbarDropdownMenu.ts 🔗

@@ -1,6 +1,6 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { background, border, text } from "./components"
-
+import { interactive, toggleable } from "../element"
 export default function dropdownMenu(colorScheme: ColorScheme) {
     let layer = colorScheme.middle
 
@@ -9,38 +9,56 @@ export default function dropdownMenu(colorScheme: ColorScheme) {
         background: background(layer),
         border: border(layer),
         shadow: colorScheme.popoverShadow,
-        header: {
-            ...text(layer, "sans", { size: "sm" }),
-            secondaryText: text(layer, "sans", { size: "sm", color: "#aaaaaa" }),
-            secondaryTextSpacing: 10,
-            padding: { left: 8, right: 8, top: 2, bottom: 2 },
-            cornerRadius: 6,
-            background: background(layer, "on"),
-            border: border(layer, "on", { overlay: true }),
-            hover: {
-                background: background(layer, "hovered"),
-                ...text(layer, "sans", "hovered", { size: "sm" }),
-            }
-        },
+        header: interactive({
+            base: {
+                ...text(layer, "sans", { size: "sm" }),
+                secondaryText: text(layer, "sans", {
+                    size: "sm",
+                    color: "#aaaaaa",
+                }),
+                secondaryTextSpacing: 10,
+                padding: { left: 8, right: 8, top: 2, bottom: 2 },
+                cornerRadius: 6,
+                background: background(layer, "on"),
+            },
+            state: {
+                hovered: {
+                    background: background(layer, "hovered"),
+                },
+                clicked: {
+                    background: background(layer, "pressed"),
+                },
+            },
+        }),
         sectionHeader: {
             ...text(layer, "sans", { size: "sm" }),
             padding: { left: 8, right: 8, top: 8, bottom: 8 },
         },
-        item: {
-            ...text(layer, "sans", { size: "sm" }),
-            secondaryTextSpacing: 10,
-            secondaryText: text(layer, "sans", { size: "sm" }),
-            padding: { left: 18, right: 18, top: 2, bottom: 2 },
-            hover: {
-                background: background(layer, "hovered"),
-                ...text(layer, "sans", "hovered", { size: "sm" }),
-            },
-            active: {
-                background: background(layer, "active"),
+        item: toggleable({
+            base: interactive({
+                base: {
+                    ...text(layer, "sans", { size: "sm" }),
+                    secondaryTextSpacing: 10,
+                    secondaryText: text(layer, "sans", { size: "sm" }),
+                    padding: { left: 18, right: 18, top: 2, bottom: 2 },
+                },
+                state: {
+                    hovered: {
+                        background: background(layer, "hovered"),
+                        ...text(layer, "sans", "hovered", { size: "sm" }),
+                    },
+                },
+            }),
+            state: {
+                active: {
+                    default: {
+                        background: background(layer, "active"),
+                    },
+                    hovered: {
+                        background: background(layer, "hovered"),
+                    },
+                },
             },
-            activeHover: {
-                background: background(layer, "active"),
-            },
-        },
+        }),
     }
 }

styles/src/styleTree/updateNotification.ts 🔗

@@ -1,5 +1,6 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { foreground, text } from "./components"
+import { interactive } from "../element"
 
 const headerPadding = 8
 
@@ -10,22 +11,30 @@ export default function updateNotification(colorScheme: ColorScheme): Object {
             ...text(layer, "sans", { size: "xs" }),
             margin: { left: headerPadding, right: headerPadding },
         },
-        actionMessage: {
-            ...text(layer, "sans", { size: "xs" }),
-            margin: { left: headerPadding, top: 6, bottom: 6 },
-            hover: {
-                color: foreground(layer, "hovered"),
+        actionMessage: interactive({
+            base: {
+                ...text(layer, "sans", { size: "xs" }),
+                margin: { left: headerPadding, top: 6, bottom: 6 },
             },
-        },
-        dismissButton: {
-            color: foreground(layer),
-            iconWidth: 8,
-            iconHeight: 8,
-            buttonWidth: 8,
-            buttonHeight: 8,
-            hover: {
-                color: foreground(layer, "hovered"),
+            state: {
+                hovered: {
+                    color: foreground(layer, "hovered"),
+                },
             },
-        },
+        }),
+        dismissButton: interactive({
+            base: {
+                color: foreground(layer),
+                iconWidth: 8,
+                iconHeight: 8,
+                buttonWidth: 8,
+                buttonHeight: 8,
+            },
+            state: {
+                hovered: {
+                    color: foreground(layer, "hovered"),
+                },
+            },
+        }),
     }
 }

styles/src/styleTree/welcome.ts 🔗

@@ -8,6 +8,7 @@ import {
     TextProperties,
     svg,
 } from "./components"
+import { interactive } from "../element"
 
 export default function welcome(colorScheme: ColorScheme) {
     let layer = colorScheme.highest
@@ -63,27 +64,31 @@ export default function welcome(colorScheme: ColorScheme) {
                 bottom: 2,
             },
         },
-        button: {
-            background: background(layer),
-            border: border(layer, "active"),
-            cornerRadius: 4,
-            margin: {
-                top: 4,
-                bottom: 4,
-            },
-            padding: {
-                top: 3,
-                bottom: 3,
-                left: 7,
-                right: 7,
-            },
-            ...text(layer, "sans", "default", interactive_text_size),
-            hover: {
-                ...text(layer, "sans", "default", interactive_text_size),
-                background: background(layer, "hovered"),
+        button: interactive({
+            base: {
+                background: background(layer),
                 border: border(layer, "active"),
+                cornerRadius: 4,
+                margin: {
+                    top: 4,
+                    bottom: 4,
+                },
+                padding: {
+                    top: 3,
+                    bottom: 3,
+                    left: 7,
+                    right: 7,
+                },
+                ...text(layer, "sans", "default", interactive_text_size),
             },
-        },
+            state: {
+                hovered: {
+                    ...text(layer, "sans", "default", interactive_text_size),
+                    background: background(layer, "hovered"),
+                },
+            },
+        }),
+
         usageNote: {
             ...text(layer, "sans", "variant", { size: "2xs" }),
             padding: {

styles/src/styleTree/workspace.ts 🔗

@@ -1,5 +1,6 @@
 import { ColorScheme } from "../theme/colorScheme"
 import { withOpacity } from "../theme/color"
+import { toggleable } from "../element"
 import {
     background,
     border,
@@ -10,38 +11,53 @@ import {
 } from "./components"
 import statusBar from "./statusBar"
 import tabBar from "./tabBar"
-
+import { interactive } from "../element"
+import merge from "ts-deepmerge"
 export default function workspace(colorScheme: ColorScheme) {
     const layer = colorScheme.lowest
     const isLight = colorScheme.isLight
     const itemSpacing = 8
-    const titlebarButton = {
-        cornerRadius: 6,
-        padding: {
-            top: 1,
-            bottom: 1,
-            left: 8,
-            right: 8,
-        },
-        ...text(layer, "sans", "variant", { size: "xs" }),
-        background: background(layer, "variant"),
-        border: border(layer),
-        hover: {
-            ...text(layer, "sans", "variant", "hovered", { size: "xs" }),
-            background: background(layer, "variant", "hovered"),
-            border: border(layer, "variant", "hovered"),
-        },
-        clicked: {
-            ...text(layer, "sans", "variant", "pressed", { size: "xs" }),
-            background: background(layer, "variant", "pressed"),
-            border: border(layer, "variant", "pressed"),
-        },
-        active: {
-            ...text(layer, "sans", "variant", "active", { size: "xs" }),
-            background: background(layer, "variant", "active"),
-            border: border(layer, "variant", "active"),
+    const titlebarButton = toggleable({
+        base: interactive({
+            base: {
+                cornerRadius: 6,
+                padding: {
+                    top: 1,
+                    bottom: 1,
+                    left: 8,
+                    right: 8,
+                },
+                ...text(layer, "sans", "variant", { size: "xs" }),
+                background: background(layer, "variant"),
+                border: border(layer),
+            },
+            state: {
+                hovered: {
+                    ...text(layer, "sans", "variant", "hovered", {
+                        size: "xs",
+                    }),
+                    background: background(layer, "variant", "hovered"),
+                    border: border(layer, "variant", "hovered"),
+                },
+                clicked: {
+                    ...text(layer, "sans", "variant", "pressed", {
+                        size: "xs",
+                    }),
+                    background: background(layer, "variant", "pressed"),
+                    border: border(layer, "variant", "pressed"),
+                },
+            },
+        }),
+        state: {
+            active: {
+                default: {
+                    ...text(layer, "sans", "variant", "active", { size: "xs" }),
+                    background: background(layer, "variant", "active"),
+                    border: border(layer, "variant", "active"),
+                },
+            },
         },
-    }
+    })
     const avatarWidth = 18
     const avatarOuterWidth = avatarWidth + 4
     const followerAvatarWidth = 14
@@ -78,19 +94,24 @@ export default function workspace(colorScheme: ColorScheme) {
                 },
                 cornerRadius: 4,
             },
-            keyboardHint: {
-                ...text(layer, "sans", "variant", { size: "sm" }),
-                padding: {
-                    top: 3,
-                    left: 8,
-                    right: 8,
-                    bottom: 3,
+            keyboardHint: interactive({
+                base: {
+                    ...text(layer, "sans", "variant", { size: "sm" }),
+                    padding: {
+                        top: 3,
+                        left: 8,
+                        right: 8,
+                        bottom: 3,
+                    },
+                    cornerRadius: 8,
                 },
-                cornerRadius: 8,
-                hover: {
-                    ...text(layer, "sans", "active", { size: "sm" }),
+                state: {
+                    hovered: {
+                        ...text(layer, "sans", "active", { size: "sm" }),
+                    },
                 },
-            },
+            }),
+
             keyboardHintWidth: 320,
         },
         joiningProjectAvatar: {
@@ -201,12 +222,15 @@ export default function workspace(colorScheme: ColorScheme) {
 
             // Sign in buttom
             // FlatButton, Variant
-            signInPrompt: {
-                margin: {
-                    left: itemSpacing,
+            signInPrompt: merge(titlebarButton, {
+                inactive: {
+                    default: {
+                        margin: {
+                            left: itemSpacing,
+                        },
+                    },
                 },
-                ...titlebarButton,
-            },
+            }),
 
             // Offline Indicator
             offlineIcon: {
@@ -234,40 +258,68 @@ export default function workspace(colorScheme: ColorScheme) {
                 },
                 cornerRadius: 6,
             },
-            callControl: {
-                cornerRadius: 6,
-                color: foreground(layer, "variant"),
-                iconWidth: 12,
-                buttonWidth: 20,
-                hover: {
-                    background: background(layer, "variant", "hovered"),
-                    color: foreground(layer, "variant", "hovered"),
+            callControl: interactive({
+                base: {
+                    cornerRadius: 6,
+                    color: foreground(layer, "variant"),
+                    iconWidth: 12,
+                    buttonWidth: 20,
                 },
-            },
-            toggleContactsButton: {
-                margin: { left: itemSpacing },
-                cornerRadius: 6,
-                color: foreground(layer, "variant"),
-                iconWidth: 14,
-                buttonWidth: 20,
-                active: {
-                    background: background(layer, "variant", "active"),
-                    color: foreground(layer, "variant", "active"),
+                state: {
+                    hovered: {
+                        background: background(layer, "variant", "hovered"),
+                        color: foreground(layer, "variant", "hovered"),
+                    },
                 },
-                clicked: {
-                    background: background(layer, "variant", "pressed"),
-                    color: foreground(layer, "variant", "pressed"),
+            }),
+            toggleContactsButton: toggleable({
+                base: interactive({
+                    base: {
+                        margin: { left: itemSpacing },
+                        cornerRadius: 6,
+                        color: foreground(layer, "variant"),
+                        iconWidth: 14,
+                        buttonWidth: 20,
+                    },
+                    state: {
+                        clicked: {
+                            background: background(layer, "variant", "pressed"),
+                        },
+                        hovered: {
+                            background: background(layer, "variant", "hovered"),
+                        },
+                    },
+                }),
+                state: {
+                    active: {
+                        default: {
+                            background: background(layer, "on", "default"),
+                        },
+                        hovered: {
+                            background: background(layer, "on", "hovered"),
+                        },
+                        clicked: {
+                            background: background(layer, "on", "pressed"),
+                        },
+                    },
                 },
-                hover: {
-                    background: background(layer, "variant", "hovered"),
-                    color: foreground(layer, "variant", "hovered"),
+            }),
+            userMenuButton: merge(titlebarButton, {
+                inactive: {
+                    default: {
+                        buttonWidth: 20,
+                        iconWidth: 12,
+                    },
                 },
-            },
-            userMenuButton: {
-                buttonWidth: 20,
-                iconWidth: 12,
-                ...titlebarButton,
-            },
+                active: {
+                    // posiewic: these properties are not currently set on main
+                    default: {
+                        iconWidth: 12,
+                        button_width: 20,
+                    },
+                },
+            }),
+
             toggleContactsBadge: {
                 cornerRadius: 3,
                 padding: 2,
@@ -285,12 +337,45 @@ export default function workspace(colorScheme: ColorScheme) {
             background: background(colorScheme.highest),
             border: border(colorScheme.highest, { bottom: true }),
             itemSpacing: 8,
-            navButton: {
-                color: foreground(colorScheme.highest, "on"),
-                iconWidth: 12,
-                buttonWidth: 24,
+            navButton: interactive({
+                base: {
+                    color: foreground(colorScheme.highest, "on"),
+                    iconWidth: 12,
+                    buttonWidth: 24,
+                    cornerRadius: 6,
+                },
+                state: {
+                    hovered: {
+                        color: foreground(colorScheme.highest, "on", "hovered"),
+                        background: background(
+                            colorScheme.highest,
+                            "on",
+                            "hovered"
+                        ),
+                    },
+                    disabled: {
+                        color: foreground(
+                            colorScheme.highest,
+                            "on",
+                            "disabled"
+                        ),
+                    },
+                },
+            }),
+            padding: { left: 8, right: 8, top: 4, bottom: 4 },
+        },
+        breadcrumbHeight: 24,
+        breadcrumbs: interactive({
+            base: {
+                ...text(colorScheme.highest, "sans", "variant"),
                 cornerRadius: 6,
-                hover: {
+                padding: {
+                    left: 6,
+                    right: 6,
+                },
+            },
+            state: {
+                hovered: {
                     color: foreground(colorScheme.highest, "on", "hovered"),
                     background: background(
                         colorScheme.highest,
@@ -298,25 +383,8 @@ export default function workspace(colorScheme: ColorScheme) {
                         "hovered"
                     ),
                 },
-                disabled: {
-                    color: foreground(colorScheme.highest, "on", "disabled"),
-                },
             },
-            padding: { left: 8, right: 8, top: 4, bottom: 4 },
-        },
-        breadcrumbHeight: 24,
-        breadcrumbs: {
-            ...text(colorScheme.highest, "sans", "variant"),
-            cornerRadius: 6,
-            padding: {
-                left: 6,
-                right: 6,
-            },
-            hover: {
-                color: foreground(colorScheme.highest, "on", "hovered"),
-                background: background(colorScheme.highest, "on", "hovered"),
-            },
-        },
+        }),
         disconnectedOverlay: {
             ...text(layer, "sans"),
             background: withOpacity(background(layer), 0.8),

styles/src/theme/tokens/colorScheme.ts 🔗

@@ -1,9 +1,19 @@
-import { SingleBoxShadowToken, SingleColorToken, SingleOtherToken, TokenTypes } from "@tokens-studio/types"
-import { ColorScheme, Shadow, SyntaxHighlightStyle, ThemeSyntax } from "../colorScheme"
+import {
+    SingleBoxShadowToken,
+    SingleColorToken,
+    SingleOtherToken,
+    TokenTypes,
+} from "@tokens-studio/types"
+import {
+    ColorScheme,
+    Shadow,
+    SyntaxHighlightStyle,
+    ThemeSyntax,
+} from "../colorScheme"
 import { LayerToken, layerToken } from "./layer"
 import { PlayersToken, playersToken } from "./players"
 import { colorToken } from "./token"
-import { Syntax } from "../syntax";
+import { Syntax } from "../syntax"
 import editor from "../../styleTree/editor"
 
 interface ColorSchemeTokens {
@@ -18,27 +28,32 @@ interface ColorSchemeTokens {
     syntax?: Partial<ThemeSyntaxColorTokens>
 }
 
-const createShadowToken = (shadow: Shadow, tokenName: string): SingleBoxShadowToken => {
+const createShadowToken = (
+    shadow: Shadow,
+    tokenName: string
+): SingleBoxShadowToken => {
     return {
         name: tokenName,
         type: TokenTypes.BOX_SHADOW,
-        value: `${shadow.offset[0]}px ${shadow.offset[1]}px ${shadow.blur}px 0px ${shadow.color}`
-    };
-};
+        value: `${shadow.offset[0]}px ${shadow.offset[1]}px ${shadow.blur}px 0px ${shadow.color}`,
+    }
+}
 
 const popoverShadowToken = (colorScheme: ColorScheme): SingleBoxShadowToken => {
-    const shadow = colorScheme.popoverShadow;
-    return createShadowToken(shadow, "popoverShadow");
-};
+    const shadow = colorScheme.popoverShadow
+    return createShadowToken(shadow, "popoverShadow")
+}
 
 const modalShadowToken = (colorScheme: ColorScheme): SingleBoxShadowToken => {
-    const shadow = colorScheme.modalShadow;
-    return createShadowToken(shadow, "modalShadow");
-};
+    const shadow = colorScheme.modalShadow
+    return createShadowToken(shadow, "modalShadow")
+}
 
 type ThemeSyntaxColorTokens = Record<keyof ThemeSyntax, SingleColorToken>
 
-function syntaxHighlightStyleColorTokens(syntax: Syntax): ThemeSyntaxColorTokens {
+function syntaxHighlightStyleColorTokens(
+    syntax: Syntax
+): ThemeSyntaxColorTokens {
     const styleKeys = Object.keys(syntax) as (keyof Syntax)[]
 
     return styleKeys.reduce((acc, styleKey) => {
@@ -46,13 +61,16 @@ function syntaxHighlightStyleColorTokens(syntax: Syntax): ThemeSyntaxColorTokens
         // This can happen because we have a "constructor" property on the syntax object
         // and a "constructor" property on the prototype of the syntax object
         // To work around this just assert that the type of the style is not a function
-        if (!syntax[styleKey] || typeof syntax[styleKey] === 'function') return acc;
-        const { color } = syntax[styleKey] as Required<SyntaxHighlightStyle>;
-        return { ...acc, [styleKey]: colorToken(styleKey, color) };
-    }, {} as ThemeSyntaxColorTokens);
+        if (!syntax[styleKey] || typeof syntax[styleKey] === "function")
+            return acc
+        const { color } = syntax[styleKey] as Required<SyntaxHighlightStyle>
+        return { ...acc, [styleKey]: colorToken(styleKey, color) }
+    }, {} as ThemeSyntaxColorTokens)
 }
 
-const syntaxTokens = (colorScheme: ColorScheme): ColorSchemeTokens['syntax'] => {
+const syntaxTokens = (
+    colorScheme: ColorScheme
+): ColorSchemeTokens["syntax"] => {
     const syntax = editor(colorScheme).syntax
 
     return syntaxHighlightStyleColorTokens(syntax)

styles/src/theme/tokens/layer.ts 🔗

@@ -1,11 +1,11 @@
-import { SingleColorToken } from "@tokens-studio/types";
-import { Layer, Style, StyleSet } from "../colorScheme";
-import { colorToken } from "./token";
+import { SingleColorToken } from "@tokens-studio/types"
+import { Layer, Style, StyleSet } from "../colorScheme"
+import { colorToken } from "./token"
 
 interface StyleToken {
-    background: SingleColorToken,
-    border: SingleColorToken,
-    foreground: SingleColorToken,
+    background: SingleColorToken
+    border: SingleColorToken
+    foreground: SingleColorToken
 }
 
 interface StyleSetToken {
@@ -37,24 +37,27 @@ export const styleToken = (style: Style, name: string): StyleToken => {
     return token
 }
 
-export const styleSetToken = (styleSet: StyleSet, name: string): StyleSetToken => {
-    const token: StyleSetToken = {} as StyleSetToken;
+export const styleSetToken = (
+    styleSet: StyleSet,
+    name: string
+): StyleSetToken => {
+    const token: StyleSetToken = {} as StyleSetToken
 
     for (const style in styleSet) {
-        const s = style as keyof StyleSet;
-        token[s] = styleToken(styleSet[s], `${name}${style}`);
+        const s = style as keyof StyleSet
+        token[s] = styleToken(styleSet[s], `${name}${style}`)
     }
 
-    return token;
+    return token
 }
 
 export const layerToken = (layer: Layer, name: string): LayerToken => {
-    const token: LayerToken = {} as LayerToken;
+    const token: LayerToken = {} as LayerToken
 
     for (const styleSet in layer) {
-        const s = styleSet as keyof Layer;
-        token[s] = styleSetToken(layer[s], `${name}${styleSet}`);
+        const s = styleSet as keyof Layer
+        token[s] = styleSetToken(layer[s], `${name}${styleSet}`)
     }
 
-    return token;
+    return token
 }

styles/src/theme/tokens/players.ts 🔗

@@ -6,13 +6,21 @@ export type PlayerToken = Record<"selection" | "cursor", SingleColorToken>
 
 export type PlayersToken = Record<keyof Players, PlayerToken>
 
-function buildPlayerToken(colorScheme: ColorScheme, index: number): PlayerToken {
-
+function buildPlayerToken(
+    colorScheme: ColorScheme,
+    index: number
+): PlayerToken {
     const playerNumber = index.toString() as keyof Players
 
     return {
-        selection: colorToken(`player${index}Selection`, colorScheme.players[playerNumber].selection),
-        cursor: colorToken(`player${index}Cursor`, colorScheme.players[playerNumber].cursor),
+        selection: colorToken(
+            `player${index}Selection`,
+            colorScheme.players[playerNumber].selection
+        ),
+        cursor: colorToken(
+            `player${index}Cursor`,
+            colorScheme.players[playerNumber].cursor
+        ),
     }
 }
 
@@ -24,5 +32,5 @@ export const playersToken = (colorScheme: ColorScheme): PlayersToken => ({
     "4": buildPlayerToken(colorScheme, 4),
     "5": buildPlayerToken(colorScheme, 5),
     "6": buildPlayerToken(colorScheme, 6),
-    "7": buildPlayerToken(colorScheme, 7)
+    "7": buildPlayerToken(colorScheme, 7),
 })

styles/src/theme/tokens/token.ts 🔗

@@ -1,6 +1,10 @@
 import { SingleColorToken, TokenTypes } from "@tokens-studio/types"
 
-export function colorToken(name: string, value: string, description?: string): SingleColorToken {
+export function colorToken(
+    name: string,
+    value: string,
+    description?: string
+): SingleColorToken {
     const token: SingleColorToken = {
         name,
         type: TokenTypes.COLOR,
@@ -8,7 +12,8 @@ export function colorToken(name: string, value: string, description?: string): S
         description,
     }
 
-    if (!token.value || token.value === '') throw new Error("Color token must have a value")
+    if (!token.value || token.value === "")
+        throw new Error("Color token must have a value")
 
     return token
 }

styles/src/utils/slugify.ts 🔗

@@ -1 +1,10 @@
-export function slugify(t: string): string { return t.toString().toLowerCase().replace(/\s+/g, '-').replace(/[^\w\-]+/g, '').replace(/\-\-+/g, '-').replace(/^-+/, '').replace(/-+$/, '') }
+export function slugify(t: string): string {
+    return t
+        .toString()
+        .toLowerCase()
+        .replace(/\s+/g, "-")
+        .replace(/[^\w\-]+/g, "")
+        .replace(/\-\-+/g, "-")
+        .replace(/^-+/, "")
+        .replace(/-+$/, "")
+}

styles/tsconfig.json 🔗

@@ -20,7 +20,17 @@
         "noFallthroughCasesInSwitch": false,
         "experimentalDecorators": true,
         "strictPropertyInitialization": false,
-        "skipLibCheck": true
+        "skipLibCheck": true,
+        "baseUrl": ".",
+        "paths": {
+            "@/*": ["./*"],
+            "@element/*": ["./src/element/*"],
+            "@component/*": ["./src/component/*"],
+            "@styleTree/*": ["./src/styleTree/*"],
+            "@theme/*": ["./src/theme/*"],
+            "@themes/*": ["./src/themes/*"],
+            "@util/*": ["./src/util/*"]
+        }
     },
     "exclude": ["node_modules"]
 }

styles/vitest.config.ts 🔗

@@ -0,0 +1,8 @@
+import { configDefaults, defineConfig } from "vitest/config"
+
+export default defineConfig({
+    test: {
+        exclude: [...configDefaults.exclude, "target/*"],
+        include: ["src/**/*.{spec,test}.ts"],
+    },
+})