Brazen Framework - View Layer

View layer for the Brazen user scripts framework

2026/07/16のページです。最新版はこちら

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.sleazyfork.org/scripts/416104/1877597/Brazen%20Framework%20-%20View%20Layer.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Brazen Framework - View Layer
// @namespace    brazenvoid
// @version      3.6.3
// @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-donate-intro { font-size: 0.8125rem; line-height: 1.35; margin: 0 0 0.55rem; } .bv-donate-actions { display: flex; flex-direction: column; gap: 0.3rem; } a.bv-button.bv-donate-patreon { align-items: center; box-sizing: border-box; color: #fff; display: inline-flex; gap: 0.45rem; justify-content: center; text-decoration: none; width: 100%; } a.bv-button.bv-donate-patreon:hover { filter: brightness(1.08); } .bv-donate-patreon-icon { color: #ff424d; display: block; flex: 0 0 auto; height: 1.15rem; width: 1.15rem; } .bv-donate-patreon-label { line-height: 1.2; } .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-dock-list) { margin: 0 0 1rem; padding-left: 1.25rem; } .bv-modal-body h3 { font-size: 0.9rem; margin: 0 0 0.35rem; } .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.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-idb-blocked-banner { background: #6b2d2d; color: #fff; font: 13px/1.35 sans-serif; left: 0; padding: 8px 12px; position: fixed; right: 0; text-align: center; top: 0; z-index: 10001; } .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-dock-list-intro { font-size: 0.78rem; line-height: 1.35; margin: 0 0 0.5rem; } .bv-dock-list-groups { display: flex; flex-direction: column; gap: 0.35rem; } .bv-dock-list-group h3 { color: #e8efe8; font-size: 0.72rem; font-weight: 500; letter-spacing: 0.04em; margin: 0.35rem 0 0.2rem; text-transform: uppercase; } .bv-dock-list-group:first-child h3 { margin-top: 0; } .bv-dock-list { list-style: none; margin: 0; padding: 0; } .bv-dock-list-row { align-items: center; display: flex; gap: 0.35rem; justify-content: space-between; margin-bottom: 0.22rem; min-height: 1.35em; } .bv-dock-list-row.bv-tag-muted .bv-dock-list-name, .bv-dock-list-row.r34-tag-muted .bv-dock-list-name { color: #8a8a8a; } .bv-dock-list-row.bv-tag-hidden .bv-dock-list-name, .bv-dock-list-row.r34-tag-hidden .bv-dock-list-name { color: #865050; } .bv-dock-list-name { flex: 1 1 auto; font-size: 0.8125rem; line-height: 1.3; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bv-dock-list-meta { color: #9eb09e; flex: 0 0 auto; font-size: 0.72rem; line-height: 1.3; white-space: nowrap; } .bv-dock-list-section-title { color: #e8efe8; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.03em; margin: 0.55rem 0 0.25rem; } .bv-dock-list-section-title:first-child { margin-top: 0; } .bv-dock-list-row .bv-tag-actions { flex: 0 0 auto; gap: 0.1rem; } .bv-dock-list-footer { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.55rem; } .bv-dock-list-footer.bv-dock-list-footer-row { flex-direction: row; flex-wrap: wrap; } .bv-dock-list-footer.bv-dock-list-footer-row .bv-button { flex: 1 1 0; min-width: 0; width: auto; } .bv-dock-list-footer .bv-button { width: 100%; } .bv-dock-list-footer .bv-button.bv-dock-list-footer-secondary { background-color: transparent; border: 1px solid rgba(255, 255, 255, 0.25); opacity: 0.9; } .bv-dm-item-progress { align-items: center; box-sizing: border-box; display: flex; inset: 5px; justify-content: center; pointer-events: none; position: absolute; z-index: 4; } .bv-dm-item-progress-inner { align-items: center; box-sizing: border-box; display: flex; flex-direction: column; gap: 2px; justify-content: center; max-width: 100%; width: 100%; } .bv-dm-item-progress-label { color: #fff; font-size: 0.58rem; font-weight: 600; letter-spacing: 0.03em; line-height: 1.1; margin: 0; overflow: hidden; text-align: center; text-overflow: ellipsis; text-shadow: 0 0 3px rgba(0, 0, 0, 0.95), 0 1px 2px rgba(0, 0, 0, 0.85); text-transform: uppercase; white-space: nowrap; width: 100%; } .bv-dm-item-progress-steps { display: flex; gap: 1px; height: 4px; width: 100%; } .bv-dm-item-progress-step { background: rgba(255, 255, 255, 0.28); border-radius: 1px; flex: 1 1 0; min-width: 0; transition: background 0.2s ease, box-shadow 0.2s ease; } .bv-dm-item-progress-step.bv-dm-step-done { background: #39d353; } .bv-dm-item-progress-step.bv-dm-step-current { background: #9ae6a9; box-shadow: 0 0 4px rgba(57, 211, 83, 0.85); } .bv-dm-item-progress-step.bv-dm-step-failed { background: #e55353; box-shadow: 0 0 4px rgba(229, 83, 83, 0.85); } .bv-dm-item-active { position: relative; } .bv-dm-item-active.bv-dm-item-failed { box-shadow: inset 0 0 0 2px #e55353; } .bv-dm-item-active > a, .bv-dm-item-active > img, .bv-dm-item-active > video { filter: blur(2px); opacity: 0.55; transition: filter 0.1s ease, opacity 0.1s ease; } .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, .bv-dock-left .bv-dock-slideout .bv-dock-progress-label, .bv-dock-right .bv-dock-slideout .bv-dock-progress-label { 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, .bv-dock-left .bv-dock-slot.bv-dock-slideout-pinned { 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, .bv-dock-left .bv-dock-slot.bv-dock-slideout-pinned .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, .bv-dock-right .bv-dock-slot.bv-dock-slideout-pinned { 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, .bv-dock-right .bv-dock-slot.bv-dock-slideout-pinned .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, .bv-dock-bottom .bv-dock-slot.bv-dock-slideout-pinned { 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, .bv-dock-bottom .bv-dock-slot.bv-dock-slideout-pinned .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:disabled, .bv-dock-btn.bv-dock-btn-disabled { cursor: not-allowed; opacity: 0.45; pointer-events: none; } .bv-dock-btn:disabled:hover, .bv-dock-btn.bv-dock-btn-disabled:hover { background: var(--bv-dock-btn-bg); } .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-inset-group { align-items: center; background: var(--bv-dock-inset-bg, rgba(0, 0, 0, 0.38)); border-radius: 4px; box-sizing: border-box; display: flex; flex: 0 0 auto; flex-wrap: nowrap; gap: var(--bv-dock-slideout-gap); padding: 0.12rem; } .bv-dock-left .bv-dock-inset-group, .bv-dock-right .bv-dock-inset-group { align-self: center; flex-direction: column; width: var(--bv-dock-btn-size); } .bv-dock-bottom .bv-dock-inset-group { flex-direction: row; width: auto; } .bv-dock-inset-group > .bv-dock-btn { align-self: center; flex: 0 0 auto; height: var(--bv-dock-child-btn-size); padding: 0.2rem; width: var(--bv-dock-child-btn-size); } .bv-dock-inset-group > .bv-dock-btn .bv-dock-btn-icon { height: 1rem; width: 1rem; } .bv-dock-slideout > .bv-dock-progress-panel { flex: 0 0 auto; } .bv-dock-progress-panel { align-items: stretch; background: transparent; border: 1px solid var(--bv-dock-btn-bg, #3a3f47); border-radius: 4px; box-sizing: border-box; display: flex; flex: 0 0 auto; flex-direction: column; justify-content: center; min-height: var(--bv-dock-child-btn-size); padding: 0.2rem 0.28rem; } .bv-dock-progress-panel.bv-dock-progress-hidden { display: none; } .bv-dock-progress-slot { align-items: stretch; display: flex; flex: 1 1 auto; flex-direction: column; gap: 0.12rem; justify-content: flex-start; max-width: calc(var(--bv-dock-btn-size) * 2.2); min-width: calc(var(--bv-dock-btn-size) * 1.35); padding: 0; width: 100%; } .bv-dock-progress-slot.bv-dock-progress-hidden { display: none; } .bv-dock-progress-bar { background: transparent; border: 1px solid var(--bv-dock-btn-bg, #3a3f47); border-radius: 3px; flex: 0 0 auto; height: 0.35rem; order: 1; overflow: hidden; position: relative; width: 100%; } .bv-dock-progress-bar > span { background: var(--bv-dock-btn-active-bg, #5a6f5a); display: block; height: 100%; left: 0; position: absolute; top: 0; transition: height 0.2s ease, width 0.2s ease; width: 0; } .bv-dock-progress-label-wrap { display: block; flex: 0 0 auto; order: 2; width: 100%; } .bv-dock-progress-label { color: #e8efe8; display: block; font-size: 0.62rem; font-weight: 500; line-height: 1.1; opacity: 1; text-align: center; white-space: nowrap; width: 100%; } .bv-dock-bottom .bv-dock-slideout .bv-dock-progress-panel { min-width: calc(var(--bv-dock-btn-size) * 1.35); } .bv-dock-bottom .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label-wrap { text-align: right; width: 100%; } .bv-dock-bottom .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label { text-align: right; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-panel, .bv-dock-right .bv-dock-slideout .bv-dock-progress-panel { width: auto; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot, .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot { align-items: flex-end; flex: 0 0 auto; flex-direction: row; gap: 0.18rem; max-width: none; min-width: 0; width: max-content; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-bar, .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-bar { flex: 0 0 auto; height: calc(var(--bv-dock-child-btn-size) * 1.45); order: 2; width: 0.35rem; } .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-bar { order: 1; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-bar > span, .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-bar > span { bottom: 0; height: 0; top: auto; width: 100%; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label-wrap { align-self: flex-end; flex: 0 0 auto; order: 1; text-align: left; width: auto; } .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label-wrap { align-self: flex-end; flex: 0 0 auto; order: 2; text-align: right; width: auto; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label, .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label { display: block; font-size: 0.62rem; opacity: 0.9; white-space: nowrap; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label { text-align: left; } .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label { text-align: right; } .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 'eye':
      path.setAttribute('d', 'M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12z')
      svg.append(path)
      let pupil = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
      pupil.setAttribute('cx', '12')
      pupil.setAttribute('cy', '12')
      pupil.setAttribute('r', '2.25')
      pupil.setAttribute('fill', 'none')
      pupil.setAttribute('stroke', 'currentColor')
      pupil.setAttribute('stroke-width', '1.75')
      svg.append(pupil)
      return svg
    case 'eye-off':
      svg.innerHTML = ''
      let eyeOffOutline = document.createElementNS('http://www.w3.org/2000/svg', 'path')
      eyeOffOutline.setAttribute('d', 'M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12z')
      eyeOffOutline.setAttribute('fill', 'none')
      eyeOffOutline.setAttribute('stroke', 'currentColor')
      eyeOffOutline.setAttribute('stroke-width', '1.75')
      eyeOffOutline.setAttribute('stroke-linecap', 'round')
      eyeOffOutline.setAttribute('stroke-linejoin', 'round')
      let eyeOffPupil = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
      eyeOffPupil.setAttribute('cx', '12')
      eyeOffPupil.setAttribute('cy', '12')
      eyeOffPupil.setAttribute('r', '2.25')
      eyeOffPupil.setAttribute('fill', 'none')
      eyeOffPupil.setAttribute('stroke', 'currentColor')
      eyeOffPupil.setAttribute('stroke-width', '1.75')
      let eyeOffSlash = document.createElementNS('http://www.w3.org/2000/svg', 'path')
      eyeOffSlash.setAttribute('d', 'M4 4l16 16')
      eyeOffSlash.setAttribute('fill', 'none')
      eyeOffSlash.setAttribute('stroke', 'currentColor')
      eyeOffSlash.setAttribute('stroke-width', '1.75')
      eyeOffSlash.setAttribute('stroke-linecap', 'round')
      svg.append(eyeOffOutline, eyeOffPupil, eyeOffSlash)
      return svg
    case 'skip-duplicate':
      // Overlapping sheets (duplicate media) — distinct from tag-blacklist `block`.
      path.setAttribute('d', 'M9 5h8v11H9zM7 8h8v11H7z')
      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 'play':
      path.setAttribute('d', 'M8 6v12l11-6-11-6z')
      path.setAttribute('fill', 'currentColor')
      path.setAttribute('stroke', 'none')
      svg.append(path)
      return svg
    case 'pause':
      path.setAttribute('d', 'M9 7v10M15 7v10')
      break
    case 'queue-add':
      path.setAttribute('d', 'M4 7h11M4 11h11M4 15h7M17 8v8M14.5 11.5H19.5')
      break
    case 'select':
      path.setAttribute('d', 'M5 9V5h4M15 5h4v4M19 15v4h-4M9 19H5v-4')
      break
    case 'discovery':
      path.setAttribute('d', 'M10 5a5 5 0 1 1 0 10 5 5 0 0 1 0-10m6 11l4 4')
      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
}

/**
 * @return {HTMLElement|null}
 */
static ensureIdbBlockedBanner()
{
  if (document.getElementById('bv-idb-blocked-banner')) {
    return document.getElementById('bv-idb-blocked-banner')
  }
  let banner = document.createElement('div')
  banner.id = 'bv-idb-blocked-banner'
  banner.textContent = 'IndexedDB is unavailable — persistence is disabled for this session.'
  document.body.prepend(banner)
  return banner
}

/**
 * @param {HTMLElement} panel
 * @return {boolean}
 */
static isDockSlidePanelVisible(panel)
{
  return !panel.classList.contains('bv-dock-panel-hidden') &&
    panel.style.display !== 'none' &&
    !panel.classList.contains('bv-dock-panel-offscreen')
}

/**
 * @param {string} orientation
 */
static setDockSlidePanelOrientations(orientation)
{
  for (let panel of document.querySelectorAll('.bv-dock-slide-panel')) {
    panel.classList.remove('bv-dock-panel-left', 'bv-dock-panel-right', 'bv-dock-panel-bottom')
    panel.classList.add('bv-dock-panel-' + orientation)
  }
}

/**
 * @param {HTMLElement} dockElement
 * @param {string} orientation
 * @param {HTMLElement[]} panels
 */
static syncDockSlidePanelStack(dockElement, orientation, panels)
{
  if (!dockElement?.getBoundingClientRect) {
    return
  }
  let dockRect = dockElement.getBoundingClientRect()
  let anchorRect = dockRect
  for (let panel of panels) {
    if (!panel) {
      continue
    }
    BrazenViewLayer._syncDockSlidePanelPosition(panel, dockRect, orientation, anchorRect)
    if (BrazenViewLayer.isDockSlidePanelVisible(panel)) {
      anchorRect = panel.getBoundingClientRect()
    }
  }
}

/**
 * @param {HTMLElement} panel
 * @param {string} orientation
 * @param {Function|null} [beforeAnimate] Called after the panel is in layout (off-screen) and before the slide-in.
 */
static showDockSlidePanel(panel, orientation, beforeAnimate = null)
{
  BrazenViewLayer._clearDockSlidePanelHideTimer(panel)
  if (BrazenViewLayer.isDockSlidePanelVisible(panel)) {
    BrazenViewLayer._applyDockSlidePanelTransform(panel, orientation, false)
    beforeAnimate?.()
    return
  }
  // Already in the open layout (e.g. mid hide/show) — finish on-screen without restarting
  // the off→on pulse (avoids stacked open animations / delayed jerks).
  let prepared = panel.style.display === 'flex' && !panel.classList.contains('bv-dock-panel-hidden')
  panel.classList.remove('bv-dock-panel-hidden')
  panel.style.display = 'flex'
  if (prepared) {
    panel.classList.remove('bv-dock-panel-offscreen')
    BrazenViewLayer._applyDockSlidePanelTransform(panel, orientation, false)
    beforeAnimate?.()
    return
  }
  panel.classList.add('bv-dock-panel-offscreen')
  BrazenViewLayer._applyDockSlidePanelTransform(panel, orientation, true)
  beforeAnimate?.()
  panel.offsetHeight
  panel.classList.remove('bv-dock-panel-offscreen')
  BrazenViewLayer._applyDockSlidePanelTransform(panel, orientation, false)
}

/**
 * @param {HTMLElement} panel
 * @param {string} orientation
 * @param {boolean} [animate]
 * @param {Function|null} [onHidden]
 */
static hideDockSlidePanel(panel, orientation, animate = true, onHidden = null)
{
  BrazenViewLayer._clearDockSlidePanelHideTimer(panel)
  if (!BrazenViewLayer.isDockSlidePanelVisible(panel) && panel.classList.contains('bv-dock-panel-hidden')) {
    onHidden?.()
    return
  }
  panel.classList.add('bv-dock-panel-offscreen')
  BrazenViewLayer._applyDockSlidePanelTransform(panel, orientation, true)
  let finish = () => {
    panel._bvDockSlideHideTimer = null
    if (!panel.classList.contains('bv-dock-panel-offscreen')) {
      return
    }
    panel.classList.add('bv-dock-panel-hidden')
    panel.style.display = 'none'
    onHidden?.()
  }
  if (animate) {
    panel._bvDockSlideHideTimer = window.setTimeout(finish, 250)
  } else {
    finish()
  }
}

/**
 * @param {HTMLElement} panel
 * @private
 */
static _clearDockSlidePanelHideTimer(panel)
{
  if (panel?._bvDockSlideHideTimer != null) {
    window.clearTimeout(panel._bvDockSlideHideTimer)
    panel._bvDockSlideHideTimer = null
  }
}

/**
 * @param {HTMLElement} panel
 * @param {boolean} hidden
 */
static setDockSlidePanelHidden(panel, hidden)
{
  if (!panel) {
    return
  }
  panel.classList.toggle('bv-dock-panel-hidden', hidden)
}

/**
 * @param {HTMLElement} panel
 * @param {{onClose?: function(): void, onMouseLeave?: function(HTMLElement): void}} handlers
 */
static wireDockSlidePanel(panel, handlers = {})
{
  let closeButton = panel.querySelector('.bv-dock-slide-panel-close')
  if (closeButton && handlers.onClose) {
    closeButton.addEventListener('click', handlers.onClose)
  }
  if (handlers.onMouseLeave) {
    panel.addEventListener('mouseleave', (event) => {
      handlers.onMouseLeave(event.currentTarget)
    })
  }
}

/**
 * @param {{mainPanel?: HTMLElement|null, compliancePanel?: HTMLElement|null}} panels
 */
static updateDockSlidePanelToggleButtons(panels = {})
{
  let mainPanel = panels.mainPanel ?? document.getElementById('bv-ui')
  let compliancePanel = panels.compliancePanel ?? document.getElementById('bv-compliance-rules')
  $('.bv-dock-main-ui-btn').toggleClass('bv-dock-btn-active', !!(mainPanel && BrazenViewLayer.isDockSlidePanelVisible(mainPanel)))
  $('.bv-dock-compliance-rules-btn').toggleClass('bv-dock-btn-active', !!(compliancePanel && BrazenViewLayer.isDockSlidePanelVisible(compliancePanel)))
}

/**
 * @param {JQuery} slot
 * @param {boolean} visible
 */
static setDownloadManagerProgressSlotVisible(slot, visible)
{
  slot.toggleClass('bv-dock-progress-hidden', !visible)
}

/**
 * @param {HTMLElement|null|undefined} element
 * @param {{label: string, stepIndex: number, stepCount: number, failed?: boolean, complete?: boolean}|null} progress
 */
static updateDownloadManagerItemProgress(element, progress)
{
  if (!element) {
    return
  }
  if (!progress?.label || progress.stepCount == null || progress.stepIndex == null) {
    BrazenViewLayer.clearDownloadManagerItemProgress(element)
    return
  }
  element.classList.add('bv-dm-item-active')
  element.classList.toggle('bv-dm-item-failed', !!progress.failed)
  let overlay = element.querySelector('.bv-dm-item-progress')
  if (!overlay) {
    overlay = document.createElement('div')
    overlay.className = 'bv-dm-item-progress'
    overlay.setAttribute('aria-hidden', 'true')
    let inner = document.createElement('div')
    inner.className = 'bv-dm-item-progress-inner'
    let labelElement = document.createElement('div')
    labelElement.className = 'bv-dm-item-progress-label'
    let stepsElement = document.createElement('div')
    stepsElement.className = 'bv-dm-item-progress-steps'
    inner.append(labelElement, stepsElement)
    overlay.append(inner)
    element.prepend(overlay)
  }
  overlay.querySelector('.bv-dm-item-progress-label').textContent = progress.label
  let stepsElement = overlay.querySelector('.bv-dm-item-progress-steps')
  if (stepsElement.childElementCount !== progress.stepCount) {
    stepsElement.replaceChildren()
    for (let index = 0; index < progress.stepCount; index++) {
      let step = document.createElement('span')
      step.className = 'bv-dm-item-progress-step'
      stepsElement.append(step)
    }
  }
  let complete = !!progress.complete
  let failed = !!progress.failed
  stepsElement.querySelectorAll('.bv-dm-item-progress-step').forEach((step, index) => {
    step.classList.toggle('bv-dm-step-done', complete || index < progress.stepIndex)
    step.classList.toggle('bv-dm-step-current', !complete && !failed && index === progress.stepIndex)
    step.classList.toggle('bv-dm-step-failed', failed && index === progress.stepIndex)
  })
}

/**
 * @param {HTMLElement|null|undefined} element
 */
static clearDownloadManagerItemProgress(element)
{
  if (!element) {
    return
  }
  element.classList.remove('bv-dm-item-active', 'bv-dm-item-failed')
  element.querySelector('.bv-dm-item-progress')?.remove()
}

/**
 * @return {JQuery}
 */
static createDownloadManagerProgressSlot()
{
  return $('<div class="bv-dock-progress-panel">').append(
    $('<div class="bv-dock-progress-slot">').append([
      $('<div class="bv-dock-progress-bar">').append($('<span>')),
      $('<div class="bv-dock-progress-label-wrap">').append($('<span 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 total = download.total
  let current = download.current
  let percent = total > 0 ? Math.round((current / total) * 100) : 0
  let fill = slot.find('.bv-dock-progress-bar > span')
  let vertical = slot.closest('.bv-dock-left, .bv-dock-right').length > 0
  if (vertical) {
    fill.css({width: '100%', height: percent + '%'})
  } else {
    fill.css({width: percent + '%', height: '100%'})
  }
  slot.find('.bv-dock-progress-label').text(`${current}/${total}`)
}

/**
 * @param {HTMLElement} panel
 * @param {DOMRect} dockRect
 * @param {string} orientation
 * @param {DOMRect} anchorRect
 * @private
 */
static _syncDockSlidePanelPosition(panel, dockRect, orientation, anchorRect)
{
  if (orientation === 'left') {
    panel.style.left = anchorRect.right + 'px'
    panel.style.right = 'auto'
    panel.style.top = dockRect.top + 'px'
    panel.style.bottom = 'auto'
    BrazenViewLayer._applyDockSlidePanelTransform(panel, orientation, panel.classList.contains('bv-dock-panel-offscreen'))
    return
  }
  if (orientation === 'right') {
    panel.style.left = 'auto'
    panel.style.right = (window.innerWidth - anchorRect.left) + 'px'
    panel.style.top = dockRect.top + 'px'
    panel.style.bottom = 'auto'
    BrazenViewLayer._applyDockSlidePanelTransform(panel, orientation, panel.classList.contains('bv-dock-panel-offscreen'))
    return
  }
  panel.style.left = (dockRect.left + dockRect.width / 2) + 'px'
  panel.style.right = 'auto'
  panel.style.top = 'auto'
  panel.style.bottom = (window.innerHeight - anchorRect.top) + 'px'
  BrazenViewLayer._applyDockSlidePanelTransform(panel, orientation, panel.classList.contains('bv-dock-panel-offscreen'))
}

/**
 * @param {HTMLElement} panel
 * @param {string} orientation
 * @param {boolean} offscreen
 * @private
 */
static _applyDockSlidePanelTransform(panel, orientation, offscreen)
{
  panel.classList.add('bv-dock-panel-animate')
  if (orientation === 'bottom') {
    panel.style.transform = offscreen ? 'translate(-50%, 100%)' : 'translateX(-50%)'
    return
  }
  if (orientation === 'right') {
    panel.style.transform = offscreen ? 'translateX(100%)' : 'translateX(0)'
    return
  }
  panel.style.transform = offscreen ? 'translateX(-100%)' : 'translateX(0)'
}

  // -------------------------------------------------------------------------
  // 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()
}

/**
 * @param {{title: string, message: string, confirmLabel?: string, cancelLabel?: string, showCancel?: boolean}} options
 * @return {Promise<boolean>}
 */
static showConfirmDialog(options)
{
  return new Promise((resolve) => {
    let settled = false
    let finish = (value) => {
      if (settled) {
        return
      }
      settled = true
      $(document).off('keydown.bvModalConfirm')
      overlay.remove()
      resolve(value)
    }
    let overlay = $('<div class="bv-modal-overlay">')
    let dialog = $('<div class="bv-modal-dialog">')
    let header = $('<div class="bv-modal-header">').append([
      $('<h2 class="bv-modal-title">').text(options.title ?? 'Confirm'),
      $('<button type="button" class="bv-modal-close bv-button" aria-label="Close">').text('×'),
    ])
    let actionButtons = [
      $('<button type="button" class="bv-button">').text(options.confirmLabel ?? 'OK').on('click', () => finish(true)),
    ]
    if (options.showCancel !== false) {
      actionButtons.push(
          $('<button type="button" class="bv-button">').text(options.cancelLabel ?? 'Cancel').on('click', () => finish(false)),
      )
    }
    let body = $('<div class="bv-modal-body">').append([
      $('<p>').text(options.message ?? ''),
      $('<div class="bv-actions bv-flex-column">').append(actionButtons),
    ])
    dialog.append(header, body)
    overlay.append(dialog)
    overlay.on('click', (event) => {
      if (event.target === overlay[0]) {
        finish(false)
      }
    })
    dialog.find('.bv-modal-close').on('click', () => finish(false))
    $(document).on('keydown.bvModalConfirm', (event) => {
      if (event.key === 'Escape') {
        finish(false)
      }
    })
    BrazenViewLayer.appendToBody(overlay)
  })
}

/**
 * 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 = $(settings.sidebarSelector)
  if (!$sidebar.length) {
    return
  }

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

  $sidebar.find(settings.rowSelector).each((_, rowNode) => {
    let $row = $(rowNode)
    let $count = $row.find(settings.countSelector).first()
    if (!$count.length) {
      return
    }

    let $slot = $count.closest('.bv-tag-actions-slot')
    let $actions
    if (!$slot.length) {
      $slot = $('<span class="bv-tag-actions-slot">')
      $count.before($slot)
      $slot.append($count)
      $actions = $('<span class="bv-tag-actions">').prop('hidden', true)
      $slot.append($actions)

      let hideTimer = null
      let showActions = () => {
        clearTimeout(hideTimer)
        hideTimer = null
        $actions.prop('hidden', false)
        $count.prop('hidden', true)
        $row.addClass('bv-tag-row-active')
      }
      let hideActions = () => {
        $actions.prop('hidden', true)
        $count.prop('hidden', false)
        $row.removeClass('bv-tag-row-active')
      }
      let scheduleHide = () => {
        clearTimeout(hideTimer)
        hideTimer = setTimeout(hideActions, hideDelayMs)
      }

      $row.on('pointerenter', showActions).on('pointerleave', scheduleHide)
    } else {
      $actions = $slot.find('.bv-tag-actions').first()
    }

    if (!$actions.length) {
      return
    }

    let tag = settings.extractTag($row[0])
    $actions.empty()
    settings.renderActions($row[0], tag, $actions[0], {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())
}

/**
 * @param {string} id
 * @param {string} title
 * @return {JQuery}
 */
static createDockSlidePanel(id, title)
{
  return $('<section class="bv-section bv-font-primary bv-dock-slide-panel bv-bg-colour bv-dock-panel-hidden">').
      attr('id', id).
      css('display', 'none').
      append([
        $('<div class="bv-dock-slide-panel-head">').append([
          $('<h2 class="bv-dock-slide-panel-title">').text(title),
          $('<button type="button" class="bv-dock-slide-panel-close bv-button" aria-label="Close">').text('×'),
        ]),
        $('<div class="bv-dock-slide-panel-body">'),
      ])
}

/**
 * @return {JQuery}
 */
createComplianceRulesSlidePanel()
{
  let $panel = BrazenViewLayer.createDockSlidePanel('bv-compliance-rules', 'Active Hide Rules')
  $panel.find('.bv-dock-slide-panel-body').empty().append(
      $('<p class="bv-dock-list-intro">').text('Filters that hid items on this page.'),
      $('<div class="bv-dock-list-groups">'),
  )
  return $panel
}

/**
 * @param {JQuery} panel
 * @param {{filterKey: string, filterLabel: string, rules: {label: string, count: number}[]}[]} report
 * @param {{canRemoveRule?: function(string, string): boolean, onRemoveRule?: function(string, string): void}} [options]
 */
renderComplianceRulesPanelContent(panel, report, options = {})
{
  let $groups = panel.find('.bv-dock-list-groups').empty()
  let body = panel.find('.bv-dock-slide-panel-body')
  if (!$groups.length) {
    body.empty().append(
        $('<p class="bv-dock-list-intro">').text('Filters that hid items on this page.'),
        $('<div class="bv-dock-list-groups">'),
    )
    $groups = body.find('.bv-dock-list-groups')
  }
  if (!report.length) {
    $groups.append($('<p class="bv-dock-list-intro">').text('No items hidden on this page.'))
    return
  }
  for (let section of report) {
    let $list = $('<ul class="bv-dock-list">')
    for (let rule of section.rules) {
      let $actions = $('<span class="bv-tag-actions">')
      if (options.canRemoveRule?.(section.filterKey, rule.label)) {
        $actions.append(
            this.createFormButton('Remove', 'Remove this rule from the filter list.', () => {
              options.onRemoveRule?.(section.filterKey, rule.label)
            }).addClass('bv-dock-list-action-btn'),
        )
      }
      $list.append(
          $('<li class="bv-dock-list-row">').append(
              $('<span class="bv-dock-list-name">').text(rule.label),
              $('<span class="bv-dock-list-meta">').text(rule.count + ' hidden'),
              $actions,
          ),
      )
    }
    $groups.append(
        $('<section class="bv-dock-list-group">').append(
            $('<h3>').text(section.filterLabel),
            $list,
        ),
    )
  }
}

/**
 * @param {object} field
 * @param {{stateClass?: string, tooltip?: string, icon?: string, disabled?: boolean}} presentation
 */
updateDockFieldButton(field, presentation = {})
{
  if (!field.dockElement?.length) {
    return
  }
  let button = field.dockElement.hasClass('bv-dock-slot') ? field.dockElement.find('> .bv-dock-btn').first() : field.dockElement
  if (!button.length) {
    return
  }
  button.removeClass('bv-dock-btn-active bv-dock-btn-disabled')
  button.prop('disabled', false)
  if (presentation.disabled) {
    button.prop('disabled', true).addClass('bv-dock-btn-disabled')
  } else if (presentation.stateClass) {
    button.addClass(presentation.stateClass)
  }
  if (presentation.tooltip) {
    button.attr('title', presentation.tooltip)
  }
  if (typeof presentation.icon === 'string') {
    button.empty().append(BrazenViewLayer.getDockIcon(presentation.icon))
  }
}

/**
 * @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">')
}

/**
 * Mounts a dock rail body from an array of nodes (field buttons, separators, etc.).
 * @param {JQuery} railBody
 * @param {JQuery[]} items
 * @return {JQuery}
 */
mountDockRail(railBody, items)
{
  railBody.empty()
  for (let item of items) {
    if (item?.length) {
      railBody.append(item)
    }
  }
  return railBody
}

/**
 * @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.disabled) {
    button.prop('disabled', true).addClass('bv-dock-btn-disabled')
  }
  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))
}

/**
 * Always-visible inset panel (slide-out styling, darker background) for grouped dock controls.
 * @param {JQuery[]} items
 * @return {JQuery}
 */
createDockInsetGroup(items = [])
{
  let group = $('<div class="bv-dock-inset-group">')
  for (let item of items) {
    if (item?.length) {
      group.append(item)
    }
  }
  return group
}

/**
 * @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')
      }).
      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)
}

/**
 * Ready-to-append Donate tab panel (Patreon link + icon).
 * Include the matching tab name (`options.tabName`, default `Donate`) in {@link createTabsSection}.
 *
 * @param {{tabName?: string, isFirst?: boolean, patreonUrl?: string, linkLabel?: string, message?: string}} [options]
 * @return {JQuery}
 */
createDonateTabPanel(options = {})
{
  let tabName = options.tabName ?? 'Donate'
  let patreonUrl = options.patreonUrl ?? 'https://www.patreon.com/c/brazen_mvs'
  let linkLabel = options.linkLabel ?? 'Patreon'
  let message = options.message ?? 'Support Brazen scripts on Patreon.'

  let $link = $('<a class="bv-button bv-donate-patreon">').
      attr({
        href: patreonUrl,
        target: '_blank',
        rel: 'noopener noreferrer',
        title: 'Open Patreon',
      }).
      append(
          BrazenViewLayer.createPatreonIcon('bv-donate-patreon-icon'),
          $('<span class="bv-donate-patreon-label">').text(linkLabel),
      )

  return this.createTabPanel(tabName, !!options.isFirst).append(
      $('<p class="bv-donate-intro">').text(message),
      $('<div class="bv-donate-actions">').append($link),
  )
}

/**
 * @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, onSkip?: Function, onOpenMedia?: Function}} options
 * @return {HTMLElement}
 */
static createTagDiscoveryPanel(options = {})
{
  let $panel = BrazenViewLayer.createDockSlidePanel('bv-tag-discovery-panel', 'Tag Discovery')
  $panel.find('.bv-dock-slide-panel-close').remove()
  $panel.find('.bv-dock-slide-panel-body').empty().append(
      $('<p class="bv-dock-list-intro">').text('New tags were found. Review actions, then confirm to continue.'),
      $('<div class="bv-dock-list-groups">'),
      $('<div class="bv-dock-list-footer bv-dock-list-footer-row">').append(
          $('<button type="button" class="bv-button bv-dock-list-footer-secondary bv-tag-discovery-skip">').
              text('Skip').
              on('click', () => options.onSkip?.()),
          $('<button type="button" class="bv-button bv-dock-list-footer-secondary bv-tag-discovery-open-media">').
              text('Open media').
              on('click', () => options.onOpenMedia?.()),
          $('<button type="button" class="bv-button bv-tag-discovery-confirm">').
              text('Confirm').
              on('click', () => options.onConfirm?.()),
      ),
  )
  return $panel[0]
}

/**
 * Rate-limit human verification panel (processor tab). Does not auto-close on mouseleave.
 *
 * @param {{
 *   onConfirm?: Function,
 *   onReopen?: Function,
 *   title?: string,
 *   message?: string,
 *   confirmLabel?: string,
 *   reopenLabel?: string,
 *   showReopen?: boolean,
 * }} [options] `showReopen: false` builds a standalone reload pane (no reopen control).
 * @return {HTMLElement}
 */
static createHumanInteractionPanel(options = {})
{
  let showReopen = options.showReopen !== false
  let $panel = BrazenViewLayer.createDockSlidePanel(
      'bv-human-interaction-panel',
      options.title ?? 'Verification required',
  )
  $panel.find('.bv-dock-slide-panel-close').remove()
  let footerButtons = [
    $('<button type="button" class="bv-button bv-human-interaction-confirm">').
        text(options.confirmLabel ?? 'Done — resume').
        on('click', () => options.onConfirm?.()),
  ]
  if (showReopen) {
    footerButtons.push(
        $('<button type="button" class="bv-button bv-dock-list-footer-secondary bv-human-interaction-reopen">').
            text(options.reopenLabel ?? 'Reopen verification tab').
            on('click', () => options.onReopen?.()),
    )
  }
  $panel.find('.bv-dock-slide-panel-body').empty().append(
      $('<p class="bv-dock-list-intro">').text(
          options.message ??
          'Complete the verification in the new tab, then resume the queue here.',
      ),
      $('<div class="bv-dock-list-footer">').append(footerButtons),
  )
  return $panel[0]
}

/**
 * @param {HTMLElement|JQuery} panel
 * @param {{title?: string, message?: string, confirmLabel?: string, reopenLabel?: string}} content
 */
static updateHumanInteractionPanelContent(panel, content = {})
{
  let $panel = $(panel)
  if (content.title) {
    $panel.find('.bv-dock-slide-panel-title').text(content.title)
  }
  if (content.message) {
    $panel.find('.bv-dock-list-intro').text(content.message)
  }
  if (content.confirmLabel) {
    $panel.find('.bv-human-interaction-confirm').text(content.confirmLabel)
  }
  if (content.reopenLabel) {
    $panel.find('.bv-human-interaction-reopen').text(content.reopenLabel)
  }
}

/**
 * Official-style Patreon mark (circle + stem).
 * @param {string} [iconClass='bv-donate-patreon-icon']
 * @return {SVGSVGElement}
 */
static createPatreonIcon(iconClass = 'bv-donate-patreon-icon')
{
  let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
  svg.setAttribute('viewBox', '0 0 24 24')
  svg.setAttribute('class', iconClass)
  svg.setAttribute('aria-hidden', 'true')
  let circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
  circle.setAttribute('cx', '14.4')
  circle.setAttribute('cy', '9.6')
  circle.setAttribute('r', '5.4')
  circle.setAttribute('fill', 'currentColor')
  let bar = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
  bar.setAttribute('x', '3.2')
  bar.setAttribute('y', '3.2')
  bar.setAttribute('width', '4')
  bar.setAttribute('height', '17.6')
  bar.setAttribute('rx', '1.2')
  bar.setAttribute('fill', 'currentColor')
  svg.append(circle, bar)
  return svg
}

/**
 * Merge {@link tagTypes} into panel render options (group labels, row classes).
 * Tag name colors are consumer-owned — set `color` per type and call {@link applyTagDiscoveryTypeColors}.
 *
 * @param {{tagTypes?: Record<string, {label?: string, rowClass?: string, color?: string}>, getGroupKey?: Function, getGroupLabel?: Function, getRowClass?: Function, groupOrder?: string[]}} config
 * @return {typeof config}
 */
static prepareTagDiscoveryPanelOptions(config = {})
{
  let options = {...config}
  let tagTypes = config.tagTypes
  if (!tagTypes) {
    return options
  }
  if (!options.getGroupLabel) {
    options.getGroupLabel = (key) => tagTypes[key]?.label ?? key
  }
  if (!options.getRowClass) {
    options.getRowClass = (tag) => {
      let key = options.getGroupKey?.(tag) ?? tag.type ?? 'unknown'
      return tagTypes[key]?.rowClass ?? tagTypes.unknown?.rowClass ?? ''
    }
  }
  return options
}

/**
 * Inject tag-name colors for the discovery panel from consumer {@link tagTypes} config.
 * @param {Record<string, {label?: string, rowClass?: string, color?: string}>} tagTypes
 */
static applyTagDiscoveryTypeColors(tagTypes)
{
  if (!tagTypes) {
    return
  }
  let styleId = 'bv-tag-discovery-type-colors'
  let css = Object.values(tagTypes)
      .filter((type) => type.rowClass && type.color)
      .map((type) => `#bv-tag-discovery-panel .${type.rowClass} .bv-dock-list-name{color:${type.color};}`)
      .join('')
  if (!css) {
    return
  }
  let $style = $('#' + styleId)
  if (!$style.length) {
    $style = $('<style>').attr('id', styleId).appendTo(document.head)
  }
  $style.text(css)
}

/**
 * @param {HTMLElement|JQuery} panel
 * @param {Array<{name: string, type: string|null, count?: number|null}>} tags
 * @param {function(HTMLElement, {name: string, type: string|null, count?: number|null}, HTMLElement): void} renderActions
 * @param {{tagTypes?: Record<string, {label?: string, rowClass?: string, color?: string}>, groupOrder?: string[], getGroupKey?: function({name: string, type: string|null}): string, getGroupLabel?: function(string): string, getRowClass?: function({name: string, type: string|null}): string, knownTags?: Array<{name: string, type: string|null, count?: number|null}>, unknownSectionLabel?: string, knownSectionLabel?: string}} [options]
 */
static renderTagDiscoveryPanelContent(panel, tags, renderActions, options = {})
{
  options = BrazenViewLayer.prepareTagDiscoveryPanelOptions(options)
  if (options.tagTypes) {
    BrazenViewLayer.applyTagDiscoveryTypeColors(options.tagTypes)
  }
  let $groups = $(panel).find('.bv-dock-list-groups').empty()
  let unknownTags = Array.isArray(tags) ? tags : []
  let knownTags = Array.isArray(options.knownTags) ? options.knownTags : []
  if (!$groups.length || (!unknownTags.length && !knownTags.length)) {
    return
  }

  let getGroupKey = options.getGroupKey ?? ((tag) => tag.type ?? 'unknown')
  let getGroupLabel = options.getGroupLabel ?? ((key) => key)
  let getRowClass = options.getRowClass ?? (() => '')
  let groupOrder = options.groupOrder ?? null

  let appendGroupedTags = (sectionLabel, sectionTags) => {
    if (!sectionTags?.length) {
      return
    }
    if (sectionLabel) {
      $groups.append($('<h3 class="bv-dock-list-section-title">').text(sectionLabel))
    }
    let buckets = new Map()
    for (let tag of sectionTags) {
      let key = getGroupKey(tag)
      if (!buckets.has(key)) {
        buckets.set(key, [])
      }
      buckets.get(key).push(tag)
    }
    let appendGroup = (key, groupTags) => {
      if (!groupTags?.length) {
        return
      }
      let $list = $('<ul class="bv-dock-list">')
      for (let tag of groupTags) {
        let $actions = $('<span class="bv-tag-actions">')
        let $row = $('<li class="bv-dock-list-row">')
        let rowClass = getRowClass(tag)
        if (rowClass) {
          $row.addClass(rowClass)
        }
        let $name = $('<span class="bv-dock-list-name">').text(tag.name)
        $row.append($name)
        if (tag.count != null && tag.count !== '') {
          $row.append($('<span class="bv-dock-list-meta">').text(String(tag.count)))
        }
        $row.append($actions)
        renderActions($row[0], tag, $actions[0])
        $list.append($row)
      }
      $groups.append(
          $('<section class="bv-dock-list-group">').append(
              $('<h3>').text(getGroupLabel(key)),
              $list,
          ),
      )
    }

    let order = groupOrder ?? [...buckets.keys()]
    let seen = new Set()
    for (let key of order) {
      seen.add(key)
      appendGroup(key, buckets.get(key))
      buckets.delete(key)
    }
    for (let [key, groupTags] of buckets) {
      if (seen.has(key)) {
        continue
      }
      appendGroup(key, groupTags)
    }
  }

  let showBothSections = unknownTags.length > 0 && knownTags.length > 0
  appendGroupedTags(
      showBothSections ? (options.unknownSectionLabel ?? 'New tags') : null,
      unknownTags,
  )
  appendGroupedTags(
      showBothSections || (!unknownTags.length && knownTags.length) ?
          (options.knownSectionLabel ?? 'Known tags') :
          null,
      knownTags,
  )
}

/**
 * @param {HTMLElement|JQuery} row
 * @param {{name: string, type: string|null}} tag
 * @param {HTMLElement|JQuery} 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)
{
  let $row = $(row)
  let $actions = $(actionsElement).empty()
  config.renderButtons($row[0], tag, $actions[0], config.helpers ?? {})
  let state = config.getRowState?.(tag) ?? {}
  $row.toggleClass('bv-tag-muted', !!state.muted).toggleClass('bv-tag-hidden', !!state.hidden)
}

/**
 * @param {'idle'|'linkingSource'|'isSubject'|'linkingTarget'} state
 * @param {string} [iconClass='bv-tag-action-icon']
 * @return {SVGSVGElement}
 */
static createTagSubstitutionIcon(state, iconClass = 'bv-tag-action-icon')
{
  let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
  svg.setAttribute('viewBox', '0 0 24 24')
  svg.setAttribute('class', iconClass)
  svg.setAttribute('aria-hidden', 'true')

  let appendSwapArrow = (centerY, direction, solid) => {
    if (solid) {
      if (direction === 'right') {
        let shaft = document.createElementNS('http://www.w3.org/2000/svg', 'path')
        shaft.setAttribute('d', `M5 ${centerY - 1.25} H14 V${centerY + 1.25} H5 Z`)
        shaft.setAttribute('fill', 'currentColor')
        let head = document.createElementNS('http://www.w3.org/2000/svg', 'path')
        head.setAttribute('d', `M14 ${centerY - 3.5} L19 ${centerY} L14 ${centerY + 3.5} Z`)
        head.setAttribute('fill', 'currentColor')
        svg.append(shaft, head)
        return
      }
      let shaft = document.createElementNS('http://www.w3.org/2000/svg', 'path')
      shaft.setAttribute('d', `M10 ${centerY - 1.25} H19 V${centerY + 1.25} H10 Z`)
      shaft.setAttribute('fill', 'currentColor')
      let head = document.createElementNS('http://www.w3.org/2000/svg', 'path')
      head.setAttribute('d', `M10 ${centerY - 3.5} L5 ${centerY} L10 ${centerY + 3.5} Z`)
      head.setAttribute('fill', 'currentColor')
      svg.append(shaft, head)
      return
    }
    let shaft = document.createElementNS('http://www.w3.org/2000/svg', 'line')
    shaft.setAttribute('stroke', 'currentColor')
    shaft.setAttribute('stroke-width', '2.2')
    shaft.setAttribute('stroke-linecap', 'round')
    let head = document.createElementNS('http://www.w3.org/2000/svg', 'polyline')
    head.setAttribute('fill', 'none')
    head.setAttribute('stroke', 'currentColor')
    head.setAttribute('stroke-width', '2.2')
    head.setAttribute('stroke-linecap', 'round')
    head.setAttribute('stroke-linejoin', 'round')
    if (direction === 'right') {
      shaft.setAttribute('x1', '5')
      shaft.setAttribute('y1', String(centerY))
      shaft.setAttribute('x2', '15')
      shaft.setAttribute('y2', String(centerY))
      head.setAttribute('points', `15,${centerY - 3.5} 19,${centerY} 15,${centerY + 3.5}`)
    } else {
      shaft.setAttribute('x1', '19')
      shaft.setAttribute('y1', String(centerY))
      shaft.setAttribute('x2', '9')
      shaft.setAttribute('y2', String(centerY))
      head.setAttribute('points', `9,${centerY - 3.5} 5,${centerY} 9,${centerY + 3.5}`)
    }
    svg.append(shaft, head)
  }

  if (state === 'idle') {
    appendSwapArrow(9, 'right', false)
    appendSwapArrow(15, 'left', false)
  } else if (state === 'linkingSource') {
    appendSwapArrow(9, 'right', true)
    appendSwapArrow(15, 'left', true)
  } else if (state === 'linkingTarget') {
    let check = document.createElementNS('http://www.w3.org/2000/svg', 'polyline')
    check.setAttribute('fill', 'none')
    check.setAttribute('stroke', 'currentColor')
    check.setAttribute('stroke-width', '2.6')
    check.setAttribute('stroke-linecap', 'round')
    check.setAttribute('stroke-linejoin', 'round')
    check.setAttribute('points', '5,13 10,18 19,7')
    svg.append(check)
  } else if (state === 'isSubject') {
    appendSwapArrow(9, 'right', false)
    appendSwapArrow(15, 'left', false)
    let slash = document.createElementNS('http://www.w3.org/2000/svg', 'line')
    slash.setAttribute('x1', '4')
    slash.setAttribute('y1', '20')
    slash.setAttribute('x2', '20')
    slash.setAttribute('y2', '4')
    slash.setAttribute('stroke', 'currentColor')
    slash.setAttribute('stroke-width', '2')
    slash.setAttribute('stroke-linecap', 'round')
    svg.append(slash)
  }
  return svg
}

/**
 * @param {{state: 'idle'|'linkingSource'|'isSubject'|'linkingTarget', onClick: function(MouseEvent): void, className?: string, iconClass?: string}} options
 * @return {JQuery}
 */
static createTagSubstitutionActionButton(options)
{
  let titles = {
    idle: 'Substitute this tag with another',
    linkingSource: 'Cancel substitution link',
    isSubject: 'Remove tag substitution',
    linkingTarget: 'Substitute the linked tag with this one',
  }
  let state = options.state ?? 'idle'
  return $('<button type="button">').
      addClass(options.className ?? 'bv-tag-action-btn').
      attr('title', titles[state] ?? titles.idle).
      append(BrazenViewLayer.createTagSubstitutionIcon(state, options.iconClass ?? 'bv-tag-action-icon')).
      on('click', (event) => {
        event.preventDefault()
        event.stopPropagation()
        options.onClick?.(event)
      })
}

/**
 * @param {boolean} filled
 * @param {string} [iconClass='bv-tag-action-icon']
 * @return {SVGSVGElement}
 */
static createTagBookmarkStarIcon(filled, iconClass = 'bv-tag-action-icon')
{
  let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
  svg.setAttribute('viewBox', '22 18 204 188')
  svg.setAttribute('class', iconClass)
  svg.setAttribute('aria-hidden', 'true')
  let path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
  path.setAttribute('d', 'M128 26l27.8 56.3 62.2 9-45 43.9 10.6 61.9L128 175.4 64.4 197.1l10.6-61.9-45-43.9 62.2-9L128 26z')
  path.setAttribute('stroke-linejoin', 'round')
  path.setAttribute('stroke-linecap', 'round')
  if (filled) {
    path.setAttribute('fill', 'currentColor')
    path.setAttribute('stroke', 'currentColor')
    path.setAttribute('stroke-width', '14')
  } else {
    path.setAttribute('fill', 'none')
    path.setAttribute('stroke', 'currentColor')
    path.setAttribute('stroke-width', '16')
  }
  svg.append(path)
  return svg
}

/**
 * @param {boolean} active
 * @param {string} [iconClass='bv-tag-action-icon']
 * @return {SVGSVGElement}
 */
static createTagIgnoreIcon(active, iconClass = 'bv-tag-action-icon')
{
  let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
  svg.setAttribute('viewBox', '0 3 19 17')
  svg.setAttribute('class', iconClass)
  svg.setAttribute('aria-hidden', 'true')
  let label = document.createElementNS('http://www.w3.org/2000/svg', 'text')
  label.setAttribute('fill', 'currentColor')
  label.setAttribute('font-family', 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif')
  label.setAttribute('font-weight', '700')
  label.setAttribute('text-rendering', 'geometricPrecision')
  let cap = document.createElementNS('http://www.w3.org/2000/svg', 'tspan')
  cap.setAttribute('x', '0.5')
  cap.setAttribute('y', '17.5')
  cap.setAttribute('font-size', '16')
  cap.textContent = 'A'
  let lower = document.createElementNS('http://www.w3.org/2000/svg', 'tspan')
  lower.setAttribute('x', '10.5')
  lower.setAttribute('y', '18.5')
  lower.setAttribute('font-size', '12.5')
  lower.textContent = 'a'
  label.append(cap, lower)
  svg.append(label)
  if (active) {
    let slash = document.createElementNS('http://www.w3.org/2000/svg', 'path')
    slash.setAttribute('d', 'M4 16L17.5 8')
    slash.setAttribute('fill', 'none')
    slash.setAttribute('stroke', 'currentColor')
    slash.setAttribute('stroke-width', '2')
    slash.setAttribute('stroke-linecap', 'round')
    svg.append(slash)
  }
  return svg
}

/**
 * @param {boolean} active
 * @param {string} [iconClass='bv-tag-action-icon']
 * @return {SVGSVGElement}
 */
static createTagBlacklistIcon(active, iconClass = 'bv-tag-action-icon')
{
  let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
  svg.setAttribute('viewBox', '0 4 24 17')
  svg.setAttribute('class', iconClass)
  svg.setAttribute('aria-hidden', 'true')
  let eye = document.createElementNS('http://www.w3.org/2000/svg', 'path')
  eye.setAttribute('d', 'M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z')
  eye.setAttribute('fill', 'none')
  eye.setAttribute('stroke', 'currentColor')
  eye.setAttribute('stroke-width', '1.5')
  eye.setAttribute('stroke-linejoin', 'round')
  let pupil = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
  pupil.setAttribute('cx', '12')
  pupil.setAttribute('cy', '12')
  pupil.setAttribute('r', '2.5')
  pupil.setAttribute('fill', 'none')
  pupil.setAttribute('stroke', 'currentColor')
  pupil.setAttribute('stroke-width', '1.5')
  svg.append(eye, pupil)
  if (active) {
    let slash = document.createElementNS('http://www.w3.org/2000/svg', 'line')
    slash.setAttribute('x1', '4')
    slash.setAttribute('y1', '20')
    slash.setAttribute('x2', '20')
    slash.setAttribute('y2', '4')
    slash.setAttribute('stroke', 'currentColor')
    slash.setAttribute('stroke-width', '2')
    slash.setAttribute('stroke-linecap', 'round')
    svg.append(slash)
  }
  return svg
}

/**
 * @param {boolean} active
 * @param {string} [iconClass='bv-tag-action-icon']
 * @return {SVGSVGElement}
 */
static createTagExploreIcon(active, iconClass = 'bv-tag-action-icon')
{
  let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
  svg.setAttribute('viewBox', '0 4 24 17')
  svg.setAttribute('class', iconClass)
  svg.setAttribute('aria-hidden', 'true')
  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)
  if (active) {
    let slash = document.createElementNS('http://www.w3.org/2000/svg', 'line')
    slash.setAttribute('x1', '4')
    slash.setAttribute('y1', '20')
    slash.setAttribute('x2', '20')
    slash.setAttribute('y2', '4')
    slash.setAttribute('stroke', 'currentColor')
    slash.setAttribute('stroke-width', '2')
    slash.setAttribute('stroke-linecap', 'round')
    svg.append(slash)
  }
  return svg
}

/**
 * @param {{title: string, icon: Node|JQuery, onClick: function(MouseEvent): void, className?: string}} options
 * @return {JQuery}
 */
static createTagAttributeActionButton(options)
{
  let $button = $('<button type="button">').
      addClass(options.className ?? 'bv-tag-action-btn').
      attr('title', options.title ?? '')
  if (options.icon) {
    $button.append(options.icon)
  }
  return $button.on('click', (event) => {
    event.preventDefault()
    event.stopPropagation()
    options.onClick?.(event)
  })
}

}