Repository: manusa/electronim
Pull request #158 (2022-10-13 04:13:54)

⬆ Bump electron to 21.1.1

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=manusa_electronim&pullRequest=158)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=158&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=158&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=158&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=158&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=158&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=158&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=manusa_electronim&pullRequest=158&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=manusa_electronim&pullRequest=158&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=manusa_electronim&pullRequest=158&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=158&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=158&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=manusa_electronim&pullRequest=158&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=158&metric=coverage&view=list) No Coverage information  
[![No Duplication information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/NoDuplicationInfo-16px.png 'No Duplication information')](https://sonarcloud.io/component_measures?id=manusa_electronim&pullRequest=158&metric=duplicated_lines_density&view=list) No Duplication information



Patch:
diff --git a/package-lock.json b/package-lock.json
index 974025b..f2c5145 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,7 +29,7 @@
         "dictionary-sv": "^3.0.1",
         "dictionary-tr": "^1.3.3",
         "dictionary-uk": "^2.1.1",
-        "electron": "21.1.0",
+        "electron": "21.1.1",
         "htm": "3.1.1",
         "markdown-it": "13.0.1",
         "nodehun": "3.0.2",
@@ -3672,9 +3672,9 @@
       }
     },
     "node_modules/electron": {
-      "version": "21.1.0",
-      "resolved": "https://registry.npmjs.org/electron/-/electron-21.1.0.tgz",
-      "integrity": "sha512-CM5VZpZPtAoPgTPcmEbRaZWXlxGuD5a5DTeAwm0F0F6/k6R3HZAi8vZnE77gwuHK/Qqcinw4/MAbiCwAKh2W+g==",
+      "version": "21.1.1",
+      "resolved": "https://registry.npmjs.org/electron/-/electron-21.1.1.tgz",
+      "integrity": "sha512-EM2hvRJtiS3n54yx25Z0Qv54t3LGG+WjUHf1AOl+PKjQj+fmXnjIgVeIF9pM21kP1BTcyjrgvN6Sff0A45OB6A==",
       "hasInstallScript": true,
       "dependencies": {
         "@electron/get": "^1.14.1",
@@ -11760,9 +11760,9 @@
       }
     },
     "electron": {
-      "version": "21.1.0",
-      "resolved": "https://registry.npmjs.org/electron/-/electron-21.1.0.tgz",
-      "integrity": "sha512-CM5VZpZPtAoPgTPcmEbRaZWXlxGuD5a5DTeAwm0F0F6/k6R3HZAi8vZnE77gwuHK/Qqcinw4/MAbiCwAKh2W+g==",
+      "version": "21.1.1",
+      "resolved": "https://registry.npmjs.org/electron/-/electron-21.1.1.tgz",
+      "integrity": "sha512-EM2hvRJtiS3n54yx25Z0Qv54t3LGG+WjUHf1AOl+PKjQj+fmXnjIgVeIF9pM21kP1BTcyjrgvN6Sff0A45OB6A==",
       "requires": {
         "@electron/get": "^1.14.1",
         "@types/node": "^16.11.26",
diff --git a/package.json b/package.json
index 55400af..087e745 100644
--- a/package.json
+++ b/package.json
@@ -201,7 +201,7 @@
     "dictionary-sv": "^3.0.1",
     "dictionary-tr": "^1.3.3",
     "dictionary-uk": "^2.1.1",
-    "electron": "21.1.0",
+    "electron": "21.1.1",
     "htm": "3.1.1",
     "markdown-it": "13.0.1",
     "nodehun": "3.0.2",
diff --git a/src/app-menu/app-menu.browser.css b/src/app-menu/app-menu.browser.css
new file mode 100644
index 0000000..a8a725d
--- /dev/null
+++ b/src/app-menu/app-menu.browser.css
@@ -0,0 +1,70 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+@import '../styles/main.css';
+:root {
+  --scrim-color: rgba(0, 0, 0, 0.3);
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --scrim-color: rgba(255, 255, 255, 0.3);
+  }
+}
+
+html.electronim,
+.electronim body {
+  background: transparent;
+}
+
+html.electronim,
+.electronim body,
+.electronim .scrim {
+  overflow: hidden;
+}
+.electronim .app-menu .wrapper,
+.electronim .app-menu .scrim {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+
+}
+.electronim .app-menu .scrim {
+  top: var(--tab-container-height);
+  background: var(--scrim-color);
+}
+.electronim .app-menu .dropdown,
+.electronim .app-menu .dropdown-menu {
+  position: absolute;
+  left: auto;
+  right: 0;
+}
+
+.electronim .app-menu .dropdown-menu {
+  padding: var(--default-spacing) var(--default-spacing)  0 0;
+}
+
+.electronim .app-menu .dropdown-item {
+  cursor: pointer;
+  user-select: none;
+  color: var(--color-gray-darker);
+  text-decoration: none;
+}
+
+.electronim .app-menu .dropdown-item.is-active {
+  color: var(--color-white);
+}
diff --git a/src/app-menu/app-menu.browser.mjs b/src/app-menu/app-menu.browser.mjs
new file mode 100644
index 0000000..7e3007b
--- /dev/null
+++ b/src/app-menu/app-menu.browser.mjs
@@ -0,0 +1,42 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+import {html, render, DropDown} from '../components/index.mjs';
+
+const {close, helpOpenDialog, settingsOpenDialog} = window.electron;
+
+const getAppMenu = () => document.querySelector('.app-menu');
+
+const AppMenu = () => {
+  const noBubbling = func => e => {
+    e.preventDefault();
+    e.stopPropagation();
+    func();
+  };
+  return (html`
+    <div class='wrapper' onClick=${close}>
+      <div class='scrim'>
+        <${DropDown} active=${true}>
+        <${DropDown.Menu}>
+          <${DropDown.Item} onClick=${noBubbling(helpOpenDialog)}>Help</${DropDown.Item}>
+          <${DropDown.Item} onClick=${noBubbling(settingsOpenDialog)}>Settings</${DropDown.Item}>
+        </${DropDown.Menu}>
+        </${DropDown}>
+      </div>
+    </div>
+  `);
+};
+
+render(html`<${AppMenu} />`, getAppMenu());
diff --git a/src/app-menu/index.html b/src/app-menu/index.html
new file mode 100644
index 0000000..2d157ef
--- /dev/null
+++ b/src/app-menu/index.html
@@ -0,0 +1,31 @@
+<!--
+Copyright 2022 Marc Nuri San Felix
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<!DOCTYPE html>
+<html lang="en" class="electronim">
+  <head>
+    <meta charset="UTF-8">
+    <meta http-equiv="Content-Security-Policy" content="script-src 'self';">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>ElectronIM app menu</title>
+    <link rel="stylesheet" type="text/css" href="./app-menu.browser.css" />
+  </head>
+  <body>
+    <div class="app-menu"></div>
+    <script src="./app-menu.browser.mjs" type="module"></script>
+    <script src="../../bundles/lib/bulma/css/bulma.css.js"></script>
+    <script src="../../bundles/lib/bulma/bulma.dark.scss.js"></script>
+  </body>
+</html>
diff --git a/src/app-menu/index.js b/src/app-menu/index.js
new file mode 100644
index 0000000..4341e8c
--- /dev/null
+++ b/src/app-menu/index.js
@@ -0,0 +1,37 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+const path = require('path');
+const {BrowserView} = require('electron');
+
+const webPreferences = {
+  transparent: true,
+  contextIsolation: true,
+  nodeIntegration: false,
+  sandbox: true,
+  preload: path.resolve(__dirname, '..', '..', 'bundles', 'app-menu.preload.js')
+};
+
+const initAppMenu = () => {
+  const appMenu = new BrowserView({webPreferences});
+  appMenu.isAppMenu = true;
+  appMenu.setAutoResize({width: false, horizontal: false, height: false, vertical: false});
+  appMenu.webContents.loadURL(`file://${__dirname}/index.html`);
+  return appMenu;
+};
+
+const isNotAppMenu = bv => bv.isAppMenu !== true;
+
+module.exports = {initAppMenu, isNotAppMenu};
diff --git a/src/app-menu/preload.js b/src/app-menu/preload.js
new file mode 100644
index 0000000..764db47
--- /dev/null
+++ b/src/app-menu/preload.js
@@ -0,0 +1,23 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+/* eslint-disable no-undef */
+const {contextBridge, ipcRenderer} = require('electron');
+
+contextBridge.exposeInMainWorld('electron', {
+  close: () => ipcRenderer.send(APP_EVENTS.appMenuClose),
+  helpOpenDialog: () => ipcRenderer.send(APP_EVENTS.helpOpenDialog),
+  settingsOpenDialog: () => ipcRenderer.send(APP_EVENTS.settingsOpenDialog)
+});
diff --git a/src/chrome-tabs/chrome-tabs.browser.css b/src/chrome-tabs/chrome-tabs.browser.css
index ff0d493..08b192a 100644
--- a/src/chrome-tabs/chrome-tabs.browser.css
+++ b/src/chrome-tabs/chrome-tabs.browser.css
@@ -49,11 +49,11 @@ body {
     display: none;
 }
 
-.tab-container .settings {
+.tab-container .menu {
     padding: 0 8px 0 0;
 }
 
-.tab-container .settings__button {
+.tab-container .menu__button {
     background: none;
     border-color: transparent;
 }
diff --git a/src/chrome-tabs/chrome-tabs.browser.mjs b/src/chrome-tabs/chrome-tabs.browser.mjs
index bace272..bdbc884 100644
--- a/src/chrome-tabs/chrome-tabs.browser.mjs
+++ b/src/chrome-tabs/chrome-tabs.browser.mjs
@@ -20,7 +20,10 @@ const shouldUseDarkColors = () => window.matchMedia && window.matchMedia('(prefe
 const getTabContainer = () => document.querySelector('.tab-container');
 const getChromeTabs = () => getTabContainer().querySelector('.chrome-tabs');
 
-const openSettings = () => ipcRenderer.send(APP_EVENTS.settingsOpenDialog);
+const openMenu = event => {
+  event.preventDefault();
+  ipcRenderer.send(APP_EVENTS.appMenuOpen);
+};
 const sendTabsReady = () => ipcRenderer.send(APP_EVENTS.tabsReady, {});
 const sendActivateTab = id => ipcRenderer.send(APP_EVENTS.activateTab, {id});
 const sendReorderTabs = tabs =>
@@ -239,14 +242,14 @@ const ChromeTabs = ({dispatch, state: {tabs}}) => {
   `;
 };
 
-const Settings = () => html`
- <div class="settings">
+const Menu = () => html`
+ <div class="menu">
      <button
-        class="settings__button button"
-        onclick=${openSettings}
+        class="menu__button button"
+        onClick=${openMenu}
       >
          <span class='icon'>
-           <em class='fas fa-cog'></em>
+           <em class='fas fa-bars'></em>
          </span>
      </button>
  </div>
@@ -264,7 +267,7 @@ const TabContainer = () => {
   }, []);
   return html`
     <${ChromeTabs} state=${state} dispatch=${dispatch}/>
-    <${Settings}/>
+    <${Menu}/>
   `;
 };
 
diff --git a/src/chrome-tabs/index.js b/src/chrome-tabs/index.js
index 1679a50..d22ad71 100644
--- a/src/chrome-tabs/index.js
+++ b/src/chrome-tabs/index.js
@@ -32,7 +32,7 @@ const handleContextMenu = (event, params) => {
   const webContents = event.sender;
   const mainWindow = BrowserWindow.fromWebContents(webContents);
   const menu = new Menu();
-  menu.append(new MenuItem({label: 'Settings', click: () => openSettingsDialog(mainWindow)}));
+  menu.append(new MenuItem({label: 'Settings', click: openSettingsDialog(mainWindow)}));
   menu.append(new MenuItem({label: 'Help', click: openHelpDialog(mainWindow)}));
   menu.append(new MenuItem({
     label: 'DevTools',
@@ -52,6 +52,8 @@ const initTabContainer = () => {
   return tabContainer;
 };
 
+const isNotTabContainer = bv => bv.isTabContainer !== true;
+
 module.exports = {
-  TABS_CONTAINER_HEIGHT, initTabContainer
+  TABS_CONTAINER_HEIGHT, initTabContainer, isNotTabContainer
 };
diff --git a/src/components/drop-down.mjs b/src/components/drop-down.mjs
new file mode 100644
index 0000000..f350926
--- /dev/null
+++ b/src/components/drop-down.mjs
@@ -0,0 +1,51 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+import {html, useState} from './index.mjs';
+
+export const DropDown = ({active = false, children}) => {
+  const className = `dropdown ${active ? 'is-active' : ''}`;
+  return (html`
+    <div class=${className}>${children}</div>
+  `);
+};
+
+DropDown.Menu = ({children}) => (html`
+  <div class='dropdown-menu' role='menu'>
+    <div class='dropdown-content'>${children}</div>
+  </div>
+`);
+
+DropDown.Item = ({onClick, children}) => {
+  const [hover, setHover] = useState(false);
+  const onMouseOver = () => setHover(true);
+  const onMouseOut = () => setHover(false);
+  const className = `dropdown-item ${hover ? 'is-active' : ''}`;
+  return (html`
+    <a 
+      class=${className}
+      onClick=${onClick}
+      onMouseOver=${onMouseOver}
+      onMouseOut=${onMouseOut}
+    >
+      ${children}
+    </a>
+  `);
+};
+
+DropDown.Divider = () => (html`
+  <hr class='dropdown-divider'/>
+`);
+
diff --git a/src/components/index.mjs b/src/components/index.mjs
index 2ab4f67..7de91a9 100644
--- a/src/components/index.mjs
+++ b/src/components/index.mjs
@@ -17,5 +17,6 @@ export {APP_EVENTS, ELECTRONIM_VERSION} from '../../bundles/constants.mjs';
 export {html, render, useLayoutEffect, useReducer, useState} from '../../bundles/preact.mjs';
 
 export {Checkbox, Field, HorizontalField, Select, sizes} from './form/index.mjs';
+export {DropDown} from './drop-down.mjs';
 export {Panel} from './panel.mjs';
 export {TopBar} from './top-bar.mjs';
diff --git a/src/constants/index.js b/src/constants/index.js
index 8eec872..8ef2c5a 100644
--- a/src/constants/index.js
+++ b/src/constants/index.js
@@ -28,12 +28,15 @@ const APP_EVENTS = {
   activateTab: 'activateTab',
   activateTabInContainer: 'activateTabInContainer',
   addTabs: 'addTabs',
+  appMenuOpen: 'appMenuOpen',
+  appMenuClose: 'appMenuClose',
   closeDialog: 'closeDialog',
   desktopCapturerGetSources: 'desktopCapturerGetSources',
   dictionaryGetAvailable: 'dictionaryGetAvailable',
   dictionaryGetAvailableNative: 'dictionaryGetAvailableNative',
   dictionaryGetEnabled: 'dictionaryGetEnabled',
   dictionaryGetMisspelled: 'dictionaryGetMisspelled',
+  helpOpenDialog: 'helpOpenDialog',
   notificationClick: 'notificationClick',
   reload: 'reload',
   settingsLoad: 'settingsLoad',
diff --git a/src/help/help.browser.css b/src/help/help.browser.css
index 53ff811..60589e7 100644
--- a/src/help/help.browser.css
+++ b/src/help/help.browser.css
@@ -16,7 +16,6 @@
 @import '../styles/main.css';
 
 :root {
-  --default-spacing: 6px;
   --top-bar-height: 60px;
   --toc-min-width: 250px;
   overflow: hidden;
diff --git a/src/main/index.js b/src/main/index.js
index c61e7b8..e44c228 100644
--- a/src/main/index.js
+++ b/src/main/index.js
@@ -15,7 +15,9 @@
  */
 const {BrowserWindow, Notification, app, desktopCapturer, ipcMain: ipc, nativeTheme} = require('electron');
 const {APP_EVENTS} = require('../constants');
-const {TABS_CONTAINER_HEIGHT, initTabContainer} = require('../chrome-tabs');
+const {initAppMenu, isNotAppMenu} = require('../app-menu');
+const {TABS_CONTAINER_HEIGHT, initTabContainer, isNotTabContainer} = require('../chrome-tabs');
+const {openHelpDialog} = require('../help');
 const {loadSettings, updateSettings, openSettingsDialog} = require('../settings');
 const {
   getAvailableDictionaries, getAvailableNativeDictionaries, loadDictionaries, getEnabledDictionaries
@@ -31,8 +33,7 @@ const webPreferences = {
 
 let mainWindow;
 let tabContainer;
-
-const isNotTabContainer = bv => bv.isTabContainer !== true;
+let appMenu;
 
 const fixUserDataLocation = () => {
   const userDataPath = app.getPath('userData');
@@ -69,9 +70,12 @@ const handleMainWindowResize = event => {
 
   setTimeout(() => {
     const {width: contentWidth, height: contentHeight} = window.getContentBounds();
+    if (appMenu && appMenu.setBounds) {
+      appMenu.setBounds({x: 0, y: 0, width: contentWidth, height: contentHeight});
+    }
     let totalHeight = 0;
     const isLast = (idx, array) => idx === array.length - 1;
-    window.getBrowserViews().forEach((bv, idx, array) => {
+    window.getBrowserViews().filter(isNotAppMenu).forEach((bv, idx, array) => {
       const {x: currentX, y: currentY, height: currentHeight} = bv.getBounds();
       let newHeight = currentHeight;
       if (isLast(idx, array)) {
@@ -120,7 +124,7 @@ const initTabListener = () => {
       const ipcSender = event.sender;
       tabManager.addTabs(ipcSender)(tabs);
     } else {
-      openSettingsDialog(mainWindow);
+      openSettingsDialog(mainWindow)();
     }
   });
   ipc.on(APP_EVENTS.activateTab, (_event, data) => activateTab(data.id));
@@ -134,7 +138,6 @@ const initTabListener = () => {
     activateTab(tabId);
   });
   ipc.on(APP_EVENTS.reload, handleTabReload);
-  ipc.on(APP_EVENTS.settingsOpenDialog, () => openSettingsDialog(mainWindow));
   ipc.on(APP_EVENTS.tabReorder, handleTabReorder);
   ipc.on(APP_EVENTS.zoomIn, handleZoomIn);
   ipc.on(APP_EVENTS.zoomOut, handleZoomOut);
@@ -145,6 +148,17 @@ const initDesktopCapturerHandler = () => {
   ipc.handle(APP_EVENTS.desktopCapturerGetSources, (_event, opts) => desktopCapturer.getSources(opts));
 };
 
+const appMenuOpen = () => {
+  const {width, height} = mainWindow.getContentBounds();
+  mainWindow.addBrowserView(appMenu);
+  appMenu.setBounds({x: 0, y: 0, width, height});
+};
+
+const appMenuClose = () => {
+  mainWindow.removeBrowserView(appMenu);
+  activateTab(tabManager.getActiveTab());
+};
+
 const closeDialog = () => {
   const dialogView = mainWindow.getBrowserView();
   activateTab(tabManager.getActiveTab());
@@ -163,18 +177,23 @@ const saveSettings = (_event, settings) => {
   tabContainer = initTabContainer();
 };
 
-const initDialogListeners = () => {
+const initGlobalListeners = () => {
+  ipc.on(APP_EVENTS.appMenuOpen, appMenuOpen);
+  ipc.on(APP_EVENTS.appMenuClose, appMenuClose);
   ipc.on(APP_EVENTS.closeDialog, closeDialog);
   ipc.handle(APP_EVENTS.dictionaryGetAvailable, getAvailableDictionaries);
   ipc.handle(APP_EVENTS.dictionaryGetAvailableNative, getAvailableNativeDictionaries);
   ipc.handle(APP_EVENTS.dictionaryGetEnabled, getEnabledDictionaries);
+  ipc.on(APP_EVENTS.helpOpenDialog, openHelpDialog(mainWindow));
   ipc.handle(APP_EVENTS.settingsLoad, loadSettings);
+  ipc.on(APP_EVENTS.settingsOpenDialog, openSettingsDialog(mainWindow));
   ipc.on(APP_EVENTS.settingsSave, saveSettings);
 };
 
 const browserVersionsReady = () => {
   app.userAgentFallback = userAgentForView(mainWindow);
   tabContainer = initTabContainer();
+  appMenu = initAppMenu();
 };
 
 const init = () => {
@@ -186,12 +205,12 @@ const init = () => {
     width, height, resizable: true, maximizable: true, webPreferences
   });
   mainWindow.removeMenu();
-  mainWindow.on('resize', handleMainWindowResize);
-  mainWindow.on('maximize', handleMainWindowResize);
+  ['resize', 'maximize']
+    .forEach(event => mainWindow.on(event, handleMainWindowResize));
   mainWindow.on('closed', () => app.quit());
   initTabListener();
   initDesktopCapturerHandler();
-  initDialogListeners();
+  initGlobalListeners();
   initBrowserVersions()
     .then(browserVersionsReady)
     .catch(() => {
diff --git a/src/settings/index.js b/src/settings/index.js
index 1802d8f..2900b36 100644
--- a/src/settings/index.js
+++ b/src/settings/index.js
@@ -65,7 +65,7 @@ const writeSettings = settings => {
 const updateSettings = settings =>
   writeSettings(ensureActiveTab({...loadSettings(), ...settings}));
 
-const openSettingsDialog = mainWindow => {
+const openSettingsDialog = mainWindow => () => {
   const settingsView = new BrowserView({webPreferences});
   settingsView.webContents.loadURL(`file://${__dirname}/index.html`);
   showDialog(mainWindow, settingsView);
diff --git a/src/settings/settings.browser.css b/src/settings/settings.browser.css
index 20cadce..d073549 100644
--- a/src/settings/settings.browser.css
+++ b/src/settings/settings.browser.css
@@ -16,7 +16,6 @@
 @import '../styles/main.css';
 
 :root {
-    --default-spacing: 6px;
     --border-color: #dbdbdb;
 }
 
diff --git a/src/styles/colors.css b/src/styles/base/colors.css
similarity index 96%
rename from src/styles/colors.css
rename to src/styles/base/colors.css
index 758bf56..53a0017 100644
--- a/src/styles/colors.css
+++ b/src/styles/base/colors.css
@@ -26,6 +26,7 @@
   --color-gray: #7d7d7d;
   --color-gray-darker: #4a4a4a;
   --color-neutral-muted: rgba(175, 184, 193, 0.2);
+  --color-white: #ffffff;
 }
 
 @media (prefers-color-scheme: dark) {
@@ -42,5 +43,6 @@
     --color-gray: #828282;
     --color-gray-darker: #b5b5b5;
     --color-neutral-muted: rgba(110, 118, 129, 0.4);
+    --color-white: #000000;
   }
 }
diff --git a/src/styles/base/index.css b/src/styles/base/index.css
index 0d79990..4bc6d8a 100644
--- a/src/styles/base/index.css
+++ b/src/styles/base/index.css
@@ -13,5 +13,7 @@
    See the License for the specific language governing permissions and
    limitations under the License.
  */
+@import './variables.css';
+@import './colors.css';
 @import './base.css';
 @import './typography.css';
diff --git a/src/styles/base/variables.css b/src/styles/base/variables.css
new file mode 100644
index 0000000..5e37918
--- /dev/null
+++ b/src/styles/base/variables.css
@@ -0,0 +1,19 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+:root {
+  --default-spacing: 6px;
+  --tab-container-height: 46px;
+}
diff --git a/src/styles/main.css b/src/styles/main.css
index 8beaa63..9309e44 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -13,6 +13,5 @@
    See the License for the specific language governing permissions and
    limitations under the License.
  */
-@import './colors.css';
 @import './base/index.css';
 @import './components/index.css';
diff --git a/webpack.js b/webpack.js
index fa450d1..7bdd49f 100755
--- a/webpack.js
+++ b/webpack.js
@@ -25,6 +25,7 @@ const {APP_EVENTS, ELECTRONIM_VERSION} = require('./src/constants');
 const BUNDLES_DIR = 'bundles';
 
 const PRELOAD_ENTRIES = [
+  'app-menu',
   'chrome-tabs',
   'help',
   'settings',


Test patch:
diff --git a/src/__tests__/electron.js b/src/__tests__/electron.js
new file mode 100644
index 0000000..694d5b5
--- /dev/null
+++ b/src/__tests__/electron.js
@@ -0,0 +1,43 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+
+const mockBrowserWindowInstance = () => {
+  const instance = {
+    addBrowserView: jest.fn(),
+    destroy: jest.fn(),
+    getContentBounds: jest.fn(() => ({})),
+    listeners: {},
+    loadURL: jest.fn(),
+    on: jest.fn((eventName, func) => {
+      instance.listeners[eventName] = func;
+    }),
+    removeBrowserView: jest.fn(),
+    removeMenu: jest.fn(),
+    setAutoResize: jest.fn(),
+    setBounds: jest.fn(),
+    setBrowserView: jest.fn(),
+    webContents: {
+      destroy: jest.fn(),
+      loadURL: jest.fn(),
+      on: jest.fn(),
+      openDevTools: jest.fn(),
+      send: jest.fn()
+    }
+  };
+  return instance;
+};
+
+module.exports = {mockBrowserWindowInstance};
diff --git a/src/__tests__/index.js b/src/__tests__/index.js
new file mode 100644
index 0000000..1dbe064
--- /dev/null
+++ b/src/__tests__/index.js
@@ -0,0 +1,19 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+
+module.exports = {
+  mockBrowserWindowInstance: require('./electron.js').mockBrowserWindowInstance
+};
diff --git a/src/app-menu/__tests__/app-menu.browser.test.mjs b/src/app-menu/__tests__/app-menu.browser.test.mjs
new file mode 100644
index 0000000..b2b392e
--- /dev/null
+++ b/src/app-menu/__tests__/app-menu.browser.test.mjs
@@ -0,0 +1,72 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+import {jest} from '@jest/globals';
+import {loadDOM} from '../../__tests__/index.mjs';
+import {getByText, fireEvent, waitFor} from '@testing-library/dom';
+
+describe('App Menu in Browser test suite', () => {
+  beforeEach(async () => {
+    jest.resetModules();
+    window.electron = {
+      close: jest.fn(),
+      helpOpenDialog: jest.fn(),
+      settingsOpenDialog: jest.fn()
+    };
+    await loadDOM({meta: import.meta, path: ['..', 'index.html']});
+  });
+  test('wrapper, click should close menu (BrowserView)', () => {
+    // When
+    fireEvent.click(document.querySelector('.app-menu .wrapper'));
+    // Then
+    expect(window.electron.close).toHaveBeenCalledTimes(1);
+  });
+  describe('Settings entry', () => {
+    let settings;
+    beforeEach(() => {
+      settings = getByText(document, 'Settings');
+    });
+    test('should be visible', () => {
+      expect(settings.getAttribute('class'))
+        .toMatch(/^dropdown-item*/);
+    });
+    test('click, should open settings dialog', () => {
+      // When
+      fireEvent.click(settings);
+      // Then
+      expect(window.electron.settingsOpenDialog).toHaveBeenCalledTimes(1);
+    });
+    test('hover, should activate entry', async () => {
+      // When
+      fireEvent.mouseOver(settings);
+      // Then
+      await waitFor(() => expect(settings.getAttribute('class'))
+        .toContain('is-active'));
+    });
+  });
+  describe('Help entry', () => {
+    test('should be visible', () => {
+      expect(getByText(document, 'Help').getAttribute('class'))
+        .toMatch(/^dropdown-item*/);
+    });
+    test('click, should open settings dialog', () => {
+      // When
+      fireEvent.click(getByText(document, 'Help'));
+      // Then
+      expect(window.electron.helpOpenDialog).toHaveBeenCalledTimes(1);
+    });
+  });
+});
+
diff --git a/src/app-menu/__tests__/preload.test.js b/src/app-menu/__tests__/preload.test.js
new file mode 100644
index 0000000..66b57b9
--- /dev/null
+++ b/src/app-menu/__tests__/preload.test.js
@@ -0,0 +1,69 @@
+/*
+   Copyright 2022 Marc Nuri San Felix
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
+ */
+describe('App Menu Module preload test suite', () => {
+  let electron;
+  beforeEach(() => {
+    jest.resetModules();
+    jest.mock('electron', () => ({
+      contextBridge: {exposeInMainWorld: jest.fn()},
+      ipcRenderer: {send: jest.fn()}
+    }));
+    electron = require('electron');
+  });
+  describe('preload (just for coverage and sanity, see bundle tests)', () => {
+    beforeEach(() => {
+      global.APP_EVENTS = require('../../constants').APP_EVENTS;
+      require('../preload');
+    });
+    test('creates an API', () => {
+      expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('electron', {
+        close: expect.toBeFunction(),
+        helpOpenDialog: expect.toBeFunction(),
+        settingsOpenDialog: expect.toBeFunction()
+      });
+    });
+    describe('API', () => {
+      let api;
+      beforeEach(() => {
+        api = electron.contextBridge.exposeInMainWorld.mock.calls[0][1];
+      });
+      test('close, invokes appMenuClose', () => {
+        api.close();
+        expect(electron.ipcRenderer.send).toHaveBeenCalledWith('appMenuClose');
+      });
+      test('helpOpenDialog, invokes helpOpenDialog', () => {
+        api.helpOpenDialog();
+        expect(electron.ipcRenderer.send).toHaveBeenCalledWith('helpOpenDialog');
+      });
+      test('settingsOpenDialog, invokes settingsOpenDialog', () => {
+        api.settingsOpenDialog();
+        expect(electron.ipcRenderer.send).toHaveBeenCalledWith('settingsOpenDialog');
+      });
+    });
+  });
+  describe('preload.bundle', () => {
+    beforeEach(() => {
+      require('../../../bundles/app-menu.preload');
+    });
+    test('creates an API', () => {
+      expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('electron', {
+        close: expect.toBeFunction(),
+        helpOpenDialog: expect.toBeFunction(),
+        settingsOpenDialog: expect.toBeFunction()
+      });
+    });
+  });
+});
diff --git a/src/chrome-tabs/__tests__/chrome-tabs.browser.test.mjs b/src/chrome-tabs/__tests__/chrome-tabs.browser.test.mjs
index 8fa87fc..abfab2d 100644
--- a/src/chrome-tabs/__tests__/chrome-tabs.browser.test.mjs
+++ b/src/chrome-tabs/__tests__/chrome-tabs.browser.test.mjs
@@ -190,11 +190,11 @@ describe('ChromeTabs in Browser test suite', () => {
     });
   });
   describe('Main button events', () => {
-    test('settingsButton click, should dispatch APP_EVENTS.settingsOpenDialog', () => {
+    test('menuButton click, should dispatch APP_EVENTS.appMenuOpen', () => {
       // Given
-      window.APP_EVENTS.settingsOpenDialog = 'open your settings please';
+      window.APP_EVENTS.appMenuOpen = 'open your settings please';
       // When
-      fireEvent.click(document.querySelector('.settings__button'));
+      fireEvent.click(document.querySelector('.menu__button'));
       // Then
       expect(mockIpcRenderer.send).toHaveBeenCalledWith('open your settings please');
     });
diff --git a/src/chrome-tabs/__tests__/index.test.js b/src/chrome-tabs/__tests__/index.test.js
index 48c4b0b..6d0813c 100644
--- a/src/chrome-tabs/__tests__/index.test.js
+++ b/src/chrome-tabs/__tests__/index.test.js
@@ -19,13 +19,7 @@ describe('Chrome Tabs Module module test suite', () => {
   let chromeTabs;
   beforeEach(() => {
     jest.resetModules();
-    mockBrowserView = {
-      setAutoResize: jest.fn(),
-      webContents: {
-        on: jest.fn(),
-        loadURL: jest.fn()
-      }
-    };
+    mockBrowserView = require('../../__tests__').mockBrowserWindowInstance();
     jest.mock('electron', () => ({
       BrowserView: jest.fn(() => mockBrowserView)
     }));
diff --git a/src/help/__tests__/index.test.js b/src/help/__tests__/index.test.js
index f648515..4c96c15 100644
--- a/src/help/__tests__/index.test.js
+++ b/src/help/__tests__/index.test.js
@@ -18,14 +18,7 @@ describe('Help module test suite', () => {
   let help;
   beforeEach(() => {
     jest.resetModules();
-    mockBrowserView = {
-      setAutoResize: jest.fn(),
-      setBounds: jest.fn(),
-      webContents: {
-        on: jest.fn(),
-        loadURL: jest.fn()
-      }
-    };
+    mockBrowserView = require('../../__tests__').mockBrowserWindowInstance();
     jest.mock('electron', () => ({
       BrowserView: jest.fn(() => mockBrowserView)
     }));
diff --git a/src/help/__tests__/preload.test.js b/src/help/__tests__/preload.test.js
index d8ca8aa..b2c7599 100644
--- a/src/help/__tests__/preload.test.js
+++ b/src/help/__tests__/preload.test.js
@@ -43,13 +43,13 @@ describe('Help Module preload test suite', () => {
       // Then
       await waitFor(() => expect(document.head.children.length).toBeGreaterThan(0));
       const styles = Array.from(document.querySelectorAll('style'));
-      expect(styles).toHaveLength(9);
-      expect(styles[0].innerHTML).toMatch(/:root \{.+--color-accent-fg:/s); // Variables
-      expect(styles[1].innerHTML).toContain('html.electronim,'); // Base
-      expect(styles[2].innerHTML).toContain('.electronim h1,'); // Typography
-      expect(styles[4].innerHTML).toContain('.electronim .control .checkbox {'); // CheckBox
-      expect(styles[5].innerHTML).toContain('.electronim .top-bar.navbar {'); // NavBar
-      expect(styles[8].innerHTML).toContain('.help-root {'); // Help-specific
+      expect(styles).toHaveLength(10);
+      expect(styles[1].innerHTML).toMatch(/:root \{.+--color-accent-fg:/s); // Variables
+      expect(styles[2].innerHTML).toContain('html.electronim,'); // Base
+      expect(styles[3].innerHTML).toContain('.electronim h1,'); // Typography
+      expect(styles[5].innerHTML).toContain('.electronim .control .checkbox {'); // CheckBox
+      expect(styles[6].innerHTML).toContain('.electronim .top-bar.navbar {'); // NavBar
+      expect(styles[9].innerHTML).toContain('.help-root {'); // Help-specific
     });
     test('loads document contents with valid asset URLs', () => {
       expect(window.docs).toEqual(expect.objectContaining({
diff --git a/src/main/__tests__/index.test.js b/src/main/__tests__/index.test.js
index cf431db..911c7f2 100644
--- a/src/main/__tests__/index.test.js
+++ b/src/main/__tests__/index.test.js
@@ -20,8 +20,8 @@ describe('Main module test suite', () => {
   let mockDesktopCapturer;
   let mockIpc;
   let mockNativeTheme;
-  let mockTabContainer;
   let mockSettings;
+  let appMenuModule;
   let settingsModule;
   let spellCheckModule;
   let tabManagerModule;
@@ -30,13 +30,7 @@ describe('Main module test suite', () => {
   beforeEach(() => {
     jest.resetModules();
     jest.useFakeTimers();
-    mockBrowserWindow = {
-      listeners: {},
-      on: jest.fn((eventName, func) => {
-        mockBrowserWindow.listeners[eventName] = func;
-      }),
-      removeMenu: jest.fn()
-    };
+    mockBrowserWindow = require('../../__tests__').mockBrowserWindowInstance();
     mockNotification = jest.fn();
     mockApp = {
       getPath: jest.fn(),
@@ -51,9 +45,9 @@ describe('Main module test suite', () => {
       })
     };
     mockNativeTheme = {};
-    mockTabContainer = {};
     mockSettings = {};
     jest.mock('electron', () => ({
+      BrowserView: jest.fn(() => mockBrowserWindow),
       BrowserWindow: jest.fn(() => mockBrowserWindow),
       Notification: jest.fn(() => mockNotification),
       app: mockApp,
@@ -61,21 +55,14 @@ describe('Main module test suite', () => {
       ipcMain: mockIpc,
       nativeTheme: mockNativeTheme
     }));
-    jest.mock('../../chrome-tabs', () => ({
-      TABS_CONTAINER_HEIGHT: 46,
-      initTabContainer: () => mockTabContainer
-    }));
-    require('../../chrome-tabs');
-    jest.mock('../../settings');
+    appMenuModule = require('../../app-menu');
+    jest.spyOn(appMenuModule, 'initAppMenu');
     settingsModule = require('../../settings');
-    settingsModule.loadSettings.mockImplementation(() => mockSettings);
-    settingsModule.openSettingsDialog.mockImplementation();
-    settingsModule.updateSettings.mockImplementation();
+    jest.spyOn(settingsModule, 'loadSettings').mockImplementation(() => mockSettings);
+    jest.spyOn(settingsModule, 'updateSettings').mockImplementation();
     jest.mock('../../spell-check');
     spellCheckModule = require('../../spell-check');
-    jest.mock('../../tab-manager');
     tabManagerModule = require('../../tab-manager');
-    tabManagerModule.getActiveTab.mockImplementation();
     jest.mock('../../user-agent');
     userAgentModule = require('../../user-agent');
     userAgentModule.userAgentForView.mockImplementation(() => 'UserAgent String');
@@ -149,19 +136,27 @@ describe('Main module test suite', () => {
       mockBrowserWindow.getBrowserViews = jest.fn(() => (views));
       main.init();
     });
-    test('maximize, should set browserview to fit window and store new size in configuration file', () => {
-      // Given
-      const singleView = {
-        getBounds: jest.fn(() => ({x: 0, y: 0, width: 1, height: 1})),
-        setBounds: jest.fn()
-      };
-      views.push(singleView);
-      // When
-      mockBrowserWindow.listeners.maximize({sender: mockBrowserWindow});
-      jest.runAllTimers();
-      // Then
-      expect(settingsModule.updateSettings).toHaveBeenCalledWith({width: 13, height: 37});
-      expect(singleView.setBounds).toHaveBeenCalledWith({x: 0, y: 0, width: 10, height: 34});
+    describe('maximize', () => {
+      test('single view, should set BrowserView to fit window', () => {
+        // Given
+        const singleView = {
+          getBounds: jest.fn(() => ({x: 0, y: 0, width: 1, height: 1})),
+          setBounds: jest.fn()
+        };
+        views.push(singleView);
+        // When
+        mockBrowserWindow.listeners.maximize({sender: mockBrowserWindow});
+        jest.runAllTimers();
+        // Then
+        expect(singleView.setBounds).toHaveBeenCalledWith({x: 0, y: 0, width: 10, height: 34});
+      });
+      test('should store new size in configuration file', () => {
+        // When
+        mockBrowserWindow.listeners.maximize({sender: mockBrowserWindow});
+        jest.runAllTimers();
+        // Then
+        expect(settingsModule.updateSettings).toHaveBeenCalledWith({width: 13, height: 37});
+      });
     });
     describe('resize', () => {
       test('#78: should be run in separate setTimeout timer function to resize properly in Linux', () => {
@@ -171,7 +166,40 @@ describe('Main module test suite', () => {
         expect(setTimeout).toHaveBeenCalledTimes(1);
         expect(mockBrowserWindow.getBrowserViews).not.toHaveBeenCalled();
       });
-      test('single view, should set BrowserView to fit window and store new size in configuration file', () => {
+      test('should store new size in configuration file', () => {
+        // When
+        mockBrowserWindow.listeners.resize({sender: mockBrowserWindow});
+        jest.runAllTimers();
+        // Then
+        expect(settingsModule.updateSettings).toHaveBeenCalledWith({width: 13, height: 37});
+      });
+      describe('app-menu', () => {
+        let mockAppMenu;
+        beforeEach(() => {
+          mockAppMenu = {
+            setBounds: jest.fn()
+          };
+          jest.spyOn(appMenuModule, 'initAppMenu').mockImplementation(() => mockAppMenu);
+          main.init();
+        });
+        test('should set app-menu bounds', () => {
+          // When
+          mockBrowserWindow.listeners.resize({sender: mockBrowserWindow});
+          jest.runAllTimers();
+          // Then
+          expect(mockAppMenu.setBounds).toHaveBeenCalledWith({x: 0, y: 0, width: 10, height: 34});
+        });
+        test('should ignore if undefined (app might be resized before app-menu is initialized)', () => {
+          // Given
+          mockAppMenu.setBounds = null;
+          // When
+          mockBrowserWindow.listeners.resize({sender: mockBrowserWindow});
+          jest.runAllTimers();
+          // Then
+          expect(mockBrowserWindow.setBounds).not.toHaveBeenCalled();
+        });
+      });
+      test('single view, should set BrowserView to fit window', () => {
         // Given
         const singleView = {
           getBounds: jest.fn(() => ({x: 0, y: 0, width: 1, height: 1})),
@@ -182,7 +210,6 @@ describe('Main module test suite', () => {
         mockBrowserWindow.listeners.resize({sender: mockBrowserWindow});
         jest.runAllTimers();
         // Then
-        expect(settingsModule.updateSettings).toHaveBeenCalledWith({width: 13, height: 37});
         expect(singleView.setBounds).toHaveBeenCalledWith({x: 0, y: 0, width: 10, height: 34});
       });
       test('multiple views, should set last BrowserView to fit window and store new size in configuration file', () => {
@@ -211,7 +238,8 @@ describe('Main module test suite', () => {
       let addTabsNested;
       beforeEach(() => {
         addTabsNested = jest.fn();
-        tabManagerModule.addTabs.mockImplementation(() => addTabsNested);
+        jest.spyOn(tabManagerModule, 'addTabs')
+          .mockImplementation(() => addTabsNested);
       });
       test('No tabs in settings, should open settings dialog', () => {
         // Given
@@ -222,7 +250,8 @@ describe('Main module test suite', () => {
         // Then
         expect(tabManagerModule.addTabs).not.toHaveBeenCalled();
         expect(addTabsNested).not.toHaveBeenCalled();
-        expect(settingsModule.openSettingsDialog).toHaveBeenCalledTimes(1);
+        expect(mockBrowserWindow.webContents.loadURL)
+          .toHaveBeenCalledWith(expect.stringMatching(/settings\/index.html$/));
       });
       test('Previous saved tabs in loaded settings, should add tabs to manager and activate them as they are added', () => {
         // Given
@@ -238,7 +267,8 @@ describe('Main module test suite', () => {
         expect(tabManagerModule.addTabs).toHaveBeenCalledWith(event.sender);
         expect(addTabsNested).toHaveBeenCalledTimes(1);
         expect(addTabsNested).toHaveBeenCalledWith([{id: '1337', otherInfo: 'A Tab', active: false}]);
-        expect(settingsModule.openSettingsDialog).not.toHaveBeenCalled();
+        expect(mockBrowserWindow.webContents.loadURL)
+          .not.toHaveBeenCalledWith(expect.stringMatching(/settings\/index.html$/));
       });
     });
     describe('activateTab', () => {
@@ -248,7 +278,6 @@ describe('Main module test suite', () => {
           setBounds: jest.fn(),
           webContents: {focus: jest.fn()}
         };
-        mockTabContainer.setBounds = jest.fn();
         mockBrowserWindow.getBrowserViews = jest.fn(() => ([]));
         mockBrowserWindow.setBrowserView = jest.fn();
         mockBrowserWindow.addBrowserView = jest.fn();
@@ -271,7 +300,7 @@ describe('Main module test suite', () => {
         mockIpc.listeners.activateTab({}, {id: 'validId'});
         // Then
         expect(activeTab.setBounds).toHaveBeenCalledWith({x: 0, y: 46, width: 13, height: 37});
-        expect(mockBrowserWindow.setBrowserView).toHaveBeenCalledWith(mockTabContainer);
+        expect(mockBrowserWindow.setBrowserView).toHaveBeenCalledWith(expect.objectContaining({isTabContainer: true}));
         expect(mockBrowserWindow.addBrowserView).toHaveBeenCalledWith(activeTab);
         expect(activeTab.webContents.focus).toHaveBeenCalledTimes(1);
       });
@@ -282,8 +311,8 @@ describe('Main module test suite', () => {
         // When
         mockIpc.listeners.activateTab({}, {id: 'validId'});
         // Then
-        expect(mockBrowserWindow.setBrowserView).toHaveBeenCalledBefore(mockTabContainer.setBounds);
-        expect(mockBrowserWindow.addBrowserView).toHaveBeenCalledBefore(mockTabContainer.setBounds);
+        expect(mockBrowserWindow.setBrowserView).toHaveBeenCalledBefore(mockBrowserWindow.setBounds);
+        expect(mockBrowserWindow.addBrowserView).toHaveBeenCalledBefore(mockBrowserWindow.setBounds);
         expect(mockBrowserWindow.setBrowserView).toHaveBeenCalledBefore(activeTab.setBounds);
         expect(mockBrowserWindow.addBrowserView).toHaveBeenCalledBefore(activeTab.setBounds);
       });
@@ -301,14 +330,14 @@ describe('Main module test suite', () => {
     });
     test('notificationClick, should restore window and activate tab', () => {
       // Given
-      mockTabContainer.webContents = {send: jest.fn()};
       mockBrowserWindow.restore = jest.fn();
       mockBrowserWindow.show = jest.fn();
+      jest.spyOn(tabManagerModule, 'getTab').mockImplementation();
       main.init();
       // When
       mockIpc.listeners.notificationClick({}, {tabId: 'validId'});
       // Then
-      expect(mockTabContainer.webContents.send).toHaveBeenCalledWith('activateTabInContainer', {tabId: 'validId'});
+      expect(mockBrowserWindow.webContents.send).toHaveBeenCalledWith('activateTabInContainer', {tabId: 'validId'});
       expect(mockBrowserWindow.restore).toHaveBeenCalledTimes(1);
       expect(mockBrowserWindow.show).toHaveBeenCalledTimes(1);
       expect(tabManagerModule.getTab).toHaveBeenCalledWith('validId');
@@ -393,16 +422,8 @@ describe('Main module test suite', () => {
         ]});
       });
     });
-    test('settingsOpenDialog, should open settings dialog', () => {
-      // Given
-      main.init();
-      // When
-      mockIpc.listeners.settingsOpenDialog();
-      // Then
-      expect(settingsModule.openSettingsDialog).toHaveBeenCalledTimes(1);
-    });
   });
-  describe('initDialogListeners ipc events', () => {
+  describe('initGlobalListeners ipc events', () => {
     let settingsView;
     beforeEach(() => {
       settingsView = {webContents: {destroy: jest.fn()}};
@@ -411,10 +432,11 @@ describe('Main module test suite', () => {
     describe('saveSettings', () => {
       beforeEach(() => {
         mockBrowserWindow.removeBrowserView = jest.fn();
-        mockTabContainer.webContents = {destroy: jest.fn()};
         main.init();
       });
       test('should reload settings and reset all views', () => {
+        // Given
+        jest.spyOn(tabManagerModule, 'removeAll').mockImplementation();
         // When
         mockIpc.listeners.settingsSave({}, {tabs: [{id: 1337}], enabledDictionaries: []});
         // Then
@@ -424,7 +446,7 @@ describe('Main module test suite', () => {
         expect(mockBrowserWindow.removeBrowserView).toHaveBeenCalledWith(settingsView);
         expect(tabManagerModule.removeAll).toHaveBeenCalledTimes(1);
         expect(settingsView.webContents.destroy).toHaveBeenCalledTimes(1);
-        expect(mockTabContainer.webContents.destroy).toHaveBeenCalledTimes(1);
+        expect(mockBrowserWindow.webContents.destroy).toHaveBeenCalledTimes(1);
       });
       test('should set saved theme', () => {
         // When
@@ -441,6 +463,7 @@ describe('Main module test suite', () => {
     });
     test('closeDialog, should destroy dialog view and activate current tab', () => {
       // Given
+      jest.spyOn(tabManagerModule, 'getActiveTab').mockImplementation();
       main.init();
       // When
       mockIpc.listeners.closeDialog();
@@ -449,5 +472,46 @@ describe('Main module test suite', () => {
       expect(tabManagerModule.getActiveTab).toHaveBeenCalledTimes(1);
       expect(settingsView.webContents.destroy).toHaveBeenCalledTimes(1);
     });
+    test('appMenuOpen, should show and resize app-menu', () => {
+      // Given
+      const mockAppMenu = {setBounds: jest.fn()};
+      jest.spyOn(appMenuModule, 'initAppMenu').mockImplementation(() => mockAppMenu);
+      main.init();
+      // When
+      mockIpc.listeners.appMenuOpen();
+      // Then
+      expect(mockBrowserWindow.addBrowserView).toHaveBeenCalledTimes(1);
+      expect(mockBrowserWindow.addBrowserView).toHaveBeenCalledWith(mockAppMenu);
+      expect(mockAppMenu.setBounds).toHaveBeenCalledWith(expect.objectContaining({x: 0, y: 0}));
+    });
+    test('appMenuClose, should hide app-menu', () => {
+      // Given
+      const mockAppMenu = {isTheMockAppMenu: true};
+      jest.spyOn(appMenuModule, 'initAppMenu').mockImplementation(() => mockAppMenu);
+      main.init();
+      // When
+      mockIpc.listeners.appMenuClose();
+      // Then
+      expect(mockBrowserWindow.removeBrowserView).toHaveBeenCalledTimes(1);
+      expect(mockBrowserWindow.removeBrowserView).toHaveBeenCalledWith(mockAppMenu);
+    });
+    test('settingsOpenDialog, should open settings dialog', () => {
+      // Given
+      main.init();
+      // When
+      mockIpc.listeners.settingsOpenDialog();
+      // Then
+      expect(mockBrowserWindow.webContents.loadURL)
+        .toHaveBeenCalledWith(expect.stringMatching(/settings\/index.html$/));
+    });
+    test('helpOpenDialog, should open help dialog', () => {
+      // Given
+      main.init();
+      // When
+      mockIpc.listeners.helpOpenDialog();
+      // Then
+      expect(mockBrowserWindow.webContents.loadURL)
+        .toHaveBeenCalledWith(expect.stringMatching(/help\/index.html$/));
+    });
   });
 });
diff --git a/src/settings/__tests__/index.test.js b/src/settings/__tests__/index.test.js
index 8bf5617..d6e1755 100644
--- a/src/settings/__tests__/index.test.js
+++ b/src/settings/__tests__/index.test.js
@@ -21,14 +21,7 @@ describe('Settings module test suite', () => {
   let settings;
   beforeEach(() => {
     jest.resetModules();
-    mockBrowserView = {
-      setAutoResize: jest.fn(),
-      setBounds: jest.fn(),
-      webContents: {
-        on: jest.fn(),
-        loadURL: jest.fn()
-      }
-    };
+    mockBrowserView = require('../../__tests__').mockBrowserWindowInstance();
     jest.mock('electron', () => ({
       BrowserView: jest.fn(() => mockBrowserView)
     }));
@@ -130,15 +123,17 @@ describe('Settings module test suite', () => {
   });
   describe('openSettingsDialog', () => {
     let mainWindow;
+    let openSettings;
     beforeEach(() => {
       mainWindow = {
         getContentBounds: jest.fn(() => ({width: 13, height: 37})),
         setBrowserView: jest.fn()
       };
+      openSettings = settings.openSettingsDialog(mainWindow);
     });
     test('webPreferences is sandboxed and has no node integration', () => {
       // When
-      settings.openSettingsDialog(mainWindow);
+      openSettings();
       // Then
       const BrowserView = require('electron').BrowserView;
       expect(BrowserView).toHaveBeenCalledTimes(1);
@@ -148,7 +143,7 @@ describe('Settings module test suite', () => {
     });
     test('should load settings URL', () => {
       // When
-      settings.openSettingsDialog(mainWindow);
+      openSettings();
       // Then
       expect(mockBrowserView.webContents.loadURL).toHaveBeenCalledTimes(1);
       expect(mockBrowserView.webContents.loadURL).toHaveBeenCalledWith(expect.stringMatching(/.+?\/index.html$/));
diff --git a/src/settings/__tests__/preload.test.js b/src/settings/__tests__/preload.test.js
index 7c90791..7584863 100644
--- a/src/settings/__tests__/preload.test.js
+++ b/src/settings/__tests__/preload.test.js
@@ -40,13 +40,13 @@ describe('Settings Module preload test suite', () => {
       // Then
       await waitFor(() => expect(document.head.children.length).toBeGreaterThan(0));
       const styles = Array.from(document.querySelectorAll('style'));
-      expect(styles).toHaveLength(9);
-      expect(styles[0].innerHTML).toMatch(/:root \{.+--color-accent-fg:/s); // Variables
-      expect(styles[1].innerHTML).toContain('html.electronim,'); // Base
-      expect(styles[2].innerHTML).toContain('.electronim h1,'); // Typography
-      expect(styles[4].innerHTML).toContain('.electronim .control .checkbox {'); // CheckBox
-      expect(styles[5].innerHTML).toContain('.electronim .top-bar.navbar {'); // NavBar
-      expect(styles[8].innerHTML).toContain('.settings.container {'); // Settings-specific
+      expect(styles).toHaveLength(10);
+      expect(styles[1].innerHTML).toMatch(/:root \{.+--color-accent-fg:/s); // Variables
+      expect(styles[2].innerHTML).toContain('html.electronim,'); // Base
+      expect(styles[3].innerHTML).toContain('.electronim h1,'); // Typography
+      expect(styles[5].innerHTML).toContain('.electronim .control .checkbox {'); // CheckBox
+      expect(styles[6].innerHTML).toContain('.electronim .top-bar.navbar {'); // NavBar
+      expect(styles[9].innerHTML).toContain('.settings.container {'); // Settings-specific
     });
   });
 });
diff --git a/src/settings/__tests__/settings.other.browser.test.mjs b/src/settings/__tests__/settings.other.browser.test.mjs
index d9cbbe8..07eb715 100644
--- a/src/settings/__tests__/settings.other.browser.test.mjs
+++ b/src/settings/__tests__/settings.other.browser.test.mjs
@@ -44,7 +44,7 @@ describe('Settings (Other) in Browser test suite', () => {
       expect(mockIpcRenderer.send).toHaveBeenCalledWith('settingsSave', expect.objectContaining({
         theme: 'light'
       }));
-    });
+    }, 10000);
   });
   test('ElectronIM version is visible', async () => {
     const $electronimVersion = await findByTestId(document, 'settings-electronim-version');
diff --git a/src/spell-check/__tests__/index.test.js b/src/spell-check/__tests__/index.test.js
index 3a4e2c0..92cf5ce 100644
--- a/src/spell-check/__tests__/index.test.js
+++ b/src/spell-check/__tests__/index.test.js
@@ -20,10 +20,7 @@ describe('Spell-check module test suite', () => {
   let spellCheck;
   beforeEach(() => {
     jest.resetModules();
-    mockBrowserWindow = {
-      destroy: jest.fn(),
-      loadURL: jest.fn()
-    };
+    mockBrowserWindow = require('../../__tests__').mockBrowserWindowInstance();
     mockIpc = {
       handle: jest.fn(),
       removeHandler: jest.fn()