Brazen Framework - View Layer

View layer for the Brazen user scripts framework

As of 15. 07. 2026. See the latest version.

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.sleazyfork.org/scripts/416104/1876591/Brazen%20Framework%20-%20View%20Layer.js

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Brazen Framework - View Layer
// @namespace    brazenvoid
// @version      3.5.0
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  View layer for the Brazen user scripts framework
// @grant        GM_addStyle
// ==/UserScript==

/**
 * @function GM_addStyle
 * @param {string} style
 */
GM_addStyle(
  `@keyframes brazen-fade { from { opacity: 0 } to { opacity: 1 } } #bv-resizer { align-items: center; background-color: white; border-radius: 3px; box-shadow: 3px 3px 10px rgba(0, 0, 0, 0.5); cursor: ew-resize; display: flex; height: 18px; justify-content: center; position: absolute; right: 10px; transition: background 0.3s ease; width: 25px; z-index: 5; } #bv-resizer:hover { background-color: lightgrey; } #bv-resizer-icon { color: black; font-size: 100%; pointer-events: none; } #bv-ui { backdrop-filter: blur(10px); background: rgba(255, 255, 255, 0.1); border-bottom-right-radius: 8px; border-left: 0; border-top-right-radius: 8px; bottom: 5vh; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); box-sizing: border-box; opacity: 0.3; overflow: auto; top: 5vh; z-index: 1001; } #bv-ui:hover { opacity: 1; } #restore-settings.bv-input { margin-bottom: 1rem } .show-settings.bv-section { border: 2px solid white; border-radius: 5px; box-shadow: 0 0 20px white; padding: 8px; top: 5vh; } .bv-actions { display: inline-flex; justify-content: center; padding: 0 0.25rem; text-align: center } .bv-actions .bv-button { width: auto } .bv-bg-colour { background-color: #4f535b } .bv-border-primary { border: 1px solid black } .bv-break { margin: 0.5rem 0 } .bv-button { background-color: revert; padding: 0.5rem 1rem; width: 100% } .bv-flex-column { flex-direction: column } .bv-font-primary { color: white } .bv-font-secondary { color: black } .bv-group { align-items: center; display: flex; min-height: 20px } .bv-group + .bv-group { margin-top: 1rem } .bv-group.bv-range-group, .bv-group.bv-text-group { align-items: center } .bv-group.bv-range-group > input { width: 75px } .bv-group.bv-range-group > input + input { margin-left: 5px } .bv-group.bv-textarea-group { align-items: start; flex-direction: column; overflow: hidden } .bv-group.bv-textarea-group > textarea.bv-input { margin-top: 0.5rem; resize: vertical; width: 100% } input.bv-input, select.bv-input, textarea.bv-input { box-sizing: border-box; margin: 0; padding: 0.5rem } .bv-input.bv-checkbox-radio { margin-right: 5px; scale: 2 } .bv-input.bv-text { width: 100% } .bv-label { flex-grow: 1; text-align: start } .bv-label.bv-text + .bv-input.bv-text { width: 40% } .bv-section, #bv-ui, .bv-modal-dialog, .show-settings.bv-section { --bv-font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-family: var(--bv-font-family); } .bv-section :is(button, input, select, textarea, .bv-button, .bv-tab-button, .bv-label, .bv-title, .bv-token-btn, .bv-bookmark-link), #bv-ui :is(button, input, select, textarea, .bv-button, .bv-tab-button, .bv-label, .bv-title, .bv-token-btn, .bv-bookmark-link), .bv-modal-dialog :is(button, input, select, textarea, .bv-button, .bv-modal-title, h3, p, span), .show-settings.bv-section :is(button, input) { font-family: var(--bv-font-family); } .bv-section { display: flex; flex-direction: column; font-size: 1rem; font-weight: normal; left: 0; padding: 1rem; position: fixed; z-index: 1000 } .bv-section > .bv-bottom-section { display: flex; flex-direction: column; margin-top: auto; } .bv-section > div + div { margin-top: 1rem } .bv-section hr { border: 1px solid white; margin: 1rem 0 } .bv-section button + button { margin-left: 0.25rem } .bv-section .bv-title { display: block; height: 20px; margin-bottom: 1rem; text-align: center; width: 100% } .bv-show-settings { border: 0; font-size: 0.7rem; height: 90vh; left: 0; margin: 0; padding: 0; position: fixed; top: 5vh; width: 0.2vw; writing-mode: sideways-lr; z-index: 999 } .bv-show-settings .bv-title { display: block; height: 20px; width: 100% } .bv-tab-button { background-color: inherit; border-bottom: 0; border-top-left-radius: 3px; border-top-right-radius: 3px; cursor: pointer; outline: none; padding: 0.5rem 0.75rem; transition: 0.3s } .bv-tab-button.bv-active, .bv-tab-button:hover { background-color: white; color: black; } .bv-tab-panel { animation: brazen-fade 1s; display: none; flex-direction: column; padding: 1rem } .bv-tab-panel.bv-active { display: flex } .bv-tabs-nav { display: flex; flex-wrap: wrap; overflow: hidden } .bv-bookmarks-group { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; width: 100%; } .bv-bookmark-columns { column-gap: 0.25rem; display: grid; grid-template-columns: minmax(0, 1fr) var(--bv-bookmark-action-size, 1.5rem); min-width: 0; row-gap: 0.25rem; scrollbar-gutter: stable; width: 100%; } .bv-bookmark-toolbar { align-items: center; display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; } .bv-bookmark-scroll { display: grid; grid-auto-rows: min-content; grid-column: 1 / -1; grid-template-columns: subgrid; max-height: 14rem; overflow-y: auto; row-gap: 0.25rem; } .bv-bookmark-list { display: contents; } .bv-bookmark-row { align-items: center; display: grid; grid-column: 1 / -1; grid-template-columns: subgrid; } .bv-bookmark-search { box-sizing: border-box; grid-column: 1; height: var(--bv-bookmark-action-size, 1.5rem); margin-top: 0; min-height: var(--bv-bookmark-action-size, 1.5rem); min-width: 0; width: auto; } .bv-bookmark-search.bv-input.bv-text { width: auto; } .bv-bookmark-sort { box-sizing: border-box; grid-column: 2; height: var(--bv-bookmark-action-size, 1.5rem); margin: 0; min-height: var(--bv-bookmark-action-size, 1.5rem); min-width: 0; padding: 0.25rem 0.5rem; width: var(--bv-bookmark-action-size, 1.5rem); } .bv-bookmark-main { align-items: center; display: flex; gap: 0.2rem; grid-column: 1; min-width: 0; } .bv-bookmark-main > .bv-bookmark-link { flex: 1 1 auto; } .bv-bookmark-action { align-items: center; box-sizing: border-box; display: inline-flex; flex: 0 0 auto; height: var(--bv-bookmark-action-size, 1.5rem); justify-content: center; margin: 0; min-height: var(--bv-bookmark-action-size, 1.5rem); min-width: 0; padding: 0; width: var(--bv-bookmark-action-size, 1.5rem); } .bv-bookmark-link { box-sizing: border-box; display: block; height: var(--bv-bookmark-action-size, 1.5rem); line-height: var(--bv-bookmark-action-size, 1.5rem); margin: 0; min-height: var(--bv-bookmark-action-size, 1.5rem); min-width: 0; overflow: hidden; padding: 0 0.38rem; text-align: left; text-overflow: ellipsis; white-space: nowrap; width: auto; } .bv-bookmark-remove { align-items: center; box-sizing: border-box; display: inline-flex; grid-column: 2; height: var(--bv-bookmark-action-size, 1.5rem); justify-content: center; line-height: 1; margin: 0; min-height: var(--bv-bookmark-action-size, 1.5rem); min-width: 0; padding: 0.25rem 0.5rem; width: var(--bv-bookmark-action-size, 1.5rem); } .bv-bookmark-empty { font-size: 0.85rem; grid-column: 1 / -1; margin-top: 0.15rem; } .bv-bookmark-toolbar button + button, .bv-bookmark-row button + button { margin-left: 0; } @supports not (grid-template-columns: subgrid) { .bv-bookmark-columns { scrollbar-gutter: auto; } .bv-bookmark-toolbar, .bv-bookmark-row { column-gap: 0.25rem; display: grid; grid-column: 1 / -1; grid-template-columns: minmax(0, 1fr) var(--bv-bookmark-action-size, 1.5rem); } .bv-bookmark-scroll { display: block; } .bv-bookmark-list { display: flex; flex-direction: column; gap: 0.25rem; } .bv-bookmark-toolbar { box-sizing: border-box; padding-inline-end: var(--bv-bookmark-scroll-inset, 0px); } } .bv-modal-overlay { align-items: center; background: rgba(0, 0, 0, 0.55); display: flex; inset: 0; justify-content: center; position: fixed; z-index: 10002; } .bv-modal-dialog { background: #4f535b; border: 1px solid black; border-radius: 4px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); color: white; max-height: 80vh; max-width: 90vw; min-width: 280px; overflow: auto; padding: 1rem; } .bv-modal-header { align-items: center; display: flex; gap: 0.5rem; justify-content: space-between; margin-bottom: 0.75rem; } .bv-modal-title { font-size: 1rem; font-weight: 500; margin: 0; } .bv-modal-close { padding: 0.25rem 0.5rem; width: auto; } .bv-modal-dialog .bv-button { background-color: #3a3f47; border: 1px solid #222; color: #fff; cursor: pointer; width: auto; } .bv-modal-dialog .bv-button:hover { background-color: #4a505a; } .bv-modal-body ul:not(.bv-compliance-rule-list) { margin: 0 0 1rem; padding-left: 1.25rem; } .bv-modal-body h3 { font-size: 0.9rem; margin: 0 0 0.35rem; } .bv-compliance-rule-list { list-style: none; margin: 0 0 1rem; padding: 0; } .bv-compliance-rule-row { align-items: center; display: flex; gap: 0.5rem; justify-content: space-between; margin-bottom: 0.35rem; } .bv-compliance-rule-label { flex: 1 1 auto; min-width: 0; } .bv-compliance-rule-remove { flex: 0 0 auto; padding: 0.2rem 0.45rem; width: auto; } .bv-dock-slide-panel { box-sizing: border-box; display: flex; flex-direction: column; position: fixed; z-index: 1001; } .bv-dock-slide-panel-head { align-items: center; display: flex; flex-shrink: 0; gap: 0.5rem; justify-content: space-between; margin-bottom: 0.35rem; } .bv-dock-slide-panel-title { font-size: 0.85rem; font-weight: 500; margin: 0; } .bv-dock-slide-panel-close { padding: 0.2rem 0.45rem; width: auto; } .bv-dock-slide-panel-body { flex: 1 1 auto; min-height: 0; overflow: auto; } .bv-dock-slide-panel-body h3 { font-size: 0.78rem; margin: 0.5rem 0 0.25rem; } .bv-dock-slide-panel-body .bv-compliance-rule-list { list-style: none; margin: 0 0 0.5rem; padding: 0; } .bv-dock-slide-panel-body .bv-compliance-rule-row { align-items: center; display: flex; gap: 0.35rem; justify-content: space-between; margin-bottom: 0.25rem; } .bv-dock-slide-panel-body .bv-compliance-rule-label { flex: 1 1 auto; min-width: 0; } .bv-dock-slide-panel-body .bv-compliance-rule-remove { flex: 0 0 auto; margin-left: 0; width: auto; } .bv-dock-slide-panel.bv-dock-panel-left { border-bottom-left-radius: 0; border-top-left-radius: 0; } .bv-dock-slide-panel.bv-dock-panel-right { border-bottom-right-radius: 0; border-top-right-radius: 0; } .bv-dock-slide-panel.bv-dock-panel-bottom { border-bottom-left-radius: 0; border-bottom-right-radius: 0; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); } .bv-dock-slide-panel.bv-dock-panel-animate { transition: transform 0.25s ease, opacity 0.25s ease; } .bv-dock-slide-panel.bv-dock-panel-offscreen { pointer-events: none; } .bv-dock-slide-panel.bv-dock-panel-hidden { display: none !important; } .bv-tag-actions-slot { display: inline-flex; position: relative; } .bv-tag-actions-slot .tag-count[hidden] { display: none; } .bv-tag-actions { align-items: center; display: inline-flex; gap: 0.15rem; } .bv-tag-actions[hidden] { display: none; } .bv-dm-selected { box-shadow: inset 0 0 0 2px var(--bv-theme-color, #778c77); border-radius: 2px; } .bv-dock-progress-slot { align-items: stretch; display: flex; flex: 0 0 auto; flex-direction: column; gap: 0.15rem; justify-content: center; max-width: calc(var(--bv-dock-btn-size) * 2.2); min-width: calc(var(--bv-dock-btn-size) * 1.6); padding: 0.15rem 0.2rem; } .bv-dock-progress-slot.bv-dock-progress-hidden { display: none; } .bv-dock-progress-bar { background: rgba(0, 0, 0, 0.25); border-radius: 3px; height: 0.35rem; overflow: hidden; width: 100%; } .bv-dock-progress-bar > span { background: var(--bv-dock-btn-active-bg, #5a6f5a); display: block; height: 100%; transition: width 0.2s ease; } .bv-dock-progress-label { color: #e8efe8; font-size: 0.58rem; line-height: 1.2; text-align: right; white-space: nowrap; } .bv-tag-discovery-list { list-style: none; margin: 0; padding: 0; } .bv-tag-discovery-row { align-items: center; display: flex; gap: 0.35rem; justify-content: space-between; margin-bottom: 0.35rem; } .bv-tag-discovery-row.bv-tag-muted .bv-tag-discovery-name { color: #8a8a8a; } .bv-tag-discovery-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bv-tag-discovery-actions { align-items: center; display: inline-flex; flex: 0 0 auto; gap: 0.1rem; } .bv-dock { --bv-dock-bg: var(--bv-theme-color, #4f535b); --bv-dock-btn-bg: #3a3f47; --bv-dock-btn-hover-bg: #4a505a; --bv-dock-btn-active-bg: #5a6f5a; --bv-dock-btn-size: 2.75rem; --bv-dock-child-btn-size: 2.15rem; --bv-dock-gap: 0.2rem; --bv-dock-slideout-bg: rgba(0, 0, 0, 0.22); --bv-dock-slideout-gap: 0.12rem; align-items: stretch; background: var(--bv-dock-bg); border: 1px solid rgba(0, 0, 0, 0.35); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45); box-sizing: border-box; color: #e8efe8; display: flex; font-family: var(--bv-font-family); gap: var(--bv-dock-gap); padding: 0.35rem; position: fixed; z-index: 1002; } .bv-dock-left { align-items: flex-start; flex-direction: row; left: 0; top: 5vh; } .bv-dock-right { align-items: flex-start; flex-direction: row-reverse; right: 0; top: 5vh; } .bv-dock-bottom { bottom: 0; flex-direction: column-reverse; left: 50%; max-width: 96vw; overflow: visible; transform: translateX(-50%); } .bv-dock-name { align-items: center; display: flex; font-size: 0.68rem; font-weight: 500; justify-content: center; letter-spacing: 0.02em; line-height: 1.2; min-width: 1.1rem; opacity: 0.9; padding: 0.15rem 0.1rem; text-align: center; white-space: nowrap; } .bv-dock-left .bv-dock-name, .bv-dock-right .bv-dock-name { writing-mode: sideways-rl; } .bv-dock-rail { align-items: stretch; display: flex; flex: 1 1 auto; gap: var(--bv-dock-gap); min-height: 0; min-width: 0; } .bv-dock-rail-head, .bv-dock-rail-body, .bv-dock-rail-foot { display: contents; } .bv-dock-separator { background: rgba(255, 255, 255, 0.12); flex: 0 0 auto; } .bv-dock-left .bv-dock-separator, .bv-dock-right .bv-dock-separator { height: 1px; width: 100%; } .bv-dock-bottom .bv-dock-separator { align-self: stretch; width: 1px; } .bv-dock-status { align-items: center; background: var(--bv-dock-btn-hover-bg, #4a505a); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 4px; box-sizing: border-box; color: #e8efe8; display: inline-flex; font-size: 0.62rem; font-weight: 500; gap: 0.3rem; line-height: 1.2; max-width: calc(var(--bv-dock-btn-size) * 2.5); opacity: 0.95; padding: 0.3rem 0.35rem; pointer-events: none; text-align: center; user-select: none; white-space: normal; } .bv-dock-status-spinner { animation: bv-dock-spin 0.9s linear infinite; border: 2px solid rgba(255, 255, 255, 0.25); border-radius: 50%; border-top-color: rgba(255, 255, 255, 0.9); flex: 0 0 auto; height: 0.75rem; width: 0.75rem; } @keyframes bv-dock-spin { to { transform: rotate(360deg); } } .bv-dock-left .bv-dock-rail { align-self: flex-start; flex-direction: column; } .bv-dock-left .bv-dock-slot { align-items: stretch; display: flex; flex-direction: column; flex: 0 0 auto; gap: 0; width: 100%; } .bv-dock-left .bv-dock-slot:hover, .bv-dock-left .bv-dock-slot:focus-within { gap: var(--bv-dock-slideout-gap); } .bv-dock-left .bv-dock-slot > .bv-dock-btn { width: 100%; } .bv-dock-left .bv-dock-slideout { flex-direction: column; max-height: 0; transition: max-height 0.2s ease, opacity 0.2s ease, padding 0.2s ease; } .bv-dock-left .bv-dock-slot:hover .bv-dock-slideout, .bv-dock-left .bv-dock-slot:focus-within .bv-dock-slideout { max-height: calc(var(--bv-dock-child-btn-size) * 6 + 0.75rem); opacity: 1; padding: 0.12rem; pointer-events: auto; } .bv-dock-right .bv-dock-rail { align-self: flex-start; flex-direction: column; } .bv-dock-right .bv-dock-slot { align-items: stretch; display: flex; flex-direction: column; flex: 0 0 auto; gap: 0; width: 100%; } .bv-dock-right .bv-dock-slot:hover, .bv-dock-right .bv-dock-slot:focus-within { gap: var(--bv-dock-slideout-gap); } .bv-dock-right .bv-dock-slot > .bv-dock-btn { width: 100%; } .bv-dock-right .bv-dock-slideout { flex-direction: column; max-height: 0; transition: max-height 0.2s ease, opacity 0.2s ease, padding 0.2s ease; } .bv-dock-right .bv-dock-slot:hover .bv-dock-slideout, .bv-dock-right .bv-dock-slot:focus-within .bv-dock-slideout { max-height: calc(var(--bv-dock-child-btn-size) * 6 + 0.75rem); opacity: 1; padding: 0.12rem; pointer-events: auto; } .bv-dock-bottom .bv-dock-rail { flex: 0 0 auto; flex-direction: row; flex-wrap: nowrap; overflow: visible; } .bv-dock-bottom .bv-dock-slot { align-items: center; display: flex; flex-direction: row; flex: 0 0 auto; gap: 0; } .bv-dock-bottom .bv-dock-slot:hover, .bv-dock-bottom .bv-dock-slot:focus-within { gap: var(--bv-dock-slideout-gap); } .bv-dock-bottom .bv-dock-slideout { flex-direction: row; max-height: none; max-width: 0; transition: max-width 0.2s ease, opacity 0.2s ease, padding 0.2s ease; } .bv-dock-bottom .bv-dock-slot:hover .bv-dock-slideout, .bv-dock-bottom .bv-dock-slot:focus-within .bv-dock-slideout { max-width: calc(var(--bv-dock-child-btn-size) * 6 + 0.75rem); opacity: 1; padding: 0.12rem; pointer-events: auto; } .bv-dock-btn { align-items: center; background: var(--bv-dock-btn-bg); border: 1px solid rgba(0, 0, 0, 0.35); border-radius: 4px; box-sizing: border-box; color: inherit; cursor: pointer; display: inline-flex; flex: 0 0 auto; height: var(--bv-dock-btn-size); justify-content: center; margin: 0; padding: 0.35rem; width: var(--bv-dock-btn-size); } .bv-dock-btn:hover { background: var(--bv-dock-btn-hover-bg); } .bv-dock-btn.bv-dock-btn-active { background: var(--bv-dock-btn-active-bg); box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); } .bv-dock-btn-icon { display: block; height: 1.35rem; pointer-events: none; width: 1.35rem; } .bv-dock-slideout { align-items: center; background: var(--bv-dock-slideout-bg); border-radius: 4px; box-sizing: border-box; display: flex; flex-wrap: nowrap; gap: var(--bv-dock-slideout-gap); opacity: 0; overflow: hidden; padding: 0; pointer-events: none; } .bv-dock-slideout > .bv-dock-btn { height: var(--bv-dock-child-btn-size); padding: 0.2rem; width: var(--bv-dock-child-btn-size); } .bv-dock-slideout > .bv-dock-btn .bv-dock-btn-icon { height: 1rem; width: 1rem; } .bv-dock-position-btn { flex: 0 0 auto; } #bv-ui.bv-dock-panel-left #bv-resizer, #bv-ui.bv-dock-panel-bottom #bv-resizer { left: auto; right: 10px; } #bv-ui.bv-dock-panel-right #bv-resizer { left: 10px; right: auto; } #bv-ui.bv-dock-panel-bottom { box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); }`
)

class BrazenViewLayer
{
/**
 * @typedef {{id: string, label: string, tags: string, url: string}} BrazenBookmarkEntry
 */

/**
 * @typedef {{
 *   getCurrentUrl?: function(): string,
 *   normalizeUrl?: function(string): string,
 *   onMatchChange: function(boolean): void,
 *   watchSelectors?: string[],
 *   isActive?: function(): boolean,
 * }} BrazenBookmarksPageMatchOptions
 */

/**
 * @typedef {{
 *   showAddButton?: boolean,
 *   addCaption?: string,
 *   addHelpText?: string,
 *   searchPlaceholder?: string,
 *   searchHelpText?: string,
 *   sortHelpText?: string,
 *   emptyMessage?: string,
 *   noMatchMessage?: string,
 *   pageMatch?: BrazenBookmarksPageMatchOptions,
 *   onAdd?: Function,
 *   onSort?: Function,
 *   onRemove?: function(string): void,
 *   onNavigate?: function(string): void,
 *   getRowActions?: function(BrazenBookmarkEntry): (HTMLElement|JQuery)[],
 * }} BrazenBookmarksPanelOptions
 */

/**
 * @typedef {{
 *   element: JQuery,
 *   render: function(BrazenBookmarkEntry[]): void,
 *   getFilterQuery: function(): string,
 *   isCurrentPageBookmarked: function(string=): boolean,
 *   checkPageMatch: function(): void,
 * }} BrazenBookmarksPanel
 */

/**
 * @typedef {{
 *   sidebarSelector?: string,
 *   rowSelector?: string,
 *   countSelector?: string,
 *   hideDelayMs?: number,
 *   extractTag?: function(Element): *,
 *   renderActions?: function(Element, *, Element, *): void,
 *   isBookmarked?: function(string): boolean,
 * }} TagSidebarCountEnhancementOptions
 */

  // -------------------------------------------------------------------------
  // Static public methods
  // -------------------------------------------------------------------------

/**
 * @param {JQuery} nodes
 */
static appendToBody(nodes)
{
  $('body').append(nodes)
}

/**
 * @param {string} name
 * @return {SVGSVGElement}
 */
static getDockIcon(name)
{
  let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
  svg.setAttribute('viewBox', '0 0 24 24')
  svg.setAttribute('class', 'bv-dock-btn-icon')
  svg.setAttribute('aria-hidden', 'true')

  let path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
  path.setAttribute('fill', 'none')
  path.setAttribute('stroke', 'currentColor')
  path.setAttribute('stroke-width', '1.75')
  path.setAttribute('stroke-linecap', 'round')
  path.setAttribute('stroke-linejoin', 'round')

  switch (name) {
    case 'menu':
      path.setAttribute('d', 'M4 7h16M4 12h16M4 17h16')
      break
    case 'bookmark':
      path.setAttribute('d', 'M6 4h12v16l-6-4-6 4V4z')
      break
    case 'download':
      path.setAttribute('d', 'M12 4v10m0 0l4-4m-4 4l-4-4M5 20h14')
      break
    case 'next':
      path.setAttribute('d', 'M6 7l5 5-5 5M13 7l5 5-5 5')
      break
    case 'explore':
      svg.innerHTML = ''
      let ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
      ring.setAttribute('cx', '12')
      ring.setAttribute('cy', '12')
      ring.setAttribute('r', '8')
      ring.setAttribute('fill', 'none')
      ring.setAttribute('stroke', 'currentColor')
      ring.setAttribute('stroke-width', '1.5')
      let needle = document.createElementNS('http://www.w3.org/2000/svg', 'path')
      needle.setAttribute('d', 'M12 6.5L14.5 12L12 17.5L9.5 12Z')
      needle.setAttribute('fill', 'none')
      needle.setAttribute('stroke', 'currentColor')
      needle.setAttribute('stroke-width', '1.5')
      needle.setAttribute('stroke-linejoin', 'round')
      svg.append(ring, needle)
      return svg
    case 'clear':
      path.setAttribute('d', 'M4 7h16M6 7l1 14h10l1-14M9 7V5h6v2')
      break
    case 'filter':
      path.setAttribute('d', 'M4 6h16M7 12h10M10 18h4')
      break
    case 'tags':
      path.setAttribute('d', 'M7 7h.01M4 6l8.5 8.5a2 2 0 0 0 2.8 0l5.2-5.2a2 2 0 0 0 0-2.8L12 2 4 10a2 2 0 0 0 0 2.8z')
      break
    case 'inject':
      path.setAttribute('d', 'M11 6H4M11 10H4M11 14H7M18 9v6m-3-3h6')
      break
    case 'block':
      path.setAttribute('d', 'M6 6l12 12M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18')
      break
    case 'resolution':
      path.setAttribute('d', 'M5 6h14v12H5z M8 10h8M8 14h5')
      break
    case 'rules':
      path.setAttribute('d', 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01')
      break
    case 'history':
      path.setAttribute('d', 'M12 8v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z')
      break
    case 'target':
      path.setAttribute('d', 'M12 3v3m0 12v3m9-9h-3M6 12H3m14.3-5.3l-2.1 2.1M8.8 15.2l-2.1 2.1m12.6 0l-2.1-2.1M8.8 8.8L6.7 6.7')
      break
    case 'dock-left':
      path.setAttribute('d', 'M5 5v14M9 7h10M9 12h10M9 17h10')
      break
    case 'dock-right':
      path.setAttribute('d', 'M19 5v14M15 7H5M15 12H5M15 17H5')
      break
    case 'dock-bottom':
      path.setAttribute('d', 'M5 19h14M7 15V5M12 15V5M17 15V5')
      break
    default:
      path.setAttribute('d', 'M12 12h.01')
      break
  }

  svg.append(path)
  return svg
}

  // -------------------------------------------------------------------------
  // Private class variables
  // -------------------------------------------------------------------------

/**
 * @type {boolean}
 * @private
 */
_resizing = false

/**
 * @type {JQuery}
 * @private
 */
_section = null

  // -------------------------------------------------------------------------
  // Constructor
  // -------------------------------------------------------------------------

/**
 * @param {string} selectorPrefix
 */
constructor(selectorPrefix)
{
  this._selectorGenerator = new SelectorGenerator(selectorPrefix)
  this._selectorPrefix = selectorPrefix
}

  // -------------------------------------------------------------------------
  // Public class methods
  // -------------------------------------------------------------------------

/**
 * @param {JQuery[]} children
 * @return {JQuery}
 */
createBottomSection(children)
{
  return $('<div class="bv-bottom-section">').append(...children)
}

/**
 * @return {JQuery}
 */
createBreakSeparator()
{
  return $('<br class="bv-break"/>')
}

/**
 * @return {JQuery}
 */
createContainer()
{
  this._section = $('<section class="bv-section bv-font-primary">')
  return this._section
}

/**
 * @param {JQuery|JQuery[]} children
 * @param {string} wrapperClasses
 * @return {JQuery}
 */
createFormActions(children, wrapperClasses = '')
{
  return $('<div class="bv-actions"/>').addClass(wrapperClasses).append(children)
}

/**
 * @param {string} caption
 * @param {JQuery.EventHandler} onClick
 * @param {string} hoverHelp
 * @return {JQuery}
 */
createFormButton(caption, hoverHelp, onClick)
{
  return $('<button class="bv-button">').attr('title', hoverHelp).text(caption).on('click', onClick)
}

createFormCheckBoxesGroupSection(label, keyValuePairs, hoverHelp)
{
  let section = this.createFormSection(label).addClass('bv-checkboxes-group').attr('title', hoverHelp)
  for (const element of keyValuePairs) {
    section.append(
        this.createFormGroup().append([
          this.createFormGroupLabel(element[0], 'checkbox'),
          this.createFormGroupInput('checkbox').attr('data-value', element[1]),
        ]),
    )
  }
  return section
}

/**
 * @return {JQuery}
 */
createFormGroup()
{
  return $('<div class="bv-group"/>')
}

/**
 * @param {string} id
 * @param {Array} keyValuePairs
 *
 * @return {JQuery}
 */
createFormGroupDropdown(id, keyValuePairs)
{
  let dropdown = $('<select>').attr('id', id).addClass('bv-input')

  for (let i = 0; i < keyValuePairs.length; i++) {
    dropdown.append($('<option>').attr('value', keyValuePairs[i][0]).text(keyValuePairs[i][1]).prop('selected', (i === 0)))
  }
  return dropdown
}

/**
 * @param {string} type
 *
 * @return {JQuery}
 */
createFormGroupInput(type)
{
  let input = $('<input class="bv-input">').attr('type', type)
  switch (type) {
    case 'number':
    case 'text':
      input.addClass('bv-text')
      break

    case 'radio':
    case 'checkbox':
      input.addClass('bv-checkbox-radio')
      break
  }
  return input
}

/**
 * @param {string} label
 * @param {string} inputType
 * @return {JQuery}
 */
createFormGroupLabel(label, inputType = '')
{
  let labelFormGroup = $('<label class="bv-label">').text(label)
  if (inputType !== '') {
    switch (inputType) {
      case 'number':
      case 'text':
        labelFormGroup.addClass('bv-text')
        labelFormGroup.text(labelFormGroup.text() + ': ')
        break
      case 'radio':
      case 'checkbox':
        labelFormGroup.addClass('bv-checkbox-radio')
        break
    }
  }
  return labelFormGroup
}

/**
 * @param {string} statisticType
 * @return {JQuery}
 */
createFormGroupStatLabel(statisticType)
{
  return $('<label class="bv-stat-label">').attr('id', this._selectorGenerator.getStatLabelSelector(statisticType)).text('0')
}

/**
 * @param {string} label
 * @param {string} inputType
 * @param {string} hoverHelp
 * @return {JQuery}
 */
createFormInputGroup(label, inputType, hoverHelp = '')
{
  return this.createFormGroup().attr('title', hoverHelp).append([
    this.createFormGroupLabel(label, inputType),
    this.createFormGroupInput(inputType),
  ])
}

createFormRadiosGroupSection(label, keyValuePairs, hoverHelp)
{
  let section = this.createFormSection(label).addClass('bv-radios-group').attr('title', hoverHelp)
  for (let i = 0; i < keyValuePairs.length; i++) {
    section.append(
        this.createFormGroup().append([
          this.createFormGroupLabel(keyValuePairs[i][0], 'radio'),
          this.createFormGroupInput('radio').prop('checked', i === 0).attr('data-value', keyValuePairs[i][1]).on('change', (event) => {
            $(event.currentTarget).parents('.bv-radios-group').first().find('input').each((index, element) => {
              if (!element.isSameNode(event.currentTarget)) {
                $(element).prop('checked', false)
              }
            })
          }),
        ]),
    )
  }
  return section
}

/**
 * @param {string} label
 * @param {string} inputsType
 * @param {number} minimum
 * @param {number} maximum
 * @param {string} hoverHelp
 * @return {JQuery}
 */
createFormRangeInputGroup(label, inputsType, minimum, maximum, hoverHelp)
{
  return this.createFormGroup().addClass('bv-range-group').attr('title', hoverHelp).append([
    this.createFormGroupLabel(label, inputsType),
    this.createFormGroupInput(inputsType).attr('min', minimum).attr('max', maximum),
    this.createFormGroupInput(inputsType).attr('min', minimum).attr('max', maximum),
  ])
}

/**
 * @param {string} title
 * @return {JQuery}
 */
createFormSection(title = '')
{
  return $('<div>').append($('<label class="bv-title">').text(title))
}

/**
 * @param {string} label
 * @param {int} rows
 * @param {string} hoverHelp
 * @return {JQuery}
 */
createFormTextAreaGroup(label, rows, hoverHelp = '')
{
  return this.createFormGroup().attr('title', hoverHelp).addClass('bv-textarea-group').append([
    this.createFormGroupLabel(label),
    $('<textarea class="bv-input" spellcheck="false">').attr('rows', rows),
  ])
}

/**
 * Searchable bookmark list with add, sort, navigate, and remove controls.
 *
 * @param {BrazenBookmarksPanelOptions} options
 * @return {BrazenBookmarksPanel}
 */
createBookmarksPanel(options = {})
{
  let settings = {
    showAddButton: true,
    addCaption: 'Add Bookmark',
    addHelpText: '',
    searchPlaceholder: 'Search bookmarks…',
    searchHelpText: 'Filter bookmarks by label or tags',
    sortHelpText: 'Sort bookmarks A–Z',
    emptyMessage: 'No bookmarks yet.',
    noMatchMessage: 'No matching bookmarks.',
    onAdd: () => {},
    onSort: () => {},
    onRemove: () => {},
    onNavigate: (url) => {
      location.href = url
    },
    getRowActions: () => [],
    ...options,
  }

  let filterQuery = ''
  let lastBookmarks = []
  let listEl = $('<div class="bv-bookmark-list">')
  let emptyEl = $('<div class="bv-bookmark-empty">').text(settings.emptyMessage)
  let searchEl = $('<input type="search" class="bv-input bv-text bv-bookmark-search">').
      attr('placeholder', settings.searchPlaceholder).
      attr('title', settings.searchHelpText)
  searchEl.on('input', () => {
    filterQuery = String(searchEl.val() ?? '')
    widget.render(lastBookmarks)
  })

  let sortButton = $('<button type="button" class="bv-bookmark-sort bv-button">').
      text('⇅').
      attr('title', settings.sortHelpText).
      on('click', () => settings.onSort())

  let toolbar = $('<div class="bv-bookmark-toolbar">').append(searchEl, sortButton)
  let scrollEl = $('<div class="bv-bookmark-scroll">')
  scrollEl.append(listEl)
  let columnsEl = $('<div class="bv-bookmark-columns">')
  columnsEl.append(toolbar, emptyEl, scrollEl)

  let syncBookmarkColumnInset = () => {
    let scrollNode = scrollEl[0]
    if (!scrollNode) {
      return
    }
    columnsEl[0].style.setProperty('--bv-bookmark-scroll-inset', `${scrollNode.offsetWidth - scrollNode.clientWidth}px`)
  }

  if (typeof ResizeObserver !== 'undefined') {
    new ResizeObserver(() => syncBookmarkColumnInset()).observe(scrollEl[0])
  }

  let elementChildren = []
  if (settings.showAddButton) {
    elementChildren.push(this.createFormButton(settings.addCaption, settings.addHelpText, () => settings.onAdd()))
  }
  elementChildren.push(columnsEl)
  let element = $('<div class="bv-bookmarks-group">').append(elementChildren)

  let widget = {
    element: element,
    getFilterQuery: () => filterQuery,
    isCurrentPageBookmarked: (url) => {
      let match = settings.pageMatch
      if (!match) {
        return false
      }
      let normalize = match.normalizeUrl ?? ((value) => String(value ?? '').trim())
      let currentUrl = normalize(url ?? (match.getCurrentUrl?.() ?? location.href))
      if (!currentUrl) {
        return false
      }
      return lastBookmarks.some((bookmark) => normalize(bookmark.url) === currentUrl)
    },
    checkPageMatch: () => {
      let match = settings.pageMatch
      if (!match) {
        return
      }
      if (match.isActive && !match.isActive()) {
        match.onMatchChange(false)
        return
      }
      match.onMatchChange(widget.isCurrentPageBookmarked())
    },
    render: (bookmarks) => {
      lastBookmarks = Array.isArray(bookmarks) ? bookmarks : []
      let query = filterQuery.trim().toLowerCase()
      let visible = query ? lastBookmarks.filter((bookmark) =>
        bookmark.label.toLowerCase().includes(query) || bookmark.tags.toLowerCase().includes(query)) : lastBookmarks

      listEl.empty()
      if (lastBookmarks.length === 0) {
        emptyEl.text(settings.emptyMessage).toggle(true)
      } else if (visible.length === 0) {
        emptyEl.text(settings.noMatchMessage).toggle(true)
      } else {
        emptyEl.toggle(false)
      }

      for (let bookmark of visible) {
        let row = $('<div class="bv-bookmark-row">')
        let main = $('<div class="bv-bookmark-main">')
        let link = $('<button type="button" class="bv-bookmark-link">').text(bookmark.label).attr('title', bookmark.tags)
        link.on('click', () => settings.onNavigate(bookmark.url))
        main.append(link)
        for (let action of settings.getRowActions(bookmark) ?? []) {
          main.append(action)
        }
        let remove = $('<button type="button" class="bv-bookmark-remove">').attr('title', 'Remove bookmark').text('×')
        remove.on('click', (event) => {
          event.stopPropagation()
          settings.onRemove(bookmark.entryId ?? bookmark.id)
        })
        row.append(main, remove)
        listEl.append(row)
      }

      syncBookmarkColumnInset()
      widget.checkPageMatch()
    },
  }

  syncBookmarkColumnInset()
  requestAnimationFrame(() => requestAnimationFrame(syncBookmarkColumnInset))

  if (settings.pageMatch) {
    let refresh = () => widget.checkPageMatch()
    for (let selector of settings.pageMatch.watchSelectors ?? []) {
      for (let element of document.querySelectorAll(selector)) {
        element.addEventListener('input', refresh)
      }
    }
    refresh()
  }

  return widget
}

createResizer()
{
  return $('<div id="bv-resizer" title="Resize"><span id="bv-resizer-icon">↔</span></div>').
      on('mousedown', (event) => {

        event.preventDefault()
        this._resizing = true

        let resizeHorizontal = (event) => {
          this._section[0].style.width = Math.max(150, event.clientX - this._section[0].getBoundingClientRect().left) + `px`
        }

        let stopResize = () => {
          removeEventListener('mousemove', resizeHorizontal)
          removeEventListener('mouseup', stopResize)
        }

        addEventListener('mousemove', resizeHorizontal)
        addEventListener('mouseup', stopResize)

      }).
      on('mouseup', (event) => {
        this._resizing = false
      })
}

createModal(title, bodyNodes)
{
  let body = $('<div class="bv-modal-body">')
  let nodes = Array.isArray(bodyNodes) ? bodyNodes : [bodyNodes]
  for (let node of nodes) {
    body.append(node instanceof $ ? node : $(node))
  }

  let dialog = $('<div class="bv-modal-dialog">').append([
    $('<div class="bv-modal-header">').append([
      $('<h2 class="bv-modal-title">').text(title),
      $('<button type="button" class="bv-modal-close bv-button" aria-label="Close">').text('×'),
    ]),
    body,
  ])

  return $('<div class="bv-modal-overlay">').append(dialog)
}

/**
 * @param {JQuery} modal
 */
showModal(modal)
{
  let $modal = $(modal)
  let hide = () => this.hideModal($modal)

  $modal.on('click', (event) => {
    if (event.target === $modal[0]) {
      hide()
    }
  })
  $modal.find('.bv-modal-close').on('click', hide)
  $(document).on('keydown.bvModal', (event) => {
    if (event.key === 'Escape') {
      hide()
    }
  })
  BrazenViewLayer.appendToBody($modal)
}

/**
 * @param {JQuery} modal
 */
hideModal(modal)
{
  $(document).off('keydown.bvModal')
  $(modal).remove()
}

/**
 * Wraps sidebar tag counts with a hover-reveal action slot.
 *
 * @param {TagSidebarCountEnhancementOptions} options
 */
enhanceTagSidebarCounts(options = {})
{
  let settings = {
    sidebarSelector: '#tag-sidebar',
    rowSelector: 'li[class*="tag-type-"]',
    countSelector: 'span.tag-count',
    hideDelayMs: 2000,
    extractTag: () => ({name: ''}),
    renderActions: () => {},
    isBookmarked: () => false,
    ...options,
  }

  let sidebar = document.querySelector(settings.sidebarSelector)
  if (!sidebar) {
    return
  }

  let reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
  let hideDelayMs = reducedMotion ? 0 : settings.hideDelayMs

  for (let row of sidebar.querySelectorAll(settings.rowSelector)) {
    let countSpan = row.querySelector(settings.countSelector)
    if (!countSpan) {
      continue
    }

    let slot = countSpan.closest('.bv-tag-actions-slot')
    let actions
    if (!slot) {
      slot = document.createElement('span')
      slot.className = 'bv-tag-actions-slot'
      countSpan.parentNode.insertBefore(slot, countSpan)
      slot.appendChild(countSpan)

      actions = document.createElement('span')
      actions.className = 'bv-tag-actions'
      actions.hidden = true
      slot.appendChild(actions)

      let hideTimer = null
      let showActions = () => {
        clearTimeout(hideTimer)
        hideTimer = null
        actions.hidden = false
        countSpan.hidden = true
        row.classList.add('bv-tag-row-active')
      }
      let hideActions = () => {
        actions.hidden = true
        countSpan.hidden = false
        row.classList.remove('bv-tag-row-active')
      }
      let scheduleHide = () => {
        clearTimeout(hideTimer)
        hideTimer = setTimeout(hideActions, hideDelayMs)
      }

      row.addEventListener('pointerenter', showActions)
      row.addEventListener('pointerleave', scheduleHide)
    } else {
      actions = slot.querySelector('.bv-tag-actions')
    }

    if (!actions) {
      continue
    }

    let tag = settings.extractTag(row)
    actions.replaceChildren()
    settings.renderActions(row, tag, actions, {isBookmarked: settings.isBookmarked})
  }
}

/**
 * @return {JQuery}
 */
createSeparator()
{
  return $('<hr/>')
}

/**
 * @return {JQuery}
 */
createSettingsHideButton()
{
  return this.createFormButton('<< Hide', '', () => this._section.css('display', 'none'))
}

/**
 * @return {JQuery}
 */
createSettingsSection()
{
  return this.createContainer().
      attr('id', 'bv-ui').
      addClass('bv-dock-slide-panel bv-bg-colour').
      hide().
      append(this.createResizer())
}

/**
 * @return {JQuery}
 */
createComplianceRulesSlidePanel()
{
  return this.createContainer().
      attr('id', 'bv-compliance-rules').
      addClass('bv-dock-slide-panel bv-bg-colour bv-dock-panel-hidden').
      css('display', 'none').
      append([
        $('<div class="bv-dock-slide-panel-head">').append([
          $('<h2 class="bv-dock-slide-panel-title">').text('Active Hide Rules'),
          $('<button type="button" class="bv-dock-slide-panel-close bv-button" aria-label="Close">').text('×'),
        ]),
        $('<div class="bv-dock-slide-panel-body">'),
      ])
}

/**
 * @param {string} caption
 * @param {JQuery} settingsSection
 *
 * @return {JQuery}
 */
createSettingsShowButton(caption, settingsSection)
{
  return $('<button class="show-settings bv-section bv-bg-colour">').
      text(caption).
      on('click', () => settingsSection.slideDown(300))
}

/**
 * @param {string} orientation
 * @param {string} nameBarText
 * @return {JQuery}
 */
createDock(orientation, nameBarText)
{
  return $('<div class="bv-dock bv-dock-' + orientation + '">').
      append($('<div class="bv-dock-name">').text(nameBarText)).
      append($('<div class="bv-dock-rail">').
          append($('<div class="bv-dock-rail-head">')).
          append($('<div class="bv-dock-rail-body">')).
          append($('<div class="bv-dock-rail-foot">')))
}

/**
 * @return {JQuery}
 */
createDockSeparator()
{
  return $('<div class="bv-dock-separator" aria-hidden="true">')
}

/**
 * @param {string} message
 * @return {JQuery}
 */
createDockMigrationStatus(message)
{
  return $('<div class="bv-dock-status" role="status" aria-live="polite">').
      append($('<span class="bv-dock-status-spinner" aria-hidden="true">')).
      append($('<span class="bv-dock-status-text">').text(message))
}

/**
 * @param {JQuery} dock
 * @param {string|null} message
 */
setDockMigrationStatus(dock, message)
{
  if (!dock?.length) {
    return
  }
  let status = dock.find('.bv-dock-status')
  if (!message) {
    status.remove()
    return
  }
  if (!status.length) {
    dock.find('.bv-dock-rail-body').prepend(this.createDockMigrationStatus(message))
    return
  }
  status.find('.bv-dock-status-text').text(message)
}

/**
 * @param {{icon?: string|HTMLElement|SVGElement, tooltip?: string, stateClass?: string, onClick?: Function}} options
 * @return {JQuery}
 */
createDockButton(options = {})
{
  let button = $('<button type="button" class="bv-dock-btn">')
  if (options.stateClass) {
    button.addClass(options.stateClass)
  }
  if (options.tooltip) {
    button.attr('title', options.tooltip)
  }
  if (options.icon) {
    if (typeof options.icon === 'string') {
      button.append(BrazenViewLayer.getDockIcon(options.icon))
    } else {
      button.append(options.icon)
    }
  }
  if (options.onClick) {
    button.on('click', (event) => {
      event.preventDefault()
      options.onClick(event)
    })
  }
  return button
}

/**
 * @param {JQuery} parentButton
 * @param {JQuery[]} childButtons
 * @return {JQuery}
 */
createDockSlideOut(parentButton, childButtons)
{
  return $('<div class="bv-dock-slot">').
      append(parentButton).
      append($('<div class="bv-dock-slideout">').append(childButtons))
}

/**
 * @param {{orientations: string[], value: string, onCycle?: Function}} options
 * @return {JQuery}
 */
createDockPositionButton(options = {})
{
  let orientationIcons = {left: 'dock-left', right: 'dock-right', bottom: 'dock-bottom'}
  let orientationLabels = {left: 'Left', right: 'Right', bottom: 'Bottom'}
  let value = options.value ?? options.orientations?.[0] ?? 'right'
  let label = orientationLabels[value] ?? value
  return this.createDockButton({
    icon: orientationIcons[value] ?? 'dock-right',
    tooltip: 'Dock position: ' + label + ' (click to change)',
    onClick: () => options.onCycle?.(),
  }).addClass('bv-dock-position-btn')
}

/**
 * @param {string} statisticsType
 * @param {string} label
 * @return {JQuery}
 */
createStatisticsFormGroup(statisticsType, label = '')
{
  if (label === '') {
    label = statisticsType.split('-').map((word) => word ? word[0].toUpperCase() + word.slice(1) : '').join(' ')
  }
  return this.createFormGroup().addClass('bv-stat-group').append([
    this.createFormGroupLabel(label + ' Filter'),
    this.createFormGroupStatLabel(statisticsType),
  ])
}

/**
 * @return {JQuery}
 */
createStatisticsTotalsGroup()
{
  return this.createFormGroup().append([
    this.createFormGroupLabel('Total'),
    this.createFormGroupStatLabel('Total'),
  ])
}

/**
 * @param {string} tabName
 * @param {boolean} isFirst
 * @return {JQuery}
 */
createTabButton(tabName, isFirst)
{
  let tabButton = $('<button class="bv-tab-button bv-border-primary">').
      text(tabName).
      on('click', (event) => {

        let button = $(event.currentTarget)
        let tabSection = button.parents('.bv-tabs-section:first')

        tabSection.find('.bv-tab-button').removeClass('bv-active bv-font-secondary').addClass('bv-font-primary')

        tabSection.find('.bv-tab-panel').removeClass('bv-active')

        button.removeClass('bv-font-primary').addClass('bv-active bv-font-secondary')

        $('#' + Utilities.toKebabCase(button.text())).addClass('bv-active')

        let root = tabSection.closest('#bv-ui')[0]
        root?.userScript?._configurationManager?.updateInterface()
      }).
      on('mouseenter', (event) => $(event.currentTarget).addClass('bv-font-secondary')).
      on('mouseleave', (event) => $(event.currentTarget).removeClass('bv-font-secondary'))

  return isFirst ? tabButton.addClass('bv-active bv-font-secondary') : tabButton.addClass('bv-font-primary')
}

/**
 * @param {string} tabName
 * @param {boolean} isFirst
 * @return {JQuery}
 */
createTabPanel(tabName, isFirst = false)
{
  let tabPanel = $('<div class="bv-tab-panel bv-border-primary">').attr('id', Utilities.toKebabCase(tabName))
  if (isFirst) {
    tabPanel.addClass('bv-active')
  }
  return tabPanel
}

/**
 * @param {string[]} tabNames
 * @param {JQuery[]} tabPanels
 * @return {JQuery}
 */
createTabsSection(tabNames, tabPanels)
{
  let tabButtons = []
  for (let i = 0; i < tabNames.length; i++) {
    tabButtons.push(this.createTabButton(tabNames[i], i === 0))
  }
  let nav = $('<div class="bv-tabs-nav">').append(tabButtons)
  return $('<div class="bv-tabs-section">').append(nav).append(...tabPanels)
}

/**
 * @param {string} title
 * @return {JQuery}
 */
createTitle(title)
{
  return $('<label class="bv-title">' + title + '</label>')
}

/**
 * @return {JQuery}
 */
getSelectedSection()
{
  return this._section
}

isSettingsPaneBeingResized()
{
  return this._resizing
}

/**
 * @param {HTMLInputElement} input
 * @param {string} text
 */
insertIntoPatternInput(input, text)
{
  let start = input.selectionStart ?? input.value.length
  let end = input.selectionEnd ?? input.value.length
  let next = input.value.slice(0, start) + text + input.value.slice(end)
  $(input).val(next).trigger('input')
  input.focus()
  let caret = start + text.length
  input.setSelectionRange(caret, caret)
  input.classList.add('bv-pattern-insert-flash')
  setTimeout(() => input.classList.remove('bv-pattern-insert-flash'), 300)
}

/**
 * @param {{token?: string, insert?: string, label: string, title: string, separator?: boolean, folderOnly?: boolean}} chip
 * @return {JQuery}
 */
createPatternTokenChip(chip)
{
  let label = chip.label ?? chip.insert ?? ''
  let insert = chip.insert ?? chip.label ?? ''
  let button = $('<button type="button" class="bv-token-btn">').text(label).attr('title', chip.title)
  if (insert) {
    button.attr('data-insert', insert)
  }
  if (chip.separator) {
    button.addClass('bv-token-sep')
  }
  if (chip.folderOnly) {
    button.addClass('bv-token-folder-sep').hide()
  }
  return button
}

/**
 * @param {{label: string, chips: {token?: string, insert?: string, label: string, title: string}[]}[]} sections
 * @param {{insert: string, label: string, title: string}[]} separators
 * @return {JQuery}
 */
createSharedPatternTokenBuilder(sections, separators)
{
  let builder = $('<div class="bv-pattern-builder bv-pattern-builder-shared">')
  let folderSeparatorButtons = $()

  for (let section of sections) {
    let row = $('<div class="bv-token-row">')
    row.append($('<span class="bv-token-row-label">').text(section.label))
    let bar = $('<div class="bv-token-bar">')
    for (let chip of section.chips) {
      bar.append(this.createPatternTokenChip(chip))
    }
    row.append(bar)
    builder.append(row)
  }

  let row = $('<div class="bv-token-row">')
  row.append($('<span class="bv-token-row-label">').text('Join'))
  let bar = $('<div class="bv-token-bar">')
  for (let chip of separators) {
    let button = this.createPatternTokenChip({...chip, separator: true, folderOnly: chip.insert === '/'})
    if (chip.insert === '/') {
      folderSeparatorButtons = folderSeparatorButtons.add(button)
    }
    bar.append(button)
  }
  row.append(bar)
  builder.append(row)

  builder.data('folderSeparatorButtons', folderSeparatorButtons)
  return builder
}

/**
 * @param {HTMLInputElement} filenameInput
 * @param {HTMLInputElement} subfolderInput
 * @param {JQuery} builder
 * @param {string} [eventNamespace]
 * @return {{ok: boolean, reason?: string, filename?: boolean, subfolder?: boolean, builder?: boolean}}
 */
patchPatternTokenBuilder(filenameInput, subfolderInput, builder, eventNamespace = 'bvpattern')
{
  if (!filenameInput || !subfolderInput || !builder) {
    return {
      ok: false,
      reason: 'pattern-builder-missing',
      filename: !!filenameInput,
      subfolder: !!subfolderInput,
      builder: !!builder,
    }
  }

  let folderSeparatorButtons = $(builder).find('.bv-token-folder-sep')
  let activeInput = filenameInput

  let activate = (input) => {
    activeInput = input
    filenameInput.classList.toggle('bv-pattern-active', input === filenameInput)
    subfolderInput.classList.toggle('bv-pattern-active', input === subfolderInput)
    folderSeparatorButtons.toggle(input === subfolderInput)
  }

  $(filenameInput).off('focusin.' + eventNamespace).on('focusin.' + eventNamespace, () => activate(filenameInput))
  $(subfolderInput).off('focusin.' + eventNamespace).on('focusin.' + eventNamespace, () => activate(subfolderInput))
  activate(filenameInput)

  $(builder).off('click.' + eventNamespace).on('click.' + eventNamespace, '.bv-token-btn', (event) => {
    event.preventDefault()
    let button = event.currentTarget
    let buttonText = button.textContent.trim()
    let insert = button.classList.contains('bv-token-sep')
        ? button.getAttribute('data-insert')
        : buttonText
    if (insert === null || insert === '') {
      return
    }
    let target = activeInput ?? filenameInput
    this.insertIntoPatternInput(target, insert)
    $(button).addClass('bv-token-flash')
    setTimeout(() => $(button).removeClass('bv-token-flash'), 180)
  })

  return {ok: true}
}

/**
 * @param {BrazenConfigurationManager} configurationManager
 * @param {string[]} configKeys
 * @return {{ok: boolean, stacked: string[]}}
 */
patchStackedPatternFieldGroups(configurationManager, configKeys)
{
  let stacked = []
  for (let key of configKeys) {
    let group = configurationManager.getField(key)?.element?.closest('.bv-group')?.[0]
    if (!group) {
      continue
    }
    group.classList.add('bv-pattern-group')
    stacked.push(key)
  }
  return {ok: true, stacked}
}

/**
 * @return {{ok: boolean, wrapped: number}}
 */
patchRangeInputLayout()
{
  let wrapped = 0
  for (let group of document.querySelectorAll('#bv-ui .bv-group.bv-range-group')) {
    if (group.querySelector('.bv-range-inputs')) {
      continue
    }
    let inputs = group.querySelectorAll(':scope > .bv-input.bv-text')
    if (inputs.length !== 2) {
      continue
    }
    let wrapper = document.createElement('div')
    wrapper.className = 'bv-range-inputs'
    group.insertBefore(wrapper, inputs[0])
    wrapper.append(inputs[0], inputs[1])
    wrapped++
  }
  return {ok: true, wrapped}
}

/**
 * @param {{onConfirm: Function}} options
 * @return {HTMLElement}
 */
static createTagDiscoveryPanel(options = {})
{
  let panel = BrazenViewLayer.createComplianceRulesSlidePanel()[0]
  panel.id = 'bv-tag-discovery-panel'
  panel.querySelector('.bv-dock-slide-panel-title').textContent = 'Tag Discovery'
  panel.querySelector('.bv-dock-slide-panel-close').addEventListener('click', () => {
    panel.classList.add('bv-dock-panel-hidden')
  })
  let body = panel.querySelector('.bv-dock-slide-panel-body')
  body.innerHTML = ''
  body.append(
      $('<p class="bv-tag-discovery-intro">').text('New tags were found. Review actions, then confirm to continue.')[0],
      $('<ul class="bv-tag-discovery-list">')[0],
      $('<div class="bv-tag-discovery-footer">').append(
          $('<button type="button" class="bv-button">').text('Confirm mappings').on('click', () => options.onConfirm?.()),
      )[0],
  )
  return panel
}

/**
 * @param {HTMLElement} panel
 * @param {Array<{name: string, type: string|null}>} tags
 * @param {function(HTMLElement, {name: string, type: string|null}, HTMLElement): void} renderActions
 */
static renderTagDiscoveryPanelContent(panel, tags, renderActions)
{
  let list = panel.querySelector('.bv-tag-discovery-list')
  if (!list) {
    return
  }
  list.innerHTML = ''
  for (let tag of tags) {
    let row = document.createElement('li')
    row.className = 'bv-tag-discovery-row'
    let name = document.createElement('span')
    name.className = 'bv-tag-discovery-name'
    name.textContent = tag.name
    let actions = document.createElement('span')
    actions.className = 'bv-tag-discovery-actions'
    row.append(name, actions)
    renderActions(row, tag, actions)
    list.append(row)
  }
}

/**
 * @param {HTMLElement} row
 * @param {{name: string, type: string|null}} tag
 * @param {HTMLElement} actionsElement
 * @param {{renderButtons: function(HTMLElement, {name: string, type: string|null}, HTMLElement, object): void, getRowState?: function({name: string, type: string|null}): {muted?: boolean, hidden?: boolean}}} config
 */
static renderTagActions(row, tag, actionsElement, config)
{
  actionsElement.innerHTML = ''
  config.renderButtons(row, tag, actionsElement, config.helpers ?? {})
  let state = config.getRowState?.(tag) ?? {}
  row.classList.toggle('bv-tag-muted', !!state.muted)
  row.classList.toggle('bv-tag-hidden', !!state.hidden)
}

/**
 * @return {JQuery}
 */
static createDownloadManagerProgressSlot()
{
  return $('<div class="bv-dock-progress-slot bv-dock-progress-hidden">').append([
    $('<div class="bv-dock-progress-bar">').append($('<span>')),
    $('<div class="bv-dock-progress-label">'),
  ])
}

/**
 * @param {JQuery} slot
 * @param {{resolution: {current: number, total: number}, download: {current: number, total: number}}} progress
 */
static updateDownloadManagerProgressSlot(slot, progress)
{
  let download = progress.download ?? {current: 0, total: 0}
  let resolution = progress.resolution ?? {current: 0, total: 0}
  let total = Math.max(download.total, resolution.total)
  let current = download.current + resolution.current
  let percent = total > 0 ? Math.round((current / total) * 100) : 0
  slot.find('.bv-dock-progress-bar > span').css('width', percent + '%')
  slot.find('.bv-dock-progress-label').text(`${current}/${total}`)
}
}