Brazen Framework - View Layer

View layer for the Brazen userscripts framework

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.sleazyfork.org/scripts/416104/1901970/Brazen%20Framework%20-%20View%20Layer.js을(를) 사용하여 포함하는 라이브러리입니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

/** Build id for Tampermonkey Resource-override checks (must match local `base-scripts` file). */
const BRAZEN_VIEW_LAYER_BUILD = 'local-5.1.0'

/** Sidebar tag rows enhanced per animation frame when chunking. */
const SIDEBAR_ENHANCE_ROWS_PER_FRAME = 40

/**
 * @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; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); box-sizing: border-box; opacity: 0.3; overflow: auto; } #bv-ui:hover { opacity: 1; } #restore-settings.bv-input { margin-bottom: 1rem } .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 { 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% } .bv-group.bv-textarea-group > textarea.bv-input[readonly] { cursor: default; opacity: 0.9 } .bv-substitution-composer { align-items: center; display: flex; gap: 0.35rem; margin-top: 0.5rem; min-width: 0; width: 100% } .bv-substitution-composer > .bv-input.bv-text { flex: 1 1 0; min-width: 0; width: auto } .bv-substitution-arrow { flex: 0 0 auto; font-size: 0.95rem; line-height: 1; opacity: 0.85; user-select: none } .bv-substitution-add { align-items: center; box-sizing: border-box; display: inline-flex; flex: 0 0 auto; height: 2rem; justify-content: center; margin: 0; min-width: 2rem; padding: 0.25rem; width: 2rem } .bv-substitution-add-icon { display: block; height: 1.1rem; pointer-events: none; width: 1.1rem } 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-section-intro { color: #9eb09e; font-size: 0.72rem; 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 { --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-ruleset-panel-rule-btn, .bv-ruleset-panel-nav-btn), #bv-ui :is(button, input, select, textarea, .bv-button, .bv-tab-button, .bv-label, .bv-title, .bv-token-btn, .bv-ruleset-panel-rule-btn, .bv-ruleset-panel-nav-btn), .bv-modal-dialog :is(button, input, select, textarea, .bv-button, .bv-modal-title, h3, p, span) { 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-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-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: relative; } #bv-dock-panel-stack { box-sizing: border-box; display: flex; gap: 8px; pointer-events: none; position: fixed; z-index: 1001; } #bv-dock-panel-stack > .bv-dock-slide-panel { flex: 0 1 auto; min-height: 0; min-width: 0; pointer-events: auto; } body.bv-theme-solid #bv-dock-panel-stack > .bv-dock-slide-panel { bottom: auto !important; left: auto !important; position: relative !important; right: auto !important; top: auto !important; z-index: auto !important; } #bv-dock-panel-stack.bv-dock-panel-stack-bottom { align-items: flex-end; flex-direction: row; max-width: 96vw; } #bv-dock-panel-stack.bv-dock-panel-stack-left, #bv-dock-panel-stack.bv-dock-panel-stack-right { align-items: stretch; flex-direction: column; max-height: 90vh; overflow-x: hidden; overflow-y: auto; } .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 { color: #8a8a8a; } .bv-dock-list-row.bv-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-name.bv-dock-list-name-link { cursor: pointer; text-decoration: none; } .bv-dock-list-name.bv-dock-list-name-link:hover, .bv-dock-list-name.bv-dock-list-name-link:focus { text-decoration: none; } .bv-dock-list-similar { margin-top: 0.55rem; } .bv-dock-list-similar > summary { cursor: pointer; list-style: none; margin: 0; user-select: none; } .bv-dock-list-similar > summary::-webkit-details-marker { display: none; } .bv-dock-list-similar > summary::before { content: '▸ '; display: inline-block; width: 0.85em; } .bv-dock-list-similar[open] > summary::before { content: '▾ '; } .bv-dock-list-similar > summary > .bv-dock-list-section-title { display: inline; margin: 0; } .bv-dock-list-similar-body { margin-top: 0.35rem; } .bv-dock-list-similar-status { color: #9eb09e; font-size: 0.72rem; line-height: 1.35; margin: 0.15rem 0 0; } .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-separator + .bv-dock-separator { display: none; } .bv-dock-rail-head:has(> .bv-dock-separator:last-child) + .bv-dock-rail-body > .bv-dock-separator:first-child, .bv-dock-rail-body:has(> .bv-dock-separator:last-child) + .bv-dock-rail-foot > .bv-dock-separator:first-child { display: none; } .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) * 10 + 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; gap: 0.12rem; justify-content: center; min-height: var(--bv-dock-child-btn-size); min-width: calc(var(--bv-dock-btn-size) * 1.35); padding: 0.2rem 0.28rem; width: max-content; } .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; min-width: calc(var(--bv-dock-btn-size) * 1.35); padding: 0; width: max-content; } .bv-dock-progress-slot.bv-dock-progress-hidden { display: none; } .bv-dock-progress-bar { align-self: stretch; background: transparent; border: 1px solid var(--bv-dock-btn-bg, #3a3f47); border-radius: 3px; box-sizing: border-box; flex: 0 0 auto; height: 0.35rem; order: 1; overflow: hidden; position: relative; width: auto; } .bv-dock-progress-resolution { align-self: stretch; order: 3; width: auto; } .bv-dock-progress-bar > span { background: var(--bv-dock-btn-active-bg, #5a6f5a); display: block; height: 100%; left: 0; position: absolute; top: 0; transform: scaleX(0); transform-origin: left center; transition: transform 0.2s ease; width: 100%; } .bv-dock-progress-label-wrap { display: block; flex: 0 0 auto; order: 2; width: max-content; } .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: max-content; } .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 { align-self: stretch; text-align: right; width: auto; } .bv-dock-bottom .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label { text-align: right; width: 100%; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-panel, .bv-dock-right .bv-dock-slideout .bv-dock-progress-panel { align-self: stretch; box-sizing: border-box; max-width: 100%; min-height: calc(var(--bv-dock-child-btn-size) * 1.45 + 0.24rem); min-width: 0; padding: 0.12rem; width: auto; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot, .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot { align-items: center; flex: 1 1 auto; flex-direction: row; gap: 0.12rem; justify-content: center; min-width: 0; width: 100%; } .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); width: 0.28rem; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-download { order: 2; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-resolution { order: 3; } .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-download { order: 1; } .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-resolution { order: 3; } .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: 100%; top: auto; transform: scaleY(0); transform-origin: bottom center; width: 100%; } .bv-dock-left .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label-wrap { align-self: center; flex: 0 1 auto; min-width: 0; order: 1; text-align: center; width: max-content; } .bv-dock-right .bv-dock-slideout .bv-dock-progress-slot .bv-dock-progress-label-wrap { align-self: center; flex: 0 1 auto; min-width: 0; order: 2; text-align: center; width: max-content; } .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; text-align: center; white-space: nowrap; width: max-content; } .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); } .bv-hidden { display: none !important; } body.bv-theme-solid { --bv-theme-color: #2a352a; --bv-dock-bg: var(--bv-theme-color); --bv-dock-btn-bg: #4a5f4a; --bv-dock-btn-active-bg: #778c77; --bv-dock-slideout-bg: rgba(0, 0, 0, 0.28); } body.bv-theme-solid #bv-ui.bv-dock-panel-left, body.bv-theme-solid #bv-compliance-rules.bv-dock-panel-left, body.bv-theme-solid #bv-tag-discovery-panel.bv-dock-panel-left, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-left, body.bv-theme-solid #bv-download-interruption-panel.bv-dock-panel-left{ border-bottom-left-radius: 0; border-left: 0; border-radius: 0 8px 8px 0; border-right: 1px solid #1a221a; box-shadow: 2px 0 12px rgba(0, 0, 0, 0.35); border-top-left-radius: 0; }body.bv-theme-solid #bv-ui.bv-dock-panel-right, body.bv-theme-solid #bv-compliance-rules.bv-dock-panel-right, body.bv-theme-solid #bv-tag-discovery-panel.bv-dock-panel-right, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-right, body.bv-theme-solid #bv-download-interruption-panel.bv-dock-panel-right{ border-bottom-right-radius: 0; border-radius: 8px 0 0 8px; border-right: 0; box-shadow: -2px 0 12px rgba(0, 0, 0, 0.35); border-top-right-radius: 0; }body.bv-theme-solid #bv-ui.bv-dock-panel-bottom, body.bv-theme-solid #bv-compliance-rules.bv-dock-panel-bottom, body.bv-theme-solid #bv-tag-discovery-panel.bv-dock-panel-bottom, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-bottom, body.bv-theme-solid #bv-download-interruption-panel.bv-dock-panel-bottom{ border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-radius: 8px 8px 0 0; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); max-height: 80vh; top: auto !important; width: min(400px, 96vw); }body.bv-theme-solid #bv-ui, body.bv-theme-solid #bv-compliance-rules, body.bv-theme-solid #bv-tag-discovery-panel, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download), body.bv-theme-solid #bv-download-interruption-panel{ box-sizing: border-box; display: flex; flex-direction: column; height: auto; max-height: 90vh; min-width: 400px; position: relative; width: 400px; z-index: auto; overflow-x: hidden; overflow-y: auto; font-size: 0.8125rem; font-weight: 400; line-height: 1.3; backdrop-filter: none !important; background: var(--bv-theme-color) !important; opacity: 1 !important; border: 1px solid #1a221a; border-right: 0; border-radius: 8px 0 0 8px; padding: 0.5rem 0.7rem 0.6rem !important; }body.bv-theme-solid #bv-compliance-rules, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download), body.bv-theme-solid #bv-download-interruption-panel{ min-width: 300px; padding: 0.5rem 0.7rem 0.6rem !important; width: min(340px, 92vw); }body.bv-theme-solid #bv-ui:not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom), body.bv-theme-solid #bv-compliance-rules:not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom), body.bv-theme-solid #bv-tag-discovery-panel:not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom), body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download):not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom), body.bv-theme-solid #bv-download-interruption-panel:not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom){ bottom: auto; left: auto; right: 0; top: 5vh; }body.bv-theme-solid #bv-ui *, body.bv-theme-solid #bv-compliance-rules *, body.bv-theme-solid #bv-tag-discovery-panel *, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) *, body.bv-theme-solid #bv-download-interruption-panel *{ box-sizing: border-box; }body.bv-theme-solid #bv-ui:hover{ opacity: 1 !important; }body.bv-theme-solid #bv-ui.bv-bg-colour{ background-color: var(--bv-theme-color) !important; }body.bv-theme-solid #bv-ui > div + div{ margin-top: 0.4rem !important; }body.bv-theme-solid #bv-resizer{ align-items: center; background-color: #4a5f4a; border: 1px solid #1a221a; cursor: ew-resize; display: flex; height: 14px; justify-content: center; position: absolute; top: 0.45rem; width: 20px; z-index: 6; }body.bv-theme-solid #bv-ui.bv-dock-panel-left #bv-resizer, body.bv-theme-solid #bv-ui.bv-dock-panel-bottom #bv-resizer{ left: auto; right: 0.4rem; }body.bv-theme-solid #bv-ui.bv-dock-panel-right #bv-resizer{ left: 0.4rem; right: auto; }body.bv-theme-solid #bv-resizer:hover{ background-color: #5a6f5a; }body.bv-theme-solid #bv-resizer-icon{ color: #e8efe8; font-size: 0.7rem; line-height: 1; pointer-events: none; }body.bv-theme-solid #bv-ui .bv-tabs-nav{ display: flex; flex-wrap: wrap; gap: 2px; margin-bottom: 0.15rem; }body.bv-theme-solid #bv-ui .bv-tab-button{ color: #c8d4c8; background-color: #1e271e; font-size: 0.75rem; font-weight: 500; margin: 0; padding: 0.22rem 0.42rem; line-height: 1.2; width: auto; }body.bv-theme-solid #bv-ui .bv-tab-button.bv-active, body.bv-theme-solid #bv-ui .bv-tab-button:hover{ background-color: #778c77 !important; color: #fff !important; }body.bv-theme-solid #bv-ui .bv-tabs-section{ max-width: 100%; min-width: 0; }body.bv-theme-solid #bv-ui .bv-tab-panel{ background-color: var(--bv-theme-color); border-color: #1a221a !important; box-sizing: border-box; display: none; flex-direction: column; max-width: 100%; min-width: 0; overflow: hidden; padding: 0.5rem 0.65rem !important; gap: 0.3rem; }body.bv-theme-solid #bv-ui .bv-tab-panel.bv-active{ display: flex; }body.bv-theme-solid #bv-ui .bv-group{ align-items: center; gap: 0.3rem; max-width: 100%; min-height: 0; min-width: 0; margin: 0; }body.bv-theme-solid #bv-ui .bv-group + .bv-group{ margin-top: 0.3rem !important; }body.bv-theme-solid #bv-ui .bv-group.bv-group-disabled{ opacity: 0.45; }body.bv-theme-solid #bv-ui .bv-group:has(.bv-input.bv-checkbox-radio){ align-items: center; column-gap: 0.5rem; display: grid; grid-template-columns: minmax(0, 1fr) auto; min-height: auto; }body.bv-theme-solid #bv-ui .bv-group:has(.bv-input.bv-checkbox-radio) .bv-label.bv-checkbox-radio{ line-height: 1.35; min-width: 0; }body.bv-theme-solid #bv-ui .bv-group:has(.bv-label.bv-text):not(.bv-pattern-group):not(.bv-range-group):not(.bv-textarea-group):not(.bv-detail-form-field):not(:has(.bv-input.bv-checkbox-radio)){ align-items: center; column-gap: 0.5rem; display: grid; grid-template-columns: 6.75rem minmax(8rem, 1fr); max-width: 100%; min-width: 0; }body.bv-theme-solid #bv-ui .bv-group.bv-range-group{ align-items: center; column-gap: 0.35rem; display: grid; grid-template-columns: 4.5rem minmax(8rem, 1fr); max-width: 100%; min-width: 0; }body.bv-theme-solid #bv-ui .bv-group:has(.bv-label.bv-text):not(.bv-pattern-group):not(.bv-range-group):not(.bv-textarea-group):not(.bv-detail-form-field):not(:has(.bv-input.bv-checkbox-radio)) .bv-label.bv-text, body.bv-theme-solid #bv-ui .bv-group.bv-range-group .bv-label.bv-text{ justify-self: start; min-width: 0; text-align: left; white-space: nowrap; }body.bv-theme-solid #bv-ui .bv-group.bv-pattern-group{ display: flex; flex-direction: column; align-items: stretch; gap: 0.2rem; }body.bv-theme-solid #bv-ui .bv-group.bv-pattern-group > .bv-label.bv-text{ display: block; text-align: left; width: 100%; }body.bv-theme-solid #bv-ui .bv-group.bv-range-group .bv-label.bv-text{ grid-column: 1; grid-row: 1; }body.bv-theme-solid #bv-ui .bv-group.bv-range-group .bv-range-inputs{ display: flex; gap: 0.35rem; grid-column: 2; grid-row: 1; min-width: 0; width: 100%; }body.bv-theme-solid #bv-ui .bv-group.bv-range-group .bv-range-inputs > .bv-input{ flex: 1 1 50%; max-width: none; min-width: 3.25rem; width: auto; }body.bv-theme-solid #bv-ui .bv-group.bv-textarea-group{ display: flex; flex-direction: column; align-items: stretch; gap: 0.2rem; padding: 0 0.05rem; }body.bv-theme-solid #bv-ui .bv-group.bv-textarea-group > textarea.bv-input{ margin-top: 0; }body.bv-theme-solid #bv-ui .bv-substitution-composer{ gap: 0.25rem; margin-top: 0; }body.bv-theme-solid #bv-ui .bv-substitution-arrow{ color: #9eb09e; }body.bv-theme-solid #bv-ui .bv-substitution-add{ background-color: #4a5f4a; border: 1px solid #3a4f3a; border-radius: 2px; color: #fff; height: 1.65rem; min-width: 1.65rem; width: 1.65rem; }body.bv-theme-solid #bv-ui .bv-substitution-add:hover{ background-color: #5a6f5a; }body.bv-theme-solid #bv-ui .bv-substitution-add-icon{ height: 0.95rem; width: 0.95rem; }body.bv-theme-solid #bv-ui .bv-group.bv-textarea-group > textarea.bv-input[readonly]{ background-color: #a8b5a8; cursor: default; }body.bv-theme-solid #bv-ui .bv-label{ color: #e8efe8; font-weight: 400; line-height: 1.25; margin: 0; }body.bv-theme-solid #bv-ui .bv-group:has(.bv-label.bv-text):not(.bv-pattern-group):not(.bv-range-group):not(:has(.bv-input.bv-checkbox-radio)) .bv-input.bv-text{ justify-self: stretch; max-width: 100%; width: 100%; }body.bv-theme-solid #bv-ui .bv-input.bv-checkbox-radio{ justify-self: end; margin: 0; scale: 1; }body.bv-theme-solid #bv-ui .bv-title{ color: #e8efe8; font-size: 0.75rem; font-weight: 500; height: auto; margin: 0 0 0.2rem; min-height: 0; }body.bv-theme-solid #bv-ui .bv-input{ background-color: #c5d0c5; color: #1a221a; border: 1px solid #8a9a8a; border-radius: 2px; box-sizing: border-box; font-size: 0.8125rem; font-weight: 400; max-width: 100%; min-width: 0; padding: 0.18rem 0.32rem; }body.bv-theme-solid #bv-ui .bv-group.bv-range-group .bv-input{ min-height: 1.5rem; min-width: 0; }body.bv-theme-solid #bv-ui textarea.bv-input, body.bv-theme-solid #bv-ui .bv-group.bv-textarea-group > textarea.bv-input{ display: block; max-width: 100%; min-height: 0; padding: 0.28rem 0.35rem; resize: vertical; width: 100%; }body.bv-theme-solid #bv-ui .bv-group.bv-pattern-group > .bv-input.bv-text{ max-width: 100%; width: 100%; }body.bv-theme-solid #bv-ui .bv-input.bv-pattern-active{ outline: 1px solid #9eb09e; outline-offset: 1px; }body.bv-theme-solid #bv-ui .bv-pattern-builder.bv-pattern-builder-shared{ margin-top: 0.15rem; }body.bv-theme-solid #bv-ui .bv-button{ background-color: #4a5f4a; box-sizing: border-box; color: #fff; border: 1px solid #3a4f3a; border-radius: 2px; font-size: 0.8125rem; font-weight: 500; line-height: 1.25; margin: 0; padding: 0.28rem 0.45rem; }body.bv-theme-solid #bv-ui a.bv-button.bv-donate-patreon{ align-items: center; display: inline-flex; gap: 0.4rem; justify-content: center; text-decoration: none; width: 100%; }body.bv-theme-solid #bv-ui a.bv-button.bv-donate-patreon:hover{ background-color: #5a6f5a; color: #fff; }body.bv-theme-solid #bv-ui .bv-donate-intro{ color: #c8d4c8; font-size: 0.78rem; margin: 0 0 0.45rem; }body.bv-theme-solid #bv-ui .bv-section-intro{ color: #9eb09e; font-size: 0.72rem; margin: 0 0 0.45rem; }body.bv-theme-solid #bv-ui .bv-donate-patreon-icon{ color: #ff424d; height: 1.1rem; width: 1.1rem; }body.bv-theme-solid #bv-ui .bv-button:hover{ background-color: #5a6f5a; }body.bv-theme-solid #bv-ui .bv-section button + button{ margin-left: 0 !important; }body.bv-theme-solid #bv-ui .bv-actions{ align-items: stretch; display: flex; gap: 0.25rem; padding: 0; width: 100%; }body.bv-theme-solid #bv-ui .bv-actions:not(.bv-flex-column){ display: grid; grid-template-columns: repeat(var(--bv-actions-cols, 3), minmax(0, 1fr)); gap: 0.3rem; }body.bv-theme-solid #bv-ui .bv-actions:not(.bv-flex-column) .bv-button{ flex: none; min-width: 0; width: 100%; }body.bv-theme-solid #bv-ui .bv-actions.bv-flex-column{ flex-direction: column; gap: 0.22rem; }body.bv-theme-solid #bv-ui .bv-actions.bv-flex-column .bv-button{ width: 100%; }body.bv-theme-solid #bv-ui .bv-bottom-section{ align-items: stretch; display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.4rem !important; max-width: 100%; min-width: 0; padding: 0 0.1rem; }body.bv-theme-solid #bv-ui hr{ border: 0; border-top: 1px solid rgba(0, 0, 0, 0.35); margin: 0.3rem 0 !important; }body.bv-theme-solid #bv-ui .bv-pattern-builder{ display: flex; flex-direction: column; gap: 0.2rem; grid-column: 1 / -1; margin-top: 0.15rem; }body.bv-theme-solid #bv-ui .bv-token-row{ align-items: start; display: grid; gap: 0.18rem 0.3rem; grid-template-columns: 2.65rem minmax(0, 1fr); }body.bv-theme-solid #bv-ui .bv-token-row-label{ color: #9eb09e; font-size: 0.68rem; grid-column: 1; line-height: 1.45; padding-top: 0.14rem; }body.bv-theme-solid #bv-ui .bv-token-bar{ display: flex; flex-wrap: wrap; gap: 0.18rem; grid-column: 2; min-width: 0; }body.bv-theme-solid #bv-ui .bv-token-btn{ background: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; box-sizing: border-box; color: #e8efe8; cursor: pointer; font-size: 0.68rem; font-weight: 500; line-height: 1.2; margin: 0; padding: 0.12rem 0.32rem; width: auto; }body.bv-theme-solid #bv-ui .bv-token-btn:hover{ background: #4f634f; }body.bv-theme-solid #bv-ui .bv-token-btn:active, body.bv-theme-solid #bv-ui .bv-button:active, body.bv-theme-solid #bv-ui .bv-tab-button:active{ filter: brightness(1.2); transform: scale(0.98); }body.bv-theme-solid #bv-ui .bv-token-btn.bv-token-flash{ background: #6a8f6a !important; border-color: #9eb09e !important; }body.bv-theme-solid #bv-ui .bv-input.bv-pattern-insert-flash{ outline: 2px solid #9eb09e; outline-offset: 1px; }body.bv-theme-solid #bv-ui .bv-token-btn.bv-token-sep{ background: #334433; min-width: 1.35rem; padding: 0.12rem 0.28rem; text-align: center; }body.bv-theme-solid #bv-ui, body.bv-theme-solid #bv-tag-discovery-panel, body.bv-theme-solid #bv-compliance-rules, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download), body.bv-theme-solid #bv-download-interruption-panel, body.bv-theme-solid #bv-ui :is(button, input, select, textarea, .bv-button, .bv-tab-button, .bv-label, .bv-title, .bv-token-btn, .bv-ruleset-panel-rule-btn, .bv-ruleset-panel-nav-btn, .bv-ruleset-panel-action, .bv-ruleset-panel-remove), body.bv-theme-solid #bv-tag-discovery-panel :is(button, input, select, textarea, .bv-button, .bv-label, .bv-title, h2, h3, p, span), body.bv-theme-solid #bv-compliance-rules :is(button, input, select, textarea, .bv-button, .bv-label, .bv-title, h2, h3, p, span), body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) :is(button, input, select, textarea, .bv-button, .bv-label, .bv-title, h2, h3, p, span), body.bv-theme-solid #bv-download-interruption-panel :is(button, input, select, textarea, .bv-button, .bv-label, .bv-title, h2, h3, p, span){ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif !important; }body.bv-theme-solid .bv-modal-dialog, body.bv-theme-solid .bv-modal-dialog :is(button, input, select, textarea, h2, h3, p, span){ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif !important; }body.bv-theme-solid #tag-sidebar .bv-tag-actions-slot{ display: inline-flex; align-items: center; line-height: 1; min-width: 2.5em; justify-content: flex-end; vertical-align: middle; }body.bv-theme-solid #tag-sidebar .bv-tag-actions-slot .tag-count{ line-height: 1; vertical-align: middle; }body.bv-theme-solid #tag-sidebar .bv-tag-actions-slot .tag-count[hidden]{ display: none; }body.bv-theme-solid #tag-sidebar .bv-tag-actions{ align-items: center; display: inline-flex; gap: 0.1rem; line-height: 1; vertical-align: middle; }body.bv-theme-solid #tag-sidebar .bv-tag-actions[hidden]{ display: none; }body.bv-theme-solid #bv-tag-discovery-panel.bv-bg-colour, body.bv-theme-solid #bv-compliance-rules.bv-bg-colour, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-bg-colour, body.bv-theme-solid #bv-download-interruption-panel.bv-bg-colour{ background-color: var(--bv-theme-color) !important; }body.bv-theme-solid #bv-tag-discovery-panel .bv-dock-slide-panel-title, body.bv-theme-solid #bv-compliance-rules .bv-dock-slide-panel-title, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-slide-panel-title, body.bv-theme-solid #bv-download-interruption-panel .bv-dock-slide-panel-title{ color: #e8efe8; font-size: 0.75rem; font-weight: 500; }body.bv-theme-solid #bv-compliance-rules .bv-dock-slide-panel-close{ background-color: #4a5f4a; border: 1px solid #3a4f3a; color: #fff; font-size: 0.8125rem; line-height: 1.1; padding: 0.18rem 0.38rem; }body.bv-theme-solid #bv-compliance-rules .bv-dock-slide-panel-close:hover{ background-color: #5a6f5a; }body.bv-theme-solid #bv-tag-discovery-panel .bv-dock-list-intro, body.bv-theme-solid #bv-compliance-rules .bv-dock-list-intro, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-intro, body.bv-theme-solid #bv-download-interruption-panel .bv-dock-list-intro{ color: #c8d4c8; font-size: 0.78rem; }body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download){ overflow-y: visible; }body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-slide-panel-body{ flex: 0 1 auto; min-height: 0; overflow: visible; }body.bv-theme-solid #bv-download-interruption-panel .bv-download-interruption-tip{ color: #9eb09e; font-size: 0.72rem; line-height: 1.35; margin: 0.55rem 0 0; }body.bv-theme-solid #bv-tag-discovery-panel .bv-dock-list-group h3, body.bv-theme-solid #bv-compliance-rules .bv-dock-list-group h3{ color: #9eb09e; font-size: 0.68rem; font-weight: 500; letter-spacing: 0.05em; }body.bv-theme-solid #bv-compliance-rules .bv-dock-list-meta{ color: #9eb09e; }body.bv-theme-solid #bv-tag-discovery-panel .bv-tag-actions, body.bv-theme-solid #bv-compliance-rules .bv-tag-actions{ align-items: center; display: inline-flex; gap: 0.12rem; line-height: 1; }body.bv-theme-solid #bv-compliance-rules .bv-dock-list-action-btn{ background-color: #4a5f4a; border: 1px solid #3a4f3a; border-radius: 2px; color: #fff; font-size: 0.72rem; line-height: 1.2; padding: 0.15rem 0.35rem; width: auto; }body.bv-theme-solid #bv-compliance-rules .bv-dock-list-action-btn:hover{ background-color: #5a6f5a; }body.bv-theme-solid #bv-tag-discovery-panel .bv-tag-action-btn{ align-items: center; background: none; border: 0; color: #c8d4c8; cursor: pointer; display: inline-flex; flex-shrink: 0; height: 1.1em; justify-content: center; line-height: 1; margin: 0; padding: 0; vertical-align: middle; width: 1.1em; }body.bv-theme-solid #bv-tag-discovery-panel .bv-tag-action-btn:hover{ color: #fff; filter: brightness(1.1); }body.bv-theme-solid #bv-tag-discovery-panel .bv-tag-action-btn:active{ transform: scale(0.95); }body.bv-theme-solid #bv-tag-discovery-panel .bv-tag-action-icon{ display: block; height: 100%; width: 100%; }body.bv-theme-solid #bv-tag-discovery-panel .bv-tag-action-icon-letters{ text-rendering: geometricPrecision; shape-rendering: geometricPrecision; -webkit-font-smoothing: antialiased; }body.bv-theme-solid #bv-tag-discovery-panel .bv-dock-list-footer .bv-button, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-footer .bv-button, body.bv-theme-solid #bv-download-interruption-panel .bv-dock-list-footer .bv-button{ background-color: #4a5f4a; border: 1px solid #3a4f3a; border-radius: 2px; box-sizing: border-box; color: #fff; font-size: 0.8125rem; font-weight: 500; line-height: 1.25; margin: 0; padding: 0.28rem 0.45rem; width: 100%; }body.bv-theme-solid #bv-tag-discovery-panel .bv-dock-list-footer.bv-dock-list-footer-row .bv-button{ flex: 1 1 0; min-width: 0; width: auto; }body.bv-theme-solid #bv-tag-discovery-panel .bv-dock-list-footer .bv-button:hover, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-footer .bv-button:hover, body.bv-theme-solid #bv-download-interruption-panel .bv-dock-list-footer .bv-button:hover{ background-color: #5a6f5a; }body.bv-theme-solid #bv-tag-discovery-panel .bv-dock-list-footer .bv-dock-list-footer-secondary, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-footer .bv-dock-list-footer-secondary{ background-color: transparent; border: 1px solid #5a6f5a; color: #c8d4c8; }body.bv-theme-solid #bv-tag-discovery-panel .bv-dock-list-footer .bv-dock-list-footer-secondary:hover, body.bv-theme-solid :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-footer .bv-dock-list-footer-secondary:hover{ background-color: #3d4f3d; color: #fff; }body.bv-theme-solid #bv-tag-discovery-panel .bv-dock-list-section-title{ color: #e8efe8; font-size: 0.78rem; font-weight: 600; }body.bv-theme-solid #bv-tag-discovery-panel .bv-dock-list-meta{ color: #9eb09e; flex: 0 0 auto; font-size: 0.72rem; line-height: 1.3; margin-left: 0.15rem; margin-right: 0.15rem; white-space: nowrap; }body.bv-theme-solid .bv-timed-button-container{ display: block; position: relative; width: 100%; }body.bv-theme-solid .bv-timed-button-container .bv-timed-button{ display: block; width: 100%; }body.bv-theme-solid .bv-timed-button-bar{ background: rgba(0, 0, 0, 0.35); border-radius: 0 0 2px 2px; bottom: 0; height: 2px; left: 0; overflow: hidden; pointer-events: none; position: absolute; right: 0; width: 100%; }body.bv-theme-solid .bv-timed-button-bar-fill{ background: #9eb09e; height: 100%; width: 100%; }body.bv-theme-solid .bv-settings-split { display: flex; flex: 1 1 auto; gap: 0.5rem; min-height: 0; min-width: 0; width: 100%; } #bv-ui.bv-settings-shell { display: flex; flex-direction: column; min-width: 0; } #bv-ui.bv-settings-shell > .bv-settings-split { flex: 1 1 auto; min-height: 0; overflow: hidden; } .bv-settings-primary { flex: 1 1 auto; min-height: 0; min-width: 0; overflow: auto; } .bv-settings-secondary { box-sizing: border-box; display: flex; flex: 1 1 0; flex-direction: column; max-width: none; min-height: 0; min-width: 0; padding: 0; } .bv-settings-secondary:not(.bv-settings-secondary-hidden) { border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 4px; padding: 0.5rem; } .bv-settings-secondary-hidden { display: none !important; } .bv-settings-detail-head { align-items: center; display: flex; flex-shrink: 0; gap: 0.5rem; justify-content: space-between; margin-bottom: 0.35rem; } .bv-settings-detail-title { color: inherit; font-size: 0.85rem; font-weight: 500; margin: 0; } .bv-settings-detail-close { padding: 0.2rem 0.45rem; width: auto; } .bv-settings-detail-body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; } .bv-settings-detail-body:has(.bv-ruleset-detail-multi) { overflow: hidden; } .bv-settings-detail-body > .bv-field-help-detail { box-sizing: border-box; max-width: 100%; overflow: auto; width: 100%; } .bv-settings-detail-body .bv-ruleset-detail-multi, .bv-settings-detail-body .bv-ruleset-detail-single { box-sizing: border-box; display: flex; flex: 1 1 auto; flex-direction: column; max-width: 100%; min-height: 0; width: 100%; } .bv-ruleset-detail-scroll { display: flex; flex: 1 1 auto; flex-direction: column; gap: 0.35rem; min-height: 0; overflow-y: auto; padding-right: 0.05rem; } .bv-ruleset-detail-scroll:has(.bv-tag-input-wrap--open) { overflow: visible; } .bv-ruleset-detail-entry { border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 4px; box-sizing: border-box; padding: 0.35rem 0.4rem 0.4rem; } .bv-ruleset-detail-add-row { display: flex; flex-shrink: 0; justify-content: flex-start; padding-bottom: 0.15rem; } .bv-ruleset-detail-add-row .bv-button { width: auto; } .bv-ruleset-detail-entry-dismiss { align-self: center; box-sizing: border-box; flex: 0 0 auto; font-size: 0.95rem; grid-column: 3; grid-row: 1; height: 1.65rem; line-height: 1; min-width: 1.65rem; padding: 0; width: 1.65rem; } .bv-ruleset-detail-entry .bv-ruleset-form { box-sizing: border-box; max-width: 100%; width: 100%; } .bv-ruleset-detail-entry .bv-ruleset-form-field, .bv-ruleset-detail-entry .bv-group { box-sizing: border-box; max-width: 100%; width: 100%; } .bv-ruleset-detail-footer { display: flex; flex-shrink: 0; flex-wrap: wrap; gap: 0.35rem; justify-content: flex-end; margin-top: 0.35rem; padding-top: 0.15rem; } .bv-ruleset-detail-footer .bv-button { width: auto; } .bv-settings-detail-body .bv-tag-input-wrap, .bv-settings-detail-body .bv-group-combobox-wrap { width: 100%; } body.bv-theme-solid #bv-ui { min-width: var(--bv-settings-column-width, 480px); width: var(--bv-settings-column-width, 480px); } body.bv-theme-solid #bv-ui.bv-dock-panel-bottom { width: min(var(--bv-settings-column-width, 480px), 96vw); } body.bv-theme-solid #bv-ui.bv-dock-panel-bottom.bv-settings-detail-open { max-width: min(calc(var(--bv-settings-column-width, 480px) * 2), 96vw); width: min(calc(var(--bv-settings-column-width, 480px) * 2), 96vw) !important; } #bv-ui.bv-settings-detail-open { min-width: calc(var(--bv-settings-column-width, 480px) * 2) !important; width: calc(var(--bv-settings-column-width, 480px) * 2) !important; } #bv-ui.bv-settings-detail-open .bv-settings-primary, #bv-ui.bv-settings-detail-open .bv-settings-secondary:not(.bv-settings-secondary-hidden) { flex: 1 1 0; min-width: 0; } .bv-field-label-row { align-items: center; display: flex; gap: 0.35rem; min-width: 0; width: 100%; } .bv-field-label-row .bv-field-label-text { display: block; flex: 1 1 auto; line-height: 1.35; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bv-field-help-btn { align-items: center; background: transparent; border: 0; box-sizing: border-box; color: inherit; cursor: pointer; display: inline-flex; flex: 0 0 auto; font-size: 0.85rem; height: 1.35rem; justify-content: center; line-height: 1; margin: 0; opacity: 0.85; padding: 0; position: relative; width: 1.35rem; z-index: 1; } .bv-field-help-btn:hover { opacity: 1; } .bv-field-help-detail { color: inherit; font-family: var(--bv-font-family); font-size: 0.8125rem; line-height: 1.4; } .bv-field-help-detail :is(p, ul, ol, li, strong, em, h4, h5) { color: inherit; } .bv-field-help-detail p { margin: 0 0 0.5rem; } .bv-field-help-detail p:last-child { margin-bottom: 0; } .bv-field-help-detail pre { background: rgba(0, 0, 0, 0.22); border-radius: 3px; font-family: var(--bv-font-family); font-size: 0.9em; margin: 0.35rem 0 0.5rem; overflow-x: auto; padding: 0.35rem 0.45rem; white-space: pre-wrap; } .bv-field-help-detail code { background: rgba(0, 0, 0, 0.22); border-radius: 2px; font-family: var(--bv-font-family); font-size: 0.9em; padding: 0.05rem 0.25rem; } .bv-field-help-detail pre code { background: transparent; padding: 0; } .bv-detail-form-field { align-items: stretch; display: flex; flex-direction: column; gap: 0.35rem; max-width: 100%; min-width: 0; width: 100%; } .bv-detail-form-field-body { display: flex; flex-direction: column; gap: 0.3rem; max-width: 100%; min-width: 0; width: 100%; } .bv-detail-form-field-body > .bv-input, .bv-detail-form-field-body > select.bv-input, .bv-detail-form-field-body > .bv-input.bv-text { max-width: 100%; width: 100%; } .bv-detail-form-field > .bv-label.bv-text { display: block; text-align: left; width: 100%; } .bv-detail-form-field > .bv-input, .bv-detail-form-field > select.bv-input { max-width: 100%; width: 100%; } .bv-detail-form-field.bv-pattern-group > .bv-detail-form-field-body { gap: 0.35rem; } .bv-detail-form-field.bv-pattern-group > .bv-detail-form-field-body > .bv-input.bv-text { margin-bottom: 0.15rem; } .bv-detail-form-field.bv-pattern-group > .bv-detail-form-field-body > .bv-pattern-builder { margin-top: 0; } .bv-detail-field-help { color: #9eb09e; font-size: 0.72rem; line-height: 1.35; margin: 0; width: 100%; } .bv-ledger-import-panel { display: flex; flex-direction: column; gap: 0.65rem; } .bv-ledger-import-panel > .bv-detail-form-field, .bv-ledger-import-panel > .bv-detail-actions, .bv-ledger-import-panel > .bv-settings-detail-progress { margin-top: 0 !important; } .bv-detail-actions { display: flex; flex-direction: row; flex-wrap: wrap; gap: 0.3rem; margin-top: 0; width: 100%; } .bv-detail-actions .bv-button { flex: 1 1 auto; min-width: 0; width: auto; } .bv-settings-detail-progress { display: flex; flex-direction: column; gap: 0.15rem; width: 100%; } .bv-settings-detail-progress-hidden { display: none !important; } .bv-settings-detail-progress-label { color: #c8d4c8; font-size: 0.78rem; line-height: 1.35; margin: 0; } .bv-settings-detail-progress-bar { background: rgba(0, 0, 0, 0.35); border-radius: 2px; height: 5px; overflow: hidden; position: relative; width: 100%; } .bv-settings-detail-progress-bar > span { background: #9eb09e; display: block; height: 100%; transform: scaleX(0); transform-origin: left center; transition: none; width: 100%; } .bv-settings-detail-progress-bar.bv-settings-detail-progress-indeterminate > span { animation: bv-migration-indeterminate 1.2s ease-in-out infinite; transform: none; width: 40%; } .bv-ledger-import-validation { font-size: 0.72rem; line-height: 1.35; margin: 0.15rem 0 0; } .bv-ledger-import-validation-error { color: #e55353; } .bv-ledger-import-folder-input { height: 0; opacity: 0; overflow: hidden; pointer-events: none; position: absolute; width: 0; } .bv-ledger-import-status { color: #c8d4c8; font-size: 0.78rem; line-height: 1.35; margin: 0.15rem 0 0; } .bv-ledger-import-config { display: flex; flex-direction: column; gap: 0.65rem; width: 100%; } .bv-ledger-import-run, .bv-ledger-import-summary { display: none; flex-direction: column; gap: 0.35rem; width: 100%; } .bv-ledger-import-run { justify-content: center; min-height: 6rem; } .bv-ledger-import-summary { gap: 0.35rem; } .bv-ledger-import-summary-title { color: #e8efe8; font-size: 0.85rem; font-weight: 500; line-height: 1.35; margin: 0; } .bv-ledger-import-summary-body { display: flex; flex-direction: column; gap: 0.2rem; } .bv-ledger-import-summary-line { color: #c8d4c8; font-size: 0.78rem; line-height: 1.35; margin: 0; } .bv-ledger-import-summary-note { color: #9eb09e; font-size: 0.72rem; line-height: 1.35; margin: 0.35rem 0 0; } .bv-ledger-import-panel.bv-ledger-import-running > .bv-ledger-import-config, .bv-ledger-import-panel.bv-ledger-import-running > .bv-ledger-import-summary, .bv-ledger-import-panel.bv-ledger-import-done > .bv-ledger-import-config, .bv-ledger-import-panel.bv-ledger-import-done > .bv-ledger-import-run { display: none !important; } .bv-ledger-import-panel.bv-ledger-import-running > .bv-ledger-import-run, .bv-ledger-import-panel.bv-ledger-import-done > .bv-ledger-import-summary { display: flex !important; } .bv-ruleset-panel { --bv-ruleset-action-size: 1.5rem; display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; width: 100%; } .bv-ruleset-panel-head { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; width: 100%; } .bv-ruleset-panel-title-row { align-items: center; display: flex; flex-wrap: nowrap; gap: 0.35rem; justify-content: space-between; min-width: 0; width: 100%; } .bv-ruleset-panel-title-row .bv-ruleset-panel-title { flex: 1 1 auto; min-width: 0; } .bv-ruleset-panel-settings-wrap { flex: 0 0 auto; } .bv-ruleset-panel-settings { align-items: center; box-sizing: border-box; display: inline-flex; height: var(--bv-ruleset-action-size); justify-content: center; margin: 0; min-height: var(--bv-ruleset-action-size); padding: 0.25rem 0.5rem; width: var(--bv-ruleset-action-size); } .bv-ruleset-panel-columns { column-gap: 0.25rem; display: grid; grid-template-columns: minmax(0, 1fr) max-content; min-width: 0; row-gap: 0.25rem; scrollbar-gutter: stable; width: 100%; } .bv-ruleset-panel-toolbar, .bv-ruleset-panel-group-toolbar { align-items: center; display: flex; flex-wrap: nowrap; gap: 0.25rem; grid-column: 1 / -1; min-width: 0; } .bv-ruleset-panel-toolbar:has(.bv-ruleset-panel-sort):has(.bv-ruleset-panel-add:not(.bv-hidden)) { display: grid; grid-template-columns: minmax(0, 1fr) var(--bv-ruleset-action-size) var(--bv-ruleset-action-size); } .bv-ruleset-panel-toolbar:has(.bv-ruleset-panel-sort):not(:has(.bv-ruleset-panel-add:not(.bv-hidden))) { display: grid; grid-template-columns: minmax(0, 1fr) var(--bv-ruleset-action-size); } .bv-ruleset-panel-search-wrap { align-items: center; box-sizing: border-box; display: flex; flex: 1 1 8rem; gap: 0.15rem; grid-column: 1; min-height: var(--bv-ruleset-action-size, 1.5rem); min-width: 0; padding-inline: 0.32rem 0.18rem; } .bv-ruleset-panel-search-wrap:focus-within { outline: 1px solid #9eb09e; outline-offset: 1px; } .bv-ruleset-panel-search-wrap.bv-input.bv-text { width: auto; } .bv-ruleset-panel-search, .bv-ruleset-panel-group-search { background: transparent; border: 0; box-shadow: none; box-sizing: border-box; flex: 1 1 auto; line-height: 1.25; margin: 0; min-height: 0; min-width: 0; outline: none; padding: 0; width: auto; } .bv-ruleset-panel-search-wrap > .bv-ruleset-search-clear { align-items: center; appearance: none; background: none; border: 0; box-shadow: none; box-sizing: border-box; color: inherit; cursor: pointer; display: inline-flex; flex: 0 0 auto; font: inherit; font-size: 1rem; height: auto; justify-content: center; line-height: 1; margin: 0; min-height: 0; min-width: 0; opacity: 0.75; padding: 0; width: auto; } .bv-ruleset-panel-search-wrap > .bv-ruleset-search-clear:hover { opacity: 1; } .bv-ruleset-panel-search-wrap > .bv-ruleset-search-clear:active { filter: none; transform: none; } .bv-ruleset-panel-search-wrap > .bv-ruleset-search-clear[hidden] { display: none !important; } .bv-ruleset-panel-search::-webkit-search-cancel-button, .bv-ruleset-panel-group-search::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; display: none; } .bv-ruleset-panel-add, .bv-ruleset-panel-add-group, .bv-ruleset-panel-sort { align-items: center; box-sizing: border-box; display: inline-flex; flex: 0 0 auto; font-size: 1rem; font-weight: 600; height: var(--bv-ruleset-action-size); justify-content: center; line-height: 1; margin: 0; min-height: var(--bv-ruleset-action-size); min-width: 0; padding: 0; width: var(--bv-ruleset-action-size); } .bv-ruleset-panel-toolbar .bv-ruleset-panel-add { grid-column: 2; } .bv-ruleset-panel-toolbar:has(.bv-ruleset-panel-sort) .bv-ruleset-panel-sort { grid-column: 2; } .bv-ruleset-panel-toolbar:has(.bv-ruleset-panel-sort) .bv-ruleset-panel-add { grid-column: 3; } .bv-ruleset-panel-group-toolbar .bv-ruleset-panel-add-group { flex: 0 0 auto; } .bv-ruleset-panel-empty { flex: 0 0 auto; font-size: 0.85rem; line-height: 1.35; min-height: 1.35em; } .bv-ruleset-panel-empty.bv-ruleset-panel-loading { opacity: 0.85; } .bv-ruleset-panel-scroll { display: flex; flex-direction: column; gap: 0.25rem; grid-column: 1 / -1; max-height: 14rem; min-height: 14rem; overflow-y: auto; row-gap: 0.25rem; } .bv-ruleset-panel-list { display: flex; flex-direction: column; gap: 0.25rem; min-width: 0; } .bv-ruleset-panel-row { align-items: center; column-gap: 0.25rem; display: grid; grid-template-columns: minmax(0, 1fr) max-content; min-width: 0; } .bv-ruleset-panel-main { align-items: center; display: flex; gap: 0.2rem; min-width: 0; } .bv-ruleset-panel-content, .bv-ruleset-panel-content-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .bv-ruleset-panel-main > .bv-ruleset-panel-rule-btn, .bv-ruleset-panel-main > .bv-ruleset-panel-nav-wrap { flex: 1 1 auto; min-width: 0; } .bv-ruleset-panel-rule-btn, .bv-ruleset-panel-main > .bv-ruleset-panel-nav-wrap .bv-ruleset-panel-nav-btn { align-items: center; box-sizing: border-box; display: flex; height: var(--bv-ruleset-action-size); justify-content: flex-start; line-height: 1.2; margin: 0; min-height: var(--bv-ruleset-action-size); min-width: 0; overflow: hidden; padding: 0 0.38rem; text-align: left; text-overflow: ellipsis; white-space: nowrap; width: auto; } .bv-ruleset-panel-main > .bv-ruleset-panel-nav-wrap { display: block; } .bv-ruleset-panel-main > .bv-ruleset-panel-nav-wrap .bv-ruleset-panel-nav-btn { width: 100%; } .bv-ruleset-panel-remove, .bv-ruleset-panel-action { align-items: center; box-sizing: border-box; display: inline-flex; flex: 0 0 auto; height: var(--bv-ruleset-action-size); justify-content: center; line-height: 1; margin: 0; min-height: var(--bv-ruleset-action-size); min-width: 0; padding: 0; width: var(--bv-ruleset-action-size); } .bv-ruleset-panel-remove { font-size: 0.95rem; } .bv-ruleset-panel-group-header { align-items: center; display: flex; gap: 0.35rem; justify-content: space-between; min-width: 0; } .bv-ruleset-panel-group-title { font-size: 0.78rem; font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bv-ruleset-panel-remove-group { padding: 0.2rem 0.45rem; width: auto; } .bv-ruleset-panel-toolbar button + button, .bv-ruleset-panel-row button + button, .bv-ruleset-panel-group-toolbar button + button { margin-left: 0; } .bv-ruleset-panel-settings-pane { display: flex; flex-direction: column; gap: 0.35rem; } .bv-ruleset-panel-settings-option { display: flex; flex-direction: column; gap: 0.2rem; margin-bottom: 0.55rem; } .bv-ruleset-panel-settings-option:last-child { margin-bottom: 0; } .bv-ruleset-panel-settings-row { align-items: center; display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: flex-start; } .bv-ruleset-panel-settings-row > span { flex: 1 1 auto; min-width: 0; } .bv-ruleset-panel-settings-row .bv-input.bv-checkbox-radio { margin: 0; scale: 1; } .bv-ruleset-panel-settings-row > .bv-input.bv-checkbox-radio, .bv-ruleset-panel-settings-row > .bv-ruleset-panel-settings-sort { flex: 0 0 auto; margin-left: auto; max-width: 100%; min-width: 9rem; } .bv-ruleset-panel-settings-help { color: #9eb09e; font-size: 0.72rem; line-height: 1.35; margin: 0; } .bv-ruleset-panel-settings-sort { width: auto; } .bv-ruleset-panel-comment-popover { background: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.45); color: #e8efe8; font-size: 0.78rem; line-height: 1.35; max-width: min(320px, 90vw); padding: 0.45rem 0.55rem; position: absolute; z-index: 10003; } .bv-ruleset-form { display: flex; flex-direction: column; gap: 0.35rem; } .bv-ruleset-form-field { align-items: stretch; flex-direction: column; } .bv-ruleset-detail-entry .bv-ruleset-form-field:has(.bv-label.bv-text) { align-items: center; column-gap: 0.5rem; display: grid; grid-template-columns: 6.75rem minmax(0, 1fr); } .bv-ruleset-detail-entry .bv-ruleset-form-field:has(.bv-label.bv-text):has(.bv-ruleset-detail-entry-dismiss) { grid-template-columns: 6.75rem minmax(0, 1fr) auto; } .bv-ruleset-detail-entry .bv-ruleset-form-field:has(.bv-label.bv-text) .bv-label.bv-text { justify-self: start; min-width: 0; text-align: left; white-space: nowrap; } .bv-ruleset-detail-entry .bv-ruleset-form-field:has(.bv-label.bv-text) .bv-input.bv-text, .bv-ruleset-detail-entry .bv-ruleset-form-field:has(.bv-label.bv-text) textarea.bv-input, .bv-ruleset-detail-entry .bv-ruleset-form-field:has(.bv-label.bv-text) .bv-tag-input-wrap, .bv-ruleset-detail-entry .bv-ruleset-form-field:has(.bv-label.bv-text) .bv-group-combobox-wrap { margin-top: 0; max-width: 100%; width: 100%; } .bv-ruleset-form-field .bv-input.bv-text, .bv-ruleset-form-field textarea.bv-input { margin-top: 0.25rem; width: 100%; } .bv-tag-input-wrap, .bv-group-combobox-wrap { position: relative; width: 100%; } .bv-tag-input-suggestions, .bv-group-combobox-suggestions { background: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; display: flex; flex-direction: column; gap: 0.12rem; left: 0; margin-top: 0.15rem; max-width: 100%; padding: 0.15rem; position: absolute; right: 0; top: 100%; z-index: 12; } .bv-tag-input-suggestions--fixed { margin-top: 0; position: fixed; right: auto; top: auto; z-index: 10020; } .bv-tag-input-status { color: #b8c9b8; font-size: 0.75rem; line-height: 1.35; padding: 0.1rem 0.2rem; user-select: none; } .bv-tag-input-suggestions.bv-hidden, .bv-group-combobox-suggestions.bv-hidden { display: none !important; } .bv-tag-input-option, .bv-group-combobox-option, .bv-group-combobox-create { font-size: 0.78rem; text-align: left; width: 100%; } .bv-ruleset-tag-tokens { display: inline; } .bv-ruleset-tag-token { font-weight: 500; } @supports (grid-template-columns: subgrid) { .bv-ruleset-panel-columns { grid-template-columns: minmax(0, 1fr) var(--bv-ruleset-action-size); } .bv-ruleset-panel-toolbar:has(.bv-ruleset-panel-sort):has(.bv-ruleset-panel-add:not(.bv-hidden)) { display: grid; grid-template-columns: minmax(0, 1fr) var(--bv-ruleset-action-size) var(--bv-ruleset-action-size); } .bv-ruleset-panel-toolbar:has(.bv-ruleset-panel-sort):not(:has(.bv-ruleset-panel-add:not(.bv-hidden))) { display: grid; grid-template-columns: minmax(0, 1fr) var(--bv-ruleset-action-size); } .bv-ruleset-panel-scroll { display: grid; grid-auto-rows: min-content; grid-template-columns: subgrid; } .bv-ruleset-panel-list { display: contents; } .bv-ruleset-panel-row { grid-column: 1 / -1; grid-template-columns: subgrid; } .bv-ruleset-panel-main { grid-column: 1; } .bv-ruleset-panel-remove { grid-column: 2; } } @supports not (grid-template-columns: subgrid) { .bv-ruleset-panel-columns { scrollbar-gutter: auto; } .bv-ruleset-panel-toolbar, .bv-ruleset-panel-group-toolbar { box-sizing: border-box; padding-inline-end: var(--bv-ruleset-scroll-inset, 0px); } } body.bv-theme-solid #bv-ui .bv-ruleset-panel { border: 1px solid rgba(0, 0, 0, 0.35); border-radius: 4px; box-sizing: border-box; margin-top: 0.15rem; padding: 0.35rem 0.4rem 0.4rem; } body.bv-theme-solid #bv-ui .bv-ruleset-panel-add, body.bv-theme-solid #bv-ui .bv-ruleset-panel-add-group, body.bv-theme-solid #bv-ui .bv-ruleset-panel-sort, body.bv-theme-solid #bv-ui .bv-ruleset-panel-settings { background-color: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; color: #e8efe8; font-size: 0.8rem; padding: 0; } body.bv-theme-solid #bv-ui .bv-ruleset-panel-remove, body.bv-theme-solid #bv-ui .bv-ruleset-panel-remove-group { background-color: #4a3d3d; border: 1px solid #6f5a5a; border-radius: 2px; color: #e8efe8; font-size: 0.95rem; line-height: 1; padding: 0; } body.bv-theme-solid #bv-ui .bv-ruleset-panel-remove { height: var(--bv-ruleset-action-size, 1.5rem); min-height: var(--bv-ruleset-action-size, 1.5rem); } body.bv-theme-solid #bv-ui .bv-ruleset-panel-rule-btn, body.bv-theme-solid #bv-ui .bv-ruleset-panel-main > .bv-ruleset-panel-nav-wrap .bv-ruleset-panel-nav-btn { align-items: center; background-color: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; color: #e8efe8; display: flex; font-size: 0.75rem; height: var(--bv-ruleset-action-size, 1.5rem); justify-content: flex-start; line-height: 1.2; min-height: var(--bv-ruleset-action-size, 1.5rem); padding: 0 0.38rem; text-align: left; } body.bv-theme-solid #bv-ui .bv-ruleset-panel-add:hover, body.bv-theme-solid #bv-ui .bv-ruleset-panel-add-group:hover, body.bv-theme-solid #bv-ui .bv-ruleset-panel-sort:hover, body.bv-theme-solid #bv-ui .bv-ruleset-panel-settings:hover, body.bv-theme-solid #bv-ui .bv-ruleset-panel-rule-btn:hover, body.bv-theme-solid #bv-ui .bv-ruleset-panel-main > .bv-ruleset-panel-nav-wrap .bv-ruleset-panel-nav-btn:hover, body.bv-theme-solid #bv-ui .bv-ruleset-panel-action:hover { background-color: #4f634f; } body.bv-theme-solid #bv-ui .bv-ruleset-panel-remove:hover, body.bv-theme-solid #bv-ui .bv-ruleset-panel-remove-group:hover { background-color: #5a4f4f; } body.bv-theme-solid #bv-ui .bv-ruleset-panel-action { background-color: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; color: #e8efe8; } body.bv-theme-solid #bv-ui .bv-ruleset-panel-action .bv-tag-action-icon { height: 1rem; width: 1rem; } body.bv-theme-solid #bv-ui .bv-ruleset-panel-rule-btn:active, body.bv-theme-solid #bv-ui .bv-ruleset-panel-nav-btn:active, body.bv-theme-solid #bv-ui .bv-ruleset-panel-remove:active, body.bv-theme-solid #bv-ui .bv-ruleset-panel-action:active { filter: brightness(1.2); transform: scale(0.98); } body.bv-theme-solid #bv-ui .bv-ruleset-panel-empty { color: #9eb09e; font-size: 0.72rem; } body.bv-theme-solid #bv-ui .bv-settings-secondary:not(.bv-settings-secondary-hidden) { border-color: rgba(0, 0, 0, 0.35); } body.bv-theme-solid #bv-ui .bv-ruleset-detail-entry { border-color: rgba(0, 0, 0, 0.35); } body.bv-theme-solid #bv-ui .bv-ruleset-detail-entry-dismiss { align-items: center; background-color: #3d4f3d; border: 1px solid #5a6f5a; color: #e8efe8; display: inline-flex; justify-content: center; padding: 0; } body.bv-theme-solid #bv-ui .bv-ruleset-detail-entry-dismiss:hover { background-color: #4f634f; } body.bv-theme-solid #bv-ui .bv-ruleset-panel-search-wrap.bv-input { align-items: center; min-height: var(--bv-ruleset-action-size, 1.5rem); padding-block: 0.18rem; padding-inline: 0.32rem 0.18rem; } body.bv-theme-solid #bv-ui .bv-ruleset-panel-search-wrap .bv-ruleset-panel-search, body.bv-theme-solid #bv-ui .bv-ruleset-panel-search-wrap .bv-ruleset-panel-group-search { background: transparent; border: 0; color: #1a221a; line-height: 1.25; min-height: 0; padding: 0; } body.bv-theme-solid #bv-ui .bv-ruleset-panel-search-wrap > .bv-ruleset-search-clear { background: none; border: 0; box-shadow: none; color: #1a221a; filter: none; padding: 0; } body.bv-theme-solid #bv-ui .bv-settings-detail-close { background-color: #4a5f4a; border: 1px solid #3a4f3a; color: #fff; } body.bv-theme-solid #bv-ui .bv-settings-detail-title { color: #e8efe8; } body.bv-theme-solid #bv-ui .bv-field-help-detail, body.bv-theme-solid #bv-ui .bv-field-help-detail :is(p, ul, ol, li, strong, em, h4, h5) { color: #c8d4c8; } body.bv-theme-solid #bv-ui .bv-field-help-detail pre { background-color: #1e271e; border: 1px solid rgba(0, 0, 0, 0.35); color: #e8efe8; } body.bv-theme-solid #bv-ui .bv-field-help-detail code { background-color: #1e271e; color: #e8efe8; } body.bv-theme-solid #bv-ui .bv-field-help-detail pre code { background: transparent; color: inherit; } body.bv-theme-solid #bv-ui .bv-detail-form-field > .bv-label.bv-text { color: #e8efe8; } body.bv-theme-solid #bv-ui .bv-group.bv-detail-form-field { align-items: stretch; display: flex !important; flex-direction: column; gap: 0.4rem; } body.bv-theme-solid #bv-ui .bv-detail-form-field-body { display: flex; flex-direction: column; gap: 0.3rem; width: 100%; } body.bv-theme-solid #bv-ui .bv-detail-form-field.bv-pattern-group > .bv-detail-form-field-body { gap: 0.35rem; } body.bv-theme-solid #bv-ui .bv-detail-form-field.bv-pattern-group > .bv-detail-form-field-body > .bv-input.bv-text { margin-bottom: 0.15rem; max-width: 100%; width: 100%; } body.bv-theme-solid #bv-ui .bv-detail-form-field.bv-pattern-group > .bv-detail-form-field-body > .bv-pattern-builder.bv-pattern-builder-shared { margin-top: 0; } body.bv-theme-solid #bv-ui .bv-detail-field-help { color: #9eb09e; } body.bv-theme-solid #bv-ui .bv-detail-actions { display: flex !important; flex-direction: row; gap: 0.3rem; grid-template-columns: none; width: 100%; } body.bv-theme-solid #bv-ui .bv-detail-actions .bv-button { flex: 1 1 auto; min-width: 0; width: auto; } body.bv-theme-solid #bv-ui .bv-settings-detail-progress-label { color: #c8d4c8; } body.bv-theme-solid #bv-ui .bv-ledger-import-summary-title { color: #e8efe8; } body.bv-theme-solid #bv-ui .bv-ledger-import-summary-line { color: #c8d4c8; } body.bv-theme-solid #bv-ui .bv-ledger-import-summary-note { color: #9eb09e; } @keyframes bv-migration-indeterminate { 0% { transform: translateX(-100%) scaleX(0.35); } 50% { transform: translateX(30%) scaleX(0.55); } 100% { transform: translateX(200%) scaleX(0.35); } } #bv-migration-panel.bv-migration-panel { backdrop-filter: none; background: var(--bv-theme-color, #4f535b); border: 1px solid rgba(0, 0, 0, 0.45); border-radius: 8px; bottom: 12vh; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); box-sizing: border-box; color: #e8efe8; display: flex; flex-direction: column; gap: 0.45rem; left: 50%; max-width: min(440px, 92vw); min-width: min(320px, 92vw); opacity: 1; padding: 0.85rem 1rem 0.95rem; position: fixed; transform: translateX(-50%); width: min(440px, 92vw); z-index: 10004; } #bv-migration-panel .bv-migration-script-name { color: #9eb09e; font-size: 0.72rem; line-height: 1.3; margin: 0; } #bv-migration-panel .bv-migration-group-label { font-size: 0.9rem; font-weight: 500; line-height: 1.35; margin: 0; } #bv-migration-panel .bv-migration-detail { color: #c8d4c8; font-size: 0.78rem; line-height: 1.35; margin: 0; min-height: 1.05em; } #bv-migration-panel .bv-migration-elapsed { color: #9eb09e; font-size: 0.72rem; line-height: 1.3; margin: 0; } #bv-migration-panel .bv-migration-progress { background: rgba(0, 0, 0, 0.35); border-radius: 2px; height: 5px; margin-top: 0.15rem; overflow: hidden; position: relative; width: 100%; } #bv-migration-panel .bv-migration-progress > span { background: #9eb09e; display: block; height: 100%; transform: scaleX(0); transform-origin: left center; transition: transform 0.25s ease; width: 100%; } #bv-migration-panel .bv-migration-progress.bv-migration-indeterminate > span { animation: bv-migration-indeterminate 1.2s ease-in-out infinite; transform: none; width: 40%; } #bv-migration-panel .bv-migration-failure-intro { font-size: 0.8125rem; line-height: 1.4; margin: 0 0 0.35rem; } #bv-migration-panel .bv-migration-failure-steps { font-size: 0.78rem; line-height: 1.4; margin: 0 0 0.55rem; padding-left: 1.1rem; } #bv-migration-panel .bv-migration-actions { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.35rem; } #bv-migration-panel.bv-migration-panel button + button { margin-left: 0; } #bv-migration-panel .bv-migration-actions .bv-button { background-color: #4a5f4a; border: 1px solid #3a4f3a; border-radius: 2px; box-sizing: border-box; color: #fff; cursor: pointer; display: block; font-size: 0.8125rem; font-weight: 500; line-height: 1.25; margin: 0; padding: 0.28rem 0.45rem; width: 100%; } #bv-migration-panel .bv-migration-actions .bv-button:hover { background-color: #5a6f5a; } #bv-migration-panel .bv-migration-actions .bv-migration-reset { background-color: transparent; border: 1px solid #5a6f5a; color: #c8d4c8; } #bv-migration-panel .bv-migration-actions .bv-migration-reset:hover { background-color: #3d4f3d; color: #fff; } #bv-migration-panel .bv-migration-consent-intro { font-size: 0.8125rem; line-height: 1.4; margin: 0 0 0.35rem; } #bv-migration-panel .bv-migration-consent-steps-heading { color: #c8d4c8; font-size: 0.78rem; line-height: 1.4; margin: 0 0 0.25rem; } #bv-migration-panel .bv-migration-consent-steps { font-size: 0.78rem; line-height: 1.4; margin: 0 0 0.45rem; padding-left: 1.1rem; }"
)

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

/**
 * @typedef {{
 *   entryId: number,
 *   fieldKey: string,
 *   groupLabel?: string|null,
 *   sortOrder?: number,
 *   payload?: *,
 *   rawLine?: string,
 *   comment?: string,
 * }} BrazenRulesetEntry
 */

/**
 * @typedef {{
 *   fieldKey: string,
 *   title: string,
 *   help?: *,
 *   searchPlaceholder?: string,
 *   groupSearchPlaceholder?: string,
 *   addEntryCaption?: string,
 *   createDetailTitle?: string,
 *   addGroupCaption?: string,
 *   emptyMessage?: string,
 *   noMatchMessage?: string,
 *   sortHelpText?: string,
 *   pageSize?: number,
 *   config?: {autoSort?: boolean, sortMode?: string, groupingEnabled?: boolean, hideTagTypes?: boolean},
 *   configControls?: {autoSort?: boolean, sortMode?: boolean, groupingEnabled?: boolean|'hidden', hideTagTypes?: boolean},
 *   configOptionHelp?: {autoSort?: string, sortMode?: string, groupingEnabled?: string, hideTagTypes?: string},
 *   sortModes?: {value: string, label: string}[],
 *   pageMatch?: BrazenRulesetPageMatchOptions|null,
 *   crudOptions?: {create?: boolean, edit?: boolean, delete?: boolean},
 *   loadPage: function({fieldKey: string, query: string, groupQuery: string, cursor: number|null}): Promise<{entries: BrazenRulesetEntry[], nextCursor: number|null}>,
 *   onConfigChange?: function(Object): void,
 *   onAddRule?: function(): void,
 *   onAddGroup?: function(): void,
 *   onEdit?: function(BrazenRulesetEntry): void,
 *   onRemove?: function(number): void,
 *   onRemoveGroup?: function(string): void,
 *   getRowActions?: function(BrazenRulesetEntry): HTMLElement[],
 *   renderEntryContent?: function(BrazenRulesetEntry, {hideTagTypes?: boolean}): HTMLElement|string,
 *   onBeforeRender?: function(BrazenRulesetEntry[]): void|Promise<void>,
 * }} BrazenRulesetPanelOptions
 */

/**
 * @typedef {{
 *   element: HTMLElement,
 *   render: function(BrazenRulesetEntry[]): void,
 *   reload: function(): Promise<void>,
 *   dispose: function(): void,
 *   getFilterQuery: function(): string,
 *   getGroupFilterQuery: function(): string,
 *   isCurrentPagePresent: function(string=): boolean,
 *   checkPageMatch: function(): void,
 *   updateConfig: function(Object): void,
 * }} BrazenRulesetPanel
 */

/**
 * @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 {Node|Node[]} nodes
 */
static appendToBody(nodes)
{
  Utilities.appendChildren(document.body, nodes)
}

/**
 * @return {HTMLElement}
 */
static createDockPanelStack()
{
  let stack = document.getElementById('bv-dock-panel-stack')
  if (stack) {
    return stack
  }
  stack = Utilities.makeEl('div', {
    class: 'bv-dock-panel-stack',
    attrs: {id: 'bv-dock-panel-stack'},
  })
  return stack
}

/**
 * @return {HTMLElement|null}
 */
static getDockPanelStack()
{
  return document.getElementById('bv-dock-panel-stack')
}

/**
 * Append dock slide panels into `#bv-dock-panel-stack` (creates / mounts the stack when needed).
 * @param {Node|Node[]} nodes
 */
static appendToDockPanelStack(nodes)
{
  let stack = BrazenViewLayer.getDockPanelStack() ?? BrazenViewLayer.createDockPanelStack()
  if (!stack.parentElement) {
    BrazenViewLayer.appendToBody(stack)
  }
  Utilities.appendChildren(stack, 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': {
      // Circle + plus — unambiguous “add to queue”.
      svg.innerHTML = ''
      let addRing = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
      addRing.setAttribute('cx', '12')
      addRing.setAttribute('cy', '12')
      addRing.setAttribute('r', '8.25')
      addRing.setAttribute('fill', 'none')
      addRing.setAttribute('stroke', 'currentColor')
      addRing.setAttribute('stroke-width', '1.75')
      let addPlus = document.createElementNS('http://www.w3.org/2000/svg', 'path')
      addPlus.setAttribute('d', 'M12 8v8M8 12h8')
      addPlus.setAttribute('fill', 'none')
      addPlus.setAttribute('stroke', 'currentColor')
      addPlus.setAttribute('stroke-width', '2.25')
      addPlus.setAttribute('stroke-linecap', 'round')
      svg.append(addRing, addPlus)
      return svg
    }
    case 'queue-remove': {
      // Circle + X — unambiguous “remove from queue”.
      svg.innerHTML = ''
      let remRing = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
      remRing.setAttribute('cx', '12')
      remRing.setAttribute('cy', '12')
      remRing.setAttribute('r', '8.25')
      remRing.setAttribute('fill', 'none')
      remRing.setAttribute('stroke', 'currentColor')
      remRing.setAttribute('stroke-width', '1.75')
      let remX = document.createElementNS('http://www.w3.org/2000/svg', 'path')
      remX.setAttribute('d', 'M9 9l6 6M15 9l-6 6')
      remX.setAttribute('fill', 'none')
      remX.setAttribute('stroke', 'currentColor')
      remX.setAttribute('stroke-width', '2.25')
      remX.setAttribute('stroke-linecap', 'round')
      svg.append(remRing, remX)
      return svg
    }
    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 'crown':
      // Three-point crown — download-manager leader tab control.
      path.setAttribute('d', 'M5 16h14l-1.5-8-3.5 3L12 6l-2 5-3.5-3L5 16zM5 16v2h14v-2')
      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()
{
  let existing = document.getElementById('bv-idb-blocked-banner')
  if (existing) {
    return existing
  }
  let banner = document.createElement('div')
  banner.id = 'bv-idb-blocked-banner'
  banner.textContent = 'This script requires IndexedDB and cannot run without it.'
  document.body.prepend(banner)
  return banner
}

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

/**
 * Panel is mounted for layout (includes mid slide-in/out before `bv-dock-panel-offscreen` clears).
 * @param {HTMLElement|null|undefined} panel
 * @return {boolean}
 * @private
 */
static _isDockSlidePanelInLayout(panel)
{
  if (!panel) {
    return false
  }
  return !panel.classList.contains('bv-dock-panel-hidden') &&
    !panel.classList.contains('bv-hidden') &&
    !panel.hidden &&
    panel.style.display !== 'none'
}

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

/**
 * Position the dock panel stack host beside/above the dock; panels flex inside the stack.
 * @param {HTMLElement} dockElement
 * @param {string} orientation
 * @param {HTMLElement[]} panels near-dock → outward order (settings first, then review panels)
 */
static syncDockPanelStackHost(dockElement, orientation, panels)
{
  let stack = BrazenViewLayer.getDockPanelStack()
  if (!stack || !dockElement?.getBoundingClientRect) {
    return
  }
  if (!stack.parentElement) {
    BrazenViewLayer.appendToBody(stack)
  }
  let dockRect = dockElement.getBoundingClientRect()
  let gap = 8
  let list = (panels ?? []).filter(Boolean)
  let syncSignature = [
    orientation,
    Math.round(dockRect.top),
    Math.round(dockRect.left),
    Math.round(dockRect.right),
    Math.round(dockRect.width),
    Math.round(window.innerHeight),
    Math.round(window.innerWidth),
  ].join('|')
  let needsOrder = list.some((panel, i) => panel.parentElement !== stack || stack.children[i] !== panel)
  if (needsOrder) {
    BrazenViewLayer._orderDockPanelStackChildren(stack, list)
  }
  if (stack.dataset.bvDockStackSync === syncSignature &&
      stack.classList.contains('bv-dock-panel-stack-' + orientation)) {
    return
  }
  stack.dataset.bvDockStackSync = syncSignature
  BrazenViewLayer._applyDockPanelStackHostStyles(stack, orientation, dockRect, gap)
}

/**
 * Keep stack children in near-dock → outward order without redundant DOM moves.
 * @param {HTMLElement} stack
 * @param {HTMLElement[]} list
 * @private
 */
static _orderDockPanelStackChildren(stack, list)
{
  for (let i = 0; i < list.length; i++) {
    let panel = list[i]
    let newlyMounted = panel.parentElement !== stack
    if (newlyMounted || stack.children[i] !== panel) {
      stack.appendChild(panel)
    }
    if (newlyMounted) {
      BrazenViewLayer._clearPanelStackLayout(panel, {full: true})
    } else {
      BrazenViewLayer._clearPanelStackLayout(panel)
    }
  }
}

/**
 * @param {HTMLElement} stack
 * @param {string} prop
 * @param {string} value
 * @private
 */
static _setStackStyleIfNeeded(stack, prop, value)
{
  if (stack.style[prop] !== value) {
    stack.style[prop] = value
  }
}

/**
 * @param {HTMLElement} stack
 * @param {string[]} props
 * @private
 */
static _clearStackStylesIfSet(stack, props)
{
  for (let prop of props) {
    if (stack.style[prop]) {
      stack.style[prop] = ''
    }
  }
}

/**
 * @param {HTMLElement} stack
 * @param {string} orientation
 * @param {DOMRect} dockRect
 * @param {number} gap
 * @private
 */
static _applyDockPanelStackHostStyles(stack, orientation, dockRect, gap)
{
  stack.classList.remove('bv-dock-panel-stack-left', 'bv-dock-panel-stack-right', 'bv-dock-panel-stack-bottom')
  stack.classList.add('bv-dock-panel-stack-' + orientation)
  if (orientation === 'left') {
    BrazenViewLayer._clearStackStylesIfSet(stack, ['right', 'bottom', 'transform', 'maxWidth'])
    BrazenViewLayer._setStackStyleIfNeeded(stack, 'left', dockRect.right + 'px')
    BrazenViewLayer._setStackStyleIfNeeded(stack, 'top', dockRect.top + 'px')
    BrazenViewLayer._setStackStyleIfNeeded(stack, 'maxHeight', Math.max(160, window.innerHeight - dockRect.top - gap) + 'px')
    return
  }
  if (orientation === 'right') {
    BrazenViewLayer._clearStackStylesIfSet(stack, ['left', 'bottom', 'transform', 'maxWidth'])
    BrazenViewLayer._setStackStyleIfNeeded(stack, 'right', (window.innerWidth - dockRect.left) + 'px')
    BrazenViewLayer._setStackStyleIfNeeded(stack, 'top', dockRect.top + 'px')
    BrazenViewLayer._setStackStyleIfNeeded(stack, 'maxHeight', Math.max(160, window.innerHeight - dockRect.top - gap) + 'px')
    return
  }
  BrazenViewLayer._clearStackStylesIfSet(stack, ['right', 'top'])
  BrazenViewLayer._setStackStyleIfNeeded(stack, 'left', (dockRect.left + dockRect.width / 2) + 'px')
  BrazenViewLayer._setStackStyleIfNeeded(stack, 'bottom', (window.innerHeight - dockRect.top) + 'px')
  BrazenViewLayer._setStackStyleIfNeeded(stack, 'transform', 'translateX(-50%)')
  BrazenViewLayer._setStackStyleIfNeeded(stack, 'maxWidth', '96vw')
  BrazenViewLayer._setStackStyleIfNeeded(stack, 'maxHeight', Math.max(160, dockRect.top - gap) + 'px')
}

/**
 * @deprecated Use {@link syncDockPanelStackHost}. Kept as alias for callers.
 * @param {HTMLElement} dockElement
 * @param {string} orientation
 * @param {HTMLElement[]} panels
 */
static syncDockSlidePanelStack(dockElement, orientation, panels)
{
  BrazenViewLayer.syncDockPanelStackHost(dockElement, orientation, panels)
}

/**
 * Clear per-panel fixed coordinates and legacy bottom dual-open layout classes.
 * @param {HTMLElement} panel
 * @param {{full?: boolean}} [options] `full` clears width caps when a panel first enters the stack.
 * @private
 */
static _clearPanelStackLayout(panel, options = {})
{
  if (!panel) {
    return
  }
  let clearIfSet = (prop) => {
    if (panel.style[prop]) {
      panel.style[prop] = ''
    }
  }
  clearIfSet('left')
  clearIfSet('right')
  clearIfSet('top')
  clearIfSet('bottom')
  if (panel.classList.contains('bv-dock-panel-bottom-half-left') ||
      panel.classList.contains('bv-dock-panel-bottom-half-right') ||
      panel.classList.contains('bv-dock-panel-bottom-side')) {
    panel.classList.remove(
        'bv-dock-panel-bottom-half-left',
        'bv-dock-panel-bottom-half-right',
        'bv-dock-panel-bottom-side',
    )
  }
  if (!options.full) {
    return
  }
  clearIfSet('maxWidth')
  clearIfSet('maxHeight')
  if (panel.id === 'bv-ui' && panel.classList.contains('bv-settings-detail-open')) {
    return
  }
  clearIfSet('width')
  clearIfSet('minWidth')
}

/**
 * @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)
  Utilities.setHidden(panel, false)
  let transformOrientation = BrazenViewLayer._resolveDockSlideTransformOrientation(panel, orientation)
  if (BrazenViewLayer.isDockSlidePanelVisible(panel)) {
    BrazenViewLayer._applyDockSlidePanelTransform(panel, transformOrientation, false)
    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, transformOrientation, false)
    return
  }
  panel.classList.add('bv-dock-panel-offscreen')
  BrazenViewLayer._applyDockSlidePanelTransform(panel, transformOrientation, true)
  beforeAnimate?.()
  panel.offsetHeight
  panel.classList.remove('bv-dock-panel-offscreen')
  BrazenViewLayer._applyDockSlidePanelTransform(panel, transformOrientation, 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
  }
  let transformOrientation = BrazenViewLayer._resolveDockSlideTransformOrientation(panel, orientation)
  panel.classList.add('bv-dock-panel-offscreen')
  BrazenViewLayer._applyDockSlidePanelTransform(panel, transformOrientation, 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 = {})
{
  // Abort prior wiring so repeat calls do not stack listeners.
  panel._bvDockWireAbort?.abort()
  let controller = new AbortController()
  panel._bvDockWireAbort = controller
  let signal = controller.signal
  let closeButton = panel.querySelector('.bv-dock-slide-panel-close')
  if (closeButton && handlers.onClose) {
    closeButton.addEventListener('click', handlers.onClose, {signal})
  }
  if (handlers.onMouseLeave) {
    panel.addEventListener('mouseleave', (event) => {
      handlers.onMouseLeave(event.currentTarget)
    }, {signal})
  }
}

/**
 * @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')
  let mainActive = !!(mainPanel && BrazenViewLayer.isDockSlidePanelVisible(mainPanel))
  let complianceActive = !!(compliancePanel && BrazenViewLayer.isDockSlidePanelVisible(compliancePanel))
  for (let button of document.querySelectorAll('.bv-dock-main-ui-btn')) {
    button.classList.toggle('bv-dock-btn-active', mainActive)
  }
  for (let button of document.querySelectorAll('.bv-dock-compliance-rules-btn')) {
    button.classList.toggle('bv-dock-btn-active', complianceActive)
  }
}

/**
 * @param {HTMLElement} slot
 * @param {boolean} visible
 */
static setDownloadManagerProgressSlotVisible(slot, visible)
{
  slot.classList.toggle('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
  }
  let signature = `${progress.label}|${progress.stepIndex}|${progress.stepCount}|${progress.complete ? 1 : 0}|${progress.failed ? 1 : 0}`
  if (element.dataset.bvDmProgress === signature) {
    return
  }
  element.dataset.bvDmProgress = signature
  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
  }
  delete element.dataset.bvDmProgress
  element.classList.remove('bv-dm-item-active', 'bv-dm-item-failed')
  element.querySelector('.bv-dm-item-progress')?.remove()
}

/**
 * @return {HTMLElement}
 */
static createDownloadManagerProgressSlot()
{
  return Utilities.makeEl('div', {
    class: 'bv-dock-progress-panel',
    attrs: {
      title: 'Download queue: primary bar and completed/total. Resolution queue: secondary bar (below on bottom dock; beside counters on left/right).',
    },
    children: Utilities.makeEl('div', {
      class: 'bv-dock-progress-slot',
      children: [
        Utilities.makeEl('div', {class: 'bv-dock-progress-bar bv-dock-progress-download', children: Utilities.makeEl('span')}),
        Utilities.makeEl('div', {class: 'bv-dock-progress-label-wrap', children: Utilities.makeEl('span', {class: 'bv-dock-progress-label'})}),
        Utilities.makeEl('div', {class: 'bv-dock-progress-bar bv-dock-progress-resolution', children: Utilities.makeEl('span')}),
      ],
    }),
  })
}

/**
 * @param {HTMLElement} slot
 * @param {{resolution: {current: number, total: number}, download: {current: number, total: number}}} progress
 */
static updateDownloadManagerProgressSlot(slot, progress)
{
  let download = progress.download ?? {current: 0, total: 0}
  let resolution = progress.resolution ?? {current: 0, total: 0}
  let vertical = !!slot.closest('.bv-dock-left, .bv-dock-right')
  // scaleX/scaleY so fill works before the panel has a definite used width (first
  // resolution-only batch used to paint 0% until download counters widened the slot).
  let setFill = (fill, current, total, fillVertical) => {
    if (!fill) {
      return
    }
    let ratio = total > 0 ? Math.min(1, Math.max(0, current / total)) : 0
    fill.style.width = '100%'
    fill.style.height = '100%'
    if (fillVertical) {
      fill.style.transform = `scaleY(${ratio})`
      fill.style.transformOrigin = 'bottom center'
    } else {
      fill.style.transform = `scaleX(${ratio})`
      fill.style.transformOrigin = 'left center'
    }
  }
  setFill(slot.querySelector('.bv-dock-progress-download > span'), download.current, download.total, vertical)
  setFill(slot.querySelector('.bv-dock-progress-resolution > span'), resolution.current, resolution.total, vertical)
  let label = slot.querySelector('.bv-dock-progress-label')
  if (label) {
    label.textContent = `${download.current}/${download.total}`
  }
}

/**
 * @param {HTMLElement} panel
 * @param {string} orientation
 * @return {string}
 * @private
 */
static _resolveDockSlideTransformOrientation(panel, orientation)
{
  return orientation
}

static _applyDockSlidePanelTransform(panel, orientation, offscreen)
{
  panel.classList.add('bv-dock-panel-animate')
  if (orientation === 'bottom') {
    panel.style.transform = offscreen ? 'translateY(100%)' : 'translateY(0)'
    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 {HTMLElement}
 * @private
 */
_section = null

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

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

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

/**
 * @param {(HTMLElement|Node)[]} children
 * @return {HTMLElement}
 */
createBottomSection(children)
{
  return Utilities.makeEl('div', {class: 'bv-bottom-section', children})
}

/**
 * @return {HTMLElement}
 */
createBreakSeparator()
{
  return Utilities.makeEl('br', {class: 'bv-break'})
}

/**
 * @return {HTMLElement}
 */
createContainer()
{
  this._section = Utilities.makeEl('section', {class: 'bv-section bv-font-primary'})
  return this._section
}

/**
 * @param {HTMLElement|HTMLElement[]} children
 * @param {string} wrapperClasses
 * @return {HTMLElement}
 */
createFormActions(children, wrapperClasses = '')
{
  let el = Utilities.makeEl('div', {class: 'bv-actions'})
  if (wrapperClasses) {
    el.className = ('bv-actions ' + wrapperClasses).trim()
  }
  Utilities.appendChildren(el, children)
  if (!wrapperClasses.includes('bv-flex-column')) {
    let buttonCount = el.querySelectorAll(':scope > .bv-button').length
    if (buttonCount > 0) {
      el.style.setProperty('--bv-actions-cols', String(buttonCount))
    }
  }
  return el
}

/**
 * Settings detail-pane field: plain label, control, optional help paragraph below (no ⓘ).
 *
 * @param {string} label
 * @param {HTMLElement|HTMLElement[]} control
 * @param {string|HTMLElement|HTMLElement[]} [help]
 * @return {HTMLElement}
 */
createDetailFormFieldGroup(label, control, help = '')
{
  let group = Utilities.makeEl('div', {class: 'bv-group bv-detail-form-field'})
  let body = Utilities.makeEl('div', {class: 'bv-detail-form-field-body'})
  Utilities.appendChildren(body, control)
  if (BrazenViewLayer._hasFieldHelp(help)) {
    if (typeof help === 'string') {
      body.append(Utilities.makeEl('p', {class: 'bv-detail-field-help', text: help}))
    } else {
      let helpEl = Utilities.makeEl('div', {class: 'bv-detail-field-help'})
      Utilities.appendChildren(helpEl, help)
      body.append(helpEl)
    }
  }
  group.append(
      Utilities.makeEl('label', {class: 'bv-label bv-text', text: label}),
      body,
  )
  return group
}

/**
 * @param {string} label
 * @param {Array} keyValuePairs
 * @param {string|HTMLElement|HTMLElement[]} [help]
 * @return {HTMLElement}
 */
createDetailFormSelectGroup(label, keyValuePairs, help = '')
{
  return this.createDetailFormFieldGroup(label, this.createFormGroupDropdown('', keyValuePairs), help)
}

/**
 * Flex action row for settings detail panes (not settings-tab `.bv-actions` grid).
 *
 * @param {HTMLElement|HTMLElement[]} children
 * @return {HTMLElement}
 */
createDetailFormActions(children)
{
  let el = Utilities.makeEl('div', {class: 'bv-detail-actions'})
  Utilities.appendChildren(el, children)
  return el
}

/**
 * @return {HTMLElement}
 */
static createSettingsDetailProgressPanel()
{
  return Utilities.makeEl('div', {
    class: 'bv-settings-detail-progress',
    children: [
      Utilities.makeEl('p', {class: 'bv-settings-detail-progress-label', text: ''}),
      Utilities.makeEl('div', {
        class: 'bv-settings-detail-progress-bar',
        children: Utilities.makeEl('span'),
      }),
    ],
  })
}

/**
 * @param {HTMLElement|null|undefined} panel
 * @param {boolean} visible
 */
static setSettingsDetailProgressPanelVisible(panel, visible)
{
  if (!panel) {
    return
  }
  panel.classList.toggle('bv-settings-detail-progress-hidden', !visible)
}

/**
 * @param {HTMLElement|null|undefined} panel
 * @param {{current?: number, total?: number, label?: string, indeterminate?: boolean, matched?: number, skipped?: number}} progress
 */
static updateSettingsDetailProgressPanel(panel, progress = {})
{
  if (!panel) {
    return
  }
  let labelEl = panel.querySelector('.bv-settings-detail-progress-label')
  let bar = panel.querySelector('.bv-settings-detail-progress-bar')
  let fill = bar?.querySelector('span')
  let current = progress.current ?? 0
  let total = progress.total ?? 0
  let parts = []
  if (progress.label) {
    parts.push(progress.label)
  }
  if (total > 0) {
    parts.push(`${current.toLocaleString()} / ${total.toLocaleString()}`)
  }
  if (progress.matched != null) {
    parts.push(`${progress.matched.toLocaleString()} matched`)
  }
  if (progress.skipped != null) {
    parts.push(`${progress.skipped.toLocaleString()} skipped`)
  }
  if (labelEl) {
    labelEl.textContent = parts.join(' · ')
  }
  bar?.classList.toggle('bv-settings-detail-progress-indeterminate', !!progress.indeterminate)
  if (fill) {
    let ratio = !progress.indeterminate && total > 0 ?
        Math.min(1, Math.max(0, current / total)) :
        0
    fill.style.transform = `scaleX(${ratio})`
  }
}

/**
 * @param {string} caption
 * @param {EventListener} onClick
 * @param {string} hoverHelp
 * @return {HTMLElement}
 */
createFormButton(caption, hoverHelp, onClick)
{
  return Utilities.makeEl('button', {
    class: 'bv-button',
    text: caption,
    attrs: {title: hoverHelp},
    on: {click: onClick},
  })
}

/**
 * @param {*} help
 * @return {boolean}
 * @private
 */
static _hasFieldHelp(help)
{
  if (help == null || help === '') {
    return false
  }
  if (typeof help === 'object' && help.nodeType) {
    return true
  }
  return String(help).trim().length > 0
}

/**
 * @param {string|{help?: *}|HTMLElement} help
 * @return {{help: *}}
 * @private
 */
static _normalizeFieldHelpOptions(help)
{
  if (help && typeof help === 'object' && !('nodeType' in help)) {
    return {
      help: help.help ?? '',
    }
  }
  if (help && typeof help === 'object' && 'nodeType' in help) {
    return {help}
  }
  return {help: String(help ?? '')}
}

/**
 * @param {string} label
 * @param {{help?: string|HTMLElement|HTMLElement[]}} [options]
 * @return {HTMLElement|null}
 */
static createFieldHelpButton(label, options = {})
{
  let settings = BrazenViewLayer._normalizeFieldHelpOptions(options)
  if (!BrazenViewLayer._hasFieldHelp(settings.help)) {
    return null
  }
  return Utilities.makeEl('button', {
    class: 'bv-field-help-btn',
    text: 'ⓘ',
    attrs: {
      type: 'button',
      'aria-label': 'Toggle help',
    },
    on: {
      click: (event) => {
        event.preventDefault()
        event.stopPropagation()
        BrazenViewLayer._openFieldHelpDetail(label, settings)
      },
    },
  })
}

/**
 * @param {string[]|{value: string, label?: string}[]} modes
 * @return {{value: string, label: string}[]}
 */
static normalizeRulesetSortModes(modes)
{
  /** @type {Record<string, string>} */
  let labels = {
    'natural-asc': 'Natural A→Z',
    'natural-desc': 'Natural Z→A',
  }
  return (Array.isArray(modes) ? modes : []).map((mode) => {
    if (typeof mode === 'string') {
      return {value: mode, label: labels[mode] ?? mode}
    }
    let value = String(mode.value ?? '')
    return {value, label: mode.label ?? labels[value] ?? value}
  })
}

/**
 * @param {string} label
 * @param {{help?: *}} settings
 * @private
 */
static _openFieldHelpDetail(label, settings)
{
  if (!BrazenViewLayer._hasFieldHelp(settings.help)) {
    return
  }
  let content = typeof settings.help === 'string' ?
      Utilities.makeEl('div', {class: 'bv-field-help-detail', html: settings.help}) :
      settings.help
  BrazenViewLayer._toggleSettingsDetailPane(label, () => {
    BrazenViewLayer.openSettingsDetailPane({title: label, content})
  })
}

/**
 * Close the detail pane when it is already open for `title`; otherwise run `open`.
 * @param {string} title
 * @param {function(): void} open
 */
static _toggleSettingsDetailPane(title, open)
{
  let pane = BrazenViewLayer._settingsDetailPane
  if (pane?.open && pane.titleEl.textContent === title) {
    BrazenViewLayer.closeSettingsDetailPane()
    return
  }
  open()
}

createFormCheckBoxesGroupSection(label, keyValuePairs, hoverHelp)
{
  return this._createFormMultiInputSection(label, keyValuePairs, hoverHelp, 'checkbox', 'bv-checkboxes-group')
}

/**
 * @return {HTMLElement}
 */
createFormGroup()
{
  return Utilities.makeEl('div', {class: 'bv-group'})
}

/**
 * @param {string} id
 * @param {Array} keyValuePairs
 *
 * @return {HTMLElement}
 */
createFormGroupDropdown(id, keyValuePairs)
{
  let dropdown = Utilities.makeEl('select', {class: 'bv-input', attrs: {id}})

  for (let i = 0; i < keyValuePairs.length; i++) {
    let option = Utilities.makeEl('option', {text: keyValuePairs[i][1], attrs: {value: keyValuePairs[i][0]}})
    option.selected = (i === 0)
    dropdown.append(option)
  }
  return dropdown
}

/**
 * @param {string} type
 *
 * @return {HTMLElement}
 */
createFormGroupInput(type)
{
  let input = Utilities.makeEl('input', {class: 'bv-input', attrs: {type}})
  switch (type) {
    case 'number':
    case 'text':
      input.classList.add('bv-text')
      break

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

/**
 * @param {string} label
 * @param {string} inputType
 * @return {HTMLElement}
 */
createFormGroupLabel(label, inputType = '')
{
  let labelFormGroup = Utilities.makeEl('label', {class: 'bv-label', text: label})
  if (inputType !== '') {
    switch (inputType) {
      case 'number':
      case 'text':
        labelFormGroup.classList.add('bv-text')
        labelFormGroup.textContent = labelFormGroup.textContent + ': '
        break
      case 'radio':
      case 'checkbox':
        labelFormGroup.classList.add('bv-checkbox-radio')
        break
    }
  }
  return labelFormGroup
}

/**
 * @param {string} label
 * @param {string} inputType
 * @param {string|{help?: *}} hoverHelp
 * @return {HTMLElement}
 */
createFormInputGroup(label, inputType, hoverHelp = '')
{
  let helpSettings = BrazenViewLayer._normalizeFieldHelpOptions(hoverHelp)
  let group = this.createFormGroup()
  Utilities.appendChildren(group, [
    this.createFieldLabelRow(label, helpSettings),
    this.createFormGroupInput(inputType),
  ])
  return group
}

createFormRadiosGroupSection(label, keyValuePairs, hoverHelp)
{
  return this._createFormMultiInputSection(label, keyValuePairs, hoverHelp, 'radio', 'bv-radios-group')
}

/**
 * @param {string} label
 * @param {Array} keyValuePairs
 * @param {string} hoverHelp
 * @param {'checkbox'|'radio'} inputType
 * @param {string} sectionClass
 * @return {HTMLElement}
 * @private
 */
_createFormMultiInputSection(label, keyValuePairs, hoverHelp, inputType, sectionClass)
{
  let helpSettings = BrazenViewLayer._normalizeFieldHelpOptions(hoverHelp)
  let section = this.createFormSection('')
  section.classList.add(sectionClass)
  section.prepend(this.createFieldLabelRow(label, helpSettings))
  for (let i = 0; i < keyValuePairs.length; i++) {
    let pair = keyValuePairs[i]
    let input = this.createFormGroupInput(inputType)
    input.setAttribute('data-value', pair[1])
    if (inputType === 'radio') {
      input.checked = (i === 0)
      input.addEventListener('change', (event) => {
        let currentTarget = event.currentTarget
        let sectionElement = currentTarget.closest('.' + sectionClass)
        if (sectionElement) {
          for (let element of sectionElement.querySelectorAll('input')) {
            if (!element.isSameNode(currentTarget)) {
              element.checked = false
            }
          }
        }
      })
    }
    section.append(
        Utilities.makeEl('div', {
          class: 'bv-group',
          children: [
            this.createFormGroupLabel(pair[0], inputType),
            input,
          ],
        }),
    )
  }
  return section
}

/**
 * @param {string} label
 * @param {string} inputsType
 * @param {number} minimum
 * @param {number} maximum
 * @param {string} hoverHelp
 * @return {HTMLElement}
 */
createFormRangeInputGroup(label, inputsType, minimum, maximum, hoverHelp)
{
  let helpSettings = BrazenViewLayer._normalizeFieldHelpOptions(hoverHelp)
  let group = this.createFormGroup()
  group.classList.add('bv-range-group')
  let inputsWrap = Utilities.makeEl('div', {class: 'bv-range-inputs'})
  let first = this.createFormGroupInput(inputsType)
  first.setAttribute('min', String(minimum))
  first.setAttribute('max', String(maximum))
  let second = this.createFormGroupInput(inputsType)
  second.setAttribute('min', String(minimum))
  second.setAttribute('max', String(maximum))
  inputsWrap.append(first, second)
  Utilities.appendChildren(group, [
    this.createFieldLabelRow(label, helpSettings),
    inputsWrap,
  ])
  return group
}

/**
 * @param {string} title
 * @return {HTMLElement}
 */
createFormSection(title = '')
{
  return Utilities.makeEl('div', {
    children: Utilities.makeEl('label', {class: 'bv-title', text: title}),
  })
}

/**
 * Single-sentence intro for a primary settings form section (below `.bv-title`, above actions).
 *
 * @param {string} text
 * @return {HTMLElement}
 */
createFormSectionIntro(text)
{
  return Utilities.makeEl('p', {class: 'bv-section-intro', text})
}

/**
 * @param {BrazenRulesetPanelOptions} options
 * @return {BrazenRulesetPanel}
 */
createRulesetPanel(options = {})
{
  let settings = {
    fieldKey: '',
    title: '',
    help: '',
    searchPlaceholder: 'Search rules…',
    groupSearchPlaceholder: 'Search groups…',
    addEntryCaption: 'Add Rule',
    createDetailTitle: '',
    addGroupCaption: 'Add group',
    emptyMessage: 'No rules yet.',
    noMatchMessage: 'No matching rules.',
    sortHelpText: 'Sort rules',
    pageSize: 50,
    config: {},
    configControls: {},
    configOptionHelp: {},
    sortModes: [
      {value: 'natural-asc', label: 'Natural A→Z'},
      {value: 'natural-desc', label: 'Natural Z→A'},
    ],
    crudOptions: {create: true, edit: true, delete: true},
    loadPage: async () => ({entries: [], nextCursor: null}),
    onConfigChange: () => {},
    onAddRule: () => {},
    onAddGroup: () => {},
    onEdit: () => {},
    onRemove: () => {},
    onRemoveGroup: null,
    getRowActions: () => [],
    renderEntryContent: null,
    onBeforeRender: () => {},
    pageMatch: null,
    onSort: null,
    ...options,
  }

  settings.sortModes = BrazenViewLayer.normalizeRulesetSortModes(settings.sortModes)

  let filterQuery = ''
  let groupFilterQuery = ''
  let loadedEntries = []
  let nextCursor = null
  let loadGeneration = 0
  let paintGeneration = 0
  let loading = false

  let listEl = Utilities.makeEl('div', {class: 'bv-ruleset-panel-list'})
  let emptyEl = Utilities.makeEl('div', {class: 'bv-ruleset-panel-empty', text: settings.emptyMessage})
  let sentinelEl = Utilities.makeEl('div', {class: 'bv-ruleset-panel-sentinel', attrs: {'aria-hidden': 'true'}})
  let loadingEl = Utilities.makeEl('div', {class: 'bv-ruleset-panel-loading bv-hidden', text: 'Loading…'})

  let searchEl = Utilities.makeEl('input', {
    class: 'bv-ruleset-panel-search',
    attrs: {
      type: 'search',
      placeholder: settings.searchPlaceholder,
      title: settings.searchHelpText ?? settings.searchPlaceholder,
    },
  })
  let searchClearBtn = Utilities.makeEl('button', {
    class: 'bv-ruleset-search-clear',
    text: '×',
    attrs: {type: 'button', title: 'Clear search'},
  })

  let groupSearchEl = Utilities.makeEl('input', {
    class: 'bv-ruleset-panel-group-search',
    attrs: {
      type: 'search',
      placeholder: settings.groupSearchPlaceholder,
      title: settings.groupSearchPlaceholder,
    },
  })
  let groupSearchClearBtn = Utilities.makeEl('button', {
    class: 'bv-ruleset-search-clear',
    text: '×',
    attrs: {type: 'button', title: 'Clear group search'},
  })

  let resolveCreateDetailTitle = () => settings.createDetailTitle ||
      ((settings.title ? `${settings.title} - ` : '') + settings.addEntryCaption)

  let addRuleBtn = Utilities.makeEl('button', {
    class: 'bv-ruleset-panel-add bv-button',
    text: '+',
    attrs: {type: 'button', title: settings.addEntryCaption},
    on: {
      click: () => BrazenViewLayer._toggleSettingsDetailPane(resolveCreateDetailTitle(), () => {
        settings.onAddRule()
      }),
    },
  })
  let addGroupBtn = Utilities.makeEl('button', {
    class: 'bv-ruleset-panel-add-group bv-button',
    text: settings.addGroupCaption,
    attrs: {type: 'button', title: settings.addGroupCaption},
    on: {click: () => settings.onAddGroup()},
  })

  let searchWrap = Utilities.makeEl('div', {
    class: 'bv-ruleset-panel-search-wrap bv-input bv-text',
    children: [searchEl, searchClearBtn],
  })
  let toolbarChildren = [searchWrap, addRuleBtn]
  let sortBtn = null
  if (typeof settings.onSort === 'function') {
    sortBtn = Utilities.makeEl('button', {
      class: 'bv-ruleset-panel-sort bv-button',
      text: '⇅',
      attrs: {type: 'button', title: settings.sortHelpText},
      on: {click: () => settings.onSort()},
    })
    toolbarChildren.splice(1, 0, sortBtn)
  }
  let toolbar = Utilities.makeEl('div', {
    class: 'bv-ruleset-panel-toolbar',
    children: toolbarChildren,
  })

  let groupSearchWrap = Utilities.makeEl('div', {
    class: 'bv-ruleset-panel-search-wrap bv-input bv-text',
    children: [groupSearchEl, groupSearchClearBtn],
  })
  let groupToolbar = Utilities.makeEl('div', {
    class: 'bv-ruleset-panel-group-toolbar',
    children: [groupSearchWrap, addGroupBtn],
  })

  let gearWrap = Utilities.makeEl('div', {class: 'bv-ruleset-panel-settings-wrap'})
  let gearBtn = Utilities.makeEl('button', {
    class: 'bv-ruleset-panel-settings bv-button',
    text: '⚙',
    attrs: {type: 'button', title: 'Ruleset settings'},
  })
  gearWrap.append(gearBtn)

  let titleRow = Utilities.makeEl('div', {class: 'bv-ruleset-panel-title-row'})
  titleRow.append(Utilities.makeEl('span', {
    class: 'bv-ruleset-panel-title bv-label',
    text: settings.title,
  }))
  let rulesetHelpBtn = BrazenViewLayer.createFieldHelpButton(settings.title, {
    help: settings.help,
  })
  if (rulesetHelpBtn) {
    titleRow.append(rulesetHelpBtn)
  }
  titleRow.append(gearWrap)

  let headEl = Utilities.makeEl('div', {
    class: 'bv-ruleset-panel-head',
    children: [titleRow],
  })

  let scrollEl = Utilities.makeEl('div', {
    class: 'bv-ruleset-panel-scroll',
    children: [emptyEl, listEl, sentinelEl, loadingEl],
  })
  let columnsEl = Utilities.makeEl('div', {
    class: 'bv-ruleset-panel-columns',
    children: [toolbar, groupToolbar, scrollEl],
  })
  let element = Utilities.makeEl('div', {
    class: 'bv-ruleset-panel',
    attrs: {'data-field-key': settings.fieldKey},
    children: [headEl, columnsEl],
  })

  /** @type {{element: Element, refresh: Function}[]} */
  let pageMatchListeners = []

  let syncColumnInset = () => {
    columnsEl.style.setProperty('--bv-ruleset-scroll-inset', `${scrollEl.offsetWidth - scrollEl.clientWidth}px`)
  }

  /** @type {ResizeObserver|null} */
  let resizeObserver = null
  if (typeof ResizeObserver !== 'undefined') {
    resizeObserver = new ResizeObserver(() => syncColumnInset())
    resizeObserver.observe(scrollEl)
  }

  /** @type {IntersectionObserver|null} */
  let scrollObserver = null
  if (typeof IntersectionObserver !== 'undefined') {
    scrollObserver = new IntersectionObserver((entries) => {
      if (entries.some((entry) => entry.isIntersecting)) {
        void widget.loadMore()
      }
    }, {root: scrollEl, rootMargin: '120px'})
    scrollObserver.observe(sentinelEl)
  }

  let emitConfigChange = (partial) => {
    Object.assign(settings.config, partial)
    settings.onConfigChange({...settings.config, ...partial})
    syncGroupingToolbar()
    if (partial.groupingEnabled !== undefined || partial.autoSort !== undefined || partial.sortMode !== undefined) {
      void widget.reload()
      return
    }
    if (partial.hideTagTypes !== undefined) {
      widget.render(loadedEntries)
    }
  }

  let syncGroupingToolbar = () => {
    let show = !!settings.config.groupingEnabled
    Utilities.setHidden(groupToolbar, !show)
  }

  let buildRulesetConfigContent = (container) => {
    container.replaceChildren()
    let controls = settings.configControls ?? {}
    let optionHelp = settings.configOptionHelp ?? {}
    let defaultOptionHelp = {
      autoSort: 'Insert new and edited rules using the current sort order.',
      sortMode: 'Controls list order and how rows are sorted when auto-sort runs.',
      groupingEnabled: 'Show group toolbars and organize rules under named groups.',
      hideTagTypes: 'Hide artist, copyright, and character prefixes in the list display only.',
    }
    /**
     * @param {string} key
     * @param {string} fallback
     * @return {string}
     */
    let resolveOptionHelp = (key, fallback = '') => optionHelp[key] ?? defaultOptionHelp[key] ?? fallback
    /**
     * @param {string} label
     * @param {string} helpKey
     * @param {Function} buildRow
     */
    let appendOption = (label, helpKey, buildRow) => {
      let block = Utilities.makeEl('div', {class: 'bv-ruleset-panel-settings-option'})
      block.append(buildRow(label))
      let help = resolveOptionHelp(helpKey)
      if (help) {
        block.append(Utilities.makeEl('p', {class: 'bv-ruleset-panel-settings-help', text: help}))
      }
      container.append(block)
    }
    if (controls.autoSort) {
      appendOption('Auto-sort', 'autoSort', (label) => {
        let row = Utilities.makeEl('label', {class: 'bv-ruleset-panel-settings-row'})
        let autoSortInput = Utilities.makeEl('input', {
          class: 'bv-input bv-checkbox-radio',
          attrs: {type: 'checkbox'},
        })
        autoSortInput.checked = !!settings.config.autoSort
        autoSortInput.addEventListener('change', () => emitConfigChange({autoSort: autoSortInput.checked}))
        row.append(Utilities.makeEl('span', {text: label}), autoSortInput)
        return row
      })
    }
    if (controls.sortMode && settings.sortModes.length) {
      appendOption('Sort mode', 'sortMode', (label) => {
        let row = Utilities.makeEl('label', {class: 'bv-ruleset-panel-settings-row'})
        let menuSort = this.createFormGroupDropdown('', settings.sortModes.map((mode) => [mode.value, mode.label]))
        menuSort.classList.add('bv-ruleset-panel-settings-sort')
        menuSort.value = settings.config.sortMode ?? settings.sortModes[0]?.value ?? ''
        menuSort.addEventListener('change', () => {
          emitConfigChange({sortMode: menuSort.value})
        })
        row.append(Utilities.makeEl('span', {text: label}), menuSort)
        return row
      })
    }
    if (controls.groupingEnabled && controls.groupingEnabled !== 'hidden') {
      appendOption('Group rules', 'groupingEnabled', (label) => {
        let row = Utilities.makeEl('label', {class: 'bv-ruleset-panel-settings-row'})
        let groupInput = Utilities.makeEl('input', {
          class: 'bv-input bv-checkbox-radio',
          attrs: {type: 'checkbox'},
        })
        groupInput.checked = !!settings.config.groupingEnabled
        groupInput.addEventListener('change', () => emitConfigChange({groupingEnabled: groupInput.checked}))
        row.append(Utilities.makeEl('span', {text: label}), groupInput)
        return row
      })
    }
    if (controls.hideTagTypes) {
      appendOption('Hide tag types', 'hideTagTypes', (label) => {
        let row = Utilities.makeEl('label', {class: 'bv-ruleset-panel-settings-row'})
        let hideTypesInput = Utilities.makeEl('input', {
          class: 'bv-input bv-checkbox-radio',
          attrs: {type: 'checkbox'},
        })
        hideTypesInput.checked = !!settings.config.hideTagTypes
        hideTypesInput.addEventListener('change', () => emitConfigChange({hideTagTypes: hideTypesInput.checked}))
        row.append(Utilities.makeEl('span', {text: label}), hideTypesInput)
        return row
      })
    }
  }

  let resolveConfigDetailTitle = () => (settings.title || 'Ruleset') + ' settings'

  let openRulesetConfigPane = () => {
    let configTitle = resolveConfigDetailTitle()
    BrazenViewLayer._toggleSettingsDetailPane(configTitle, () => {
      let content = Utilities.makeEl('div', {class: 'bv-ruleset-panel-settings-pane'})
      buildRulesetConfigContent(content)
      BrazenViewLayer.openSettingsDetailPane({
        title: configTitle,
        content,
      })
    })
  }

  let buildDisplaySections = (entries) => {
    if (!settings.config.groupingEnabled) {
      return [{type: 'flat', label: null, entries}]
    }
    let hasStringSearch = !!filterQuery.trim()
    /** @type {Map<string, BrazenRulesetEntry[]>} */
    let grouped = new Map()
    /** @type {BrazenRulesetEntry[]} */
    let ungrouped = []
    for (let entry of entries) {
      if (entry.groupLabel) {
        if (!grouped.has(entry.groupLabel)) {
          grouped.set(entry.groupLabel, [])
        }
        grouped.get(entry.groupLabel).push(entry)
      } else {
        ungrouped.push(entry)
      }
    }
    /** @type {{type: string, label: string|null, entries: BrazenRulesetEntry[]}[]} */
    let sections = []
    for (let [label, groupEntries] of grouped) {
      sections.push({type: 'group', label, entries: groupEntries})
    }
    if (ungrouped.length) {
      if (hasStringSearch) {
        sections.push({type: 'ungrouped', label: 'Ungrouped', entries: ungrouped})
      } else {
        sections.push({type: 'flat', label: null, entries: ungrouped})
      }
    }
    return sections.length ? sections : [{type: 'flat', label: null, entries: []}]
  }

  let renderEntryContent = (entry) => {
    let rendered = settings.renderEntryContent ?
        settings.renderEntryContent(entry, {hideTagTypes: !!settings.config.hideTagTypes}) :
        (entry.rawLine ?? '')
    if (typeof rendered === 'string') {
      return Utilities.makeEl('span', {class: 'bv-ruleset-panel-content-text', text: rendered})
    }
    if (rendered instanceof HTMLElement) {
      rendered.classList.add('bv-ruleset-panel-content')
      return rendered
    }
    return Utilities.makeEl('span', {class: 'bv-ruleset-panel-content-text', text: String(rendered ?? '')})
  }

  let contentHasInteractiveControl = (content) => {
    if (!(content instanceof HTMLElement)) {
      return false
    }
    if (content.dataset.rulesetInteractive === 'true') {
      return true
    }
    return !!content.querySelector('button, a')
  }

  let createEntryRow = (entry) => {
    let main = Utilities.makeEl('div', {class: 'bv-ruleset-panel-main'})
    let content = renderEntryContent(entry)
    let commentText = String(entry.comment ?? '').trim()
    if (settings.crudOptions.edit !== false && !contentHasInteractiveControl(content)) {
      main.append(BrazenViewLayer.wrapRulesetRuleButton(content, {
        title: commentText || undefined,
        onClick: (event) => {
          event.stopPropagation()
          settings.onEdit(entry)
        },
      }))
    } else {
      main.append(content)
    }
    for (let action of settings.getRowActions(entry) ?? []) {
      action.classList.add('bv-ruleset-panel-action')
      main.append(action)
    }

    let rowChildren = [main]
    if (settings.crudOptions.delete !== false) {
      rowChildren.push(Utilities.makeEl('button', {
        class: 'bv-ruleset-panel-remove bv-button',
        text: '×',
        attrs: {type: 'button', title: 'Remove rule'},
        on: {
          click: (event) => {
            event.stopPropagation()
            settings.onRemove(entry.entryId)
          },
        },
      }))
    }
    return Utilities.makeEl('div', {
      class: 'bv-ruleset-panel-row',
      attrs: {'data-entry-id': String(entry.entryId)},
      children: rowChildren,
    })
  }

  let paintList = (skipBeforeRender = false) => {
    listEl.replaceChildren()
    let queryActive = !!filterQuery.trim() || !!groupFilterQuery.trim()
    if (!loadedEntries.length) {
      if (loading) {
        emptyEl.textContent = 'Loading…'
        emptyEl.classList.add('bv-ruleset-panel-loading')
      } else {
        emptyEl.textContent = queryActive ? settings.noMatchMessage : settings.emptyMessage
        emptyEl.classList.remove('bv-ruleset-panel-loading')
      }
      Utilities.setHidden(emptyEl, false)
      Utilities.setHidden(loadingEl, true)
      Utilities.setHidden(sentinelEl, true)
      syncColumnInset()
      return
    }

    Utilities.setHidden(emptyEl, true)
    emptyEl.classList.remove('bv-ruleset-panel-loading')

    if (!skipBeforeRender) {
      let generation = ++paintGeneration
      let before
      try {
        before = settings.onBeforeRender(loadedEntries)
      } catch (_) { /* ignore */ }
      if (before && typeof before.then === 'function') {
        // Defer row paint until warm settles so tokens colorize on the first visible frame.
        Utilities.setHidden(loadingEl, false)
        Utilities.setHidden(sentinelEl, true)
        syncColumnInset()
        void before.then(() => {
          if (generation === paintGeneration) {
            paintList(true)
          }
        }).catch(() => {
          if (generation === paintGeneration) {
            paintList(true)
          }
        })
        return
      }
    }

    for (let section of buildDisplaySections(loadedEntries)) {
      if (section.type === 'group') {
        let headerActions = []
        if (settings.onRemoveGroup) {
          headerActions.push(Utilities.makeEl('button', {
            class: 'bv-ruleset-panel-remove-group bv-button',
            text: '×',
            attrs: {type: 'button', title: 'Remove group'},
            on: {
              click: (event) => {
                event.stopPropagation()
                settings.onRemoveGroup(section.label)
              },
            },
          }))
        }
        listEl.append(Utilities.makeEl('div', {
          class: 'bv-ruleset-panel-group-header',
          children: [
            Utilities.makeEl('span', {class: 'bv-ruleset-panel-group-title', text: section.label ?? ''}),
            ...headerActions,
          ],
        }))
      } else if (section.type === 'ungrouped') {
        listEl.append(Utilities.makeEl('div', {
          class: 'bv-ruleset-panel-group-header bv-ruleset-panel-ungrouped-header',
          children: [Utilities.makeEl('span', {class: 'bv-ruleset-panel-group-title', text: section.label ?? 'Ungrouped'})],
        }))
      }
      for (let entry of section.entries) {
        listEl.append(createEntryRow(entry))
      }
    }

    Utilities.setHidden(loadingEl, !(loading && loadedEntries.length > 0))
    Utilities.setHidden(sentinelEl, !nextCursor)
    syncColumnInset()
    widget.checkPageMatch?.()
  }

  let fetchPage = async (cursor, append) => {
    let generation = ++loadGeneration
    loading = true
    paintList()
    try {
      let page = await settings.loadPage({
        fieldKey: settings.fieldKey,
        query: filterQuery.trim(),
        groupQuery: groupFilterQuery.trim(),
        cursor,
      })
      if (generation !== loadGeneration) {
        return
      }
      let entries = Array.isArray(page?.entries) ? page.entries : []
      loadedEntries = append ? loadedEntries.concat(entries) : entries
      nextCursor = page?.nextCursor ?? null
    } catch (_) {
      if (generation === loadGeneration && !append) {
        loadedEntries = []
        nextCursor = null
      }
    } finally {
      if (generation === loadGeneration) {
        loading = false
        paintList()
      }
    }
  }

  let syncSearchClearVisibility = () => {
    Utilities.setHidden(searchClearBtn, !String(searchEl.value ?? '').length)
  }
  let syncGroupSearchClearVisibility = () => {
    Utilities.setHidden(groupSearchClearBtn, !String(groupSearchEl.value ?? '').length)
  }

  let widget = {
    element: element,
    getFilterQuery: () => filterQuery,
    getGroupFilterQuery: () => groupFilterQuery,
    updateConfig: (partial) => {
      Object.assign(settings.config, partial)
      syncGroupingToolbar()
      widget.render(loadedEntries)
    },
    reload: async () => {
      nextCursor = null
      loadedEntries = []
      await fetchPage(null, false)
    },
    loadMore: async () => {
      if (loading || nextCursor === null) {
        return
      }
      await fetchPage(nextCursor, true)
    },
    render: (entries, meta) => {
      loadedEntries = Array.isArray(entries) ? entries : []
      if (meta && 'nextCursor' in meta) {
        nextCursor = meta.nextCursor ?? null
      }
      paintList()
    },
    isCurrentPagePresent: (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 loadedEntries.some((entry) => {
        let payload = entry?.payload ?? entry
        return normalize(payload?.url ?? '') === currentUrl
      })
    },
    checkPageMatch: () => {
      let match = settings.pageMatch
      if (!match) {
        return
      }
      if (match.isActive && !match.isActive()) {
        match.onMatchChange?.(false)
        return
      }
      match.onMatchChange?.(widget.isCurrentPagePresent())
    },
    updatePanelCopy: (copy = {}) => {
      if (copy.addEntryCaption) {
        settings.addEntryCaption = copy.addEntryCaption
        addRuleBtn.title = copy.addEntryCaption
      }
      if (copy.createDetailTitle) {
        settings.createDetailTitle = copy.createDetailTitle
      }
      if (copy.emptyMessage) {
        settings.emptyMessage = copy.emptyMessage
      }
      if (copy.noMatchMessage) {
        settings.noMatchMessage = copy.noMatchMessage
      }
      if (copy.searchPlaceholder) {
        settings.searchPlaceholder = copy.searchPlaceholder
        searchEl.placeholder = copy.searchPlaceholder
      }
      if (copy.sortHelpText) {
        settings.sortHelpText = copy.sortHelpText
        if (sortBtn) {
          sortBtn.title = copy.sortHelpText
        }
      }
      paintList()
    },
    dispose: () => {
      loadGeneration++
      resizeObserver?.disconnect()
      resizeObserver = null
      scrollObserver?.disconnect()
      scrollObserver = null
      for (let entry of pageMatchListeners) {
        entry.element.removeEventListener('input', entry.refresh)
      }
      pageMatchListeners = []
      BrazenViewLayer._hideRulesetCommentPopover()
    },
    _onDocumentClick: null,
  }
  widget.isCurrentPageBookmarked = (url) => widget.isCurrentPagePresent(url)

  searchEl.addEventListener('input', () => {
    filterQuery = String(searchEl.value ?? '')
    syncSearchClearVisibility()
    void widget.reload()
  })
  searchClearBtn.addEventListener('click', () => {
    searchEl.value = ''
    filterQuery = ''
    syncSearchClearVisibility()
    void widget.reload()
  })
  groupSearchEl.addEventListener('input', () => {
    groupFilterQuery = String(groupSearchEl.value ?? '')
    syncGroupSearchClearVisibility()
    void widget.reload()
  })
  groupSearchClearBtn.addEventListener('click', () => {
    groupSearchEl.value = ''
    groupFilterQuery = ''
    syncGroupSearchClearVisibility()
    void widget.reload()
  })
  gearBtn.addEventListener('click', (event) => {
    event.preventDefault()
    event.stopPropagation()
    openRulesetConfigPane()
  })

  Utilities.setHidden(addRuleBtn, settings.crudOptions.create === false)
  Utilities.setHidden(addGroupBtn, settings.crudOptions.create === false)
  syncGroupingToolbar()
  syncColumnInset()
  syncSearchClearVisibility()
  syncGroupSearchClearVisibility()
  requestAnimationFrame(() => requestAnimationFrame(syncColumnInset))
  if (settings.pageMatch) {
    let refresh = () => widget.checkPageMatch()
    for (let selector of settings.pageMatch.watchSelectors ?? []) {
      for (let matchElement of document.querySelectorAll(selector)) {
        matchElement.addEventListener('input', refresh)
        pageMatchListeners.push({element: matchElement, refresh})
      }
    }
    refresh()
  }

  return widget
}

/**
 * @param {HTMLElement} anchor
 * @param {string} text
 * @private
 */
static _toggleRulesetCommentPopover(anchor, text)
{
  let existing = BrazenViewLayer._rulesetCommentPopover
  if (existing?.anchor === anchor) {
    BrazenViewLayer._hideRulesetCommentPopover()
    return
  }
  BrazenViewLayer._hideRulesetCommentPopover()
  let popover = Utilities.makeEl('div', {class: 'bv-ruleset-panel-comment-popover', text})
  document.body.append(popover)
  let rect = anchor.getBoundingClientRect()
  popover.style.top = `${rect.bottom + window.scrollY + 4}px`
  popover.style.left = `${Math.max(8, rect.left + window.scrollX - 8)}px`
  let onDismiss = (event) => {
    if (popover.contains(event.target) || anchor.contains(event.target)) {
      return
    }
    BrazenViewLayer._hideRulesetCommentPopover()
  }
  BrazenViewLayer._rulesetCommentPopover = {anchor, popover, onDismiss}
  setTimeout(() => document.addEventListener('click', onDismiss), 0)
}

/** @type {{anchor: HTMLElement, popover: HTMLElement, onDismiss: Function}|null} */
static _rulesetCommentPopover = null

static _hideRulesetCommentPopover()
{
  let existing = BrazenViewLayer._rulesetCommentPopover
  if (!existing) {
    return
  }
  document.removeEventListener('click', existing.onDismiss)
  existing.popover.remove()
  BrazenViewLayer._rulesetCommentPopover = null
}

createResizer()
{
  let handle = Utilities.makeEl('div', {
    attrs: {id: 'bv-resizer', title: 'Resize'},
    children: Utilities.makeEl('span', {attrs: {id: 'bv-resizer-icon'}, text: '↔'}),
  })
  handle.addEventListener('mousedown', (event) => {
    event.preventDefault()
    this._resizing = true
    let resizeHorizontal = (moveEvent) => {
      if (!this._section) {
        return
      }
      let rect = this._section.getBoundingClientRect()
      let resizeFromLeft = this._section.classList.contains('bv-dock-panel-right')
      let nextWidth = resizeFromLeft ?
          Math.max(150, moveEvent.clientX - rect.left) :
          Math.max(150, rect.right - moveEvent.clientX)
      if (this._section.classList.contains('bv-settings-detail-open')) {
        let columnWidth = Math.max(150, Math.round(nextWidth / 2))
        BrazenViewLayer.applySettingsColumnWidth(this._section, columnWidth)
        return
      }
      BrazenViewLayer.applySettingsColumnWidth(this._section, nextWidth)
    }
    let stopResize = () => {
      this._resizing = false
      removeEventListener('mousemove', resizeHorizontal)
      removeEventListener('mouseup', stopResize)
    }
    addEventListener('mousemove', resizeHorizontal)
    addEventListener('mouseup', stopResize)
  })
  return handle
}

createModal(title, bodyNodes)
{
  let body = Utilities.makeEl('div', {class: 'bv-modal-body'})
  let nodes = Array.isArray(bodyNodes) ? bodyNodes : [bodyNodes]
  Utilities.appendChildren(body, nodes)

  let dialog = Utilities.makeEl('div', {
    class: 'bv-modal-dialog',
    children: [
      Utilities.makeEl('div', {
        class: 'bv-modal-header',
        children: [
          Utilities.makeEl('h2', {class: 'bv-modal-title', text: title}),
          Utilities.makeEl('button', {
            class: 'bv-modal-close bv-button',
            text: '×',
            attrs: {type: 'button', 'aria-label': 'Close'},
          }),
        ],
      }),
      body,
    ],
  })

  return Utilities.makeEl('div', {class: 'bv-modal-overlay', children: dialog})
}

/**
 * @param {HTMLElement} modal
 */
showModal(modal)
{
  let hide = () => this.hideModal(modal)
  modal.addEventListener('click', (event) => {
    if (event.target === modal) {
      hide()
    }
  })
  modal.querySelector('.bv-modal-close')?.addEventListener('click', hide)
  modal._bvModalKeydown = (event) => {
    if (event.key === 'Escape') {
      hide()
    }
  }
  document.addEventListener('keydown', modal._bvModalKeydown)
  BrazenViewLayer.appendToBody(modal)
}

/**
 * @param {HTMLElement} modal
 */
hideModal(modal)
{
  if (modal?._bvModalKeydown) {
    document.removeEventListener('keydown', modal._bvModalKeydown)
    delete modal._bvModalKeydown
  }
  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 overlay = Utilities.makeEl('div', {class: 'bv-modal-overlay'})
    let finish = (value) => {
      if (settled) {
        return
      }
      settled = true
      if (overlay._bvModalConfirmKeydown) {
        document.removeEventListener('keydown', overlay._bvModalConfirmKeydown)
        delete overlay._bvModalConfirmKeydown
      }
      overlay.remove()
      resolve(value)
    }
    let closeButton = Utilities.makeEl('button', {
      class: 'bv-modal-close bv-button',
      text: '×',
      attrs: {type: 'button', 'aria-label': 'Close'},
      on: {click: () => finish(false)},
    })
    let actionButtons = [
      Utilities.makeEl('button', {
        class: 'bv-button',
        text: options.confirmLabel ?? 'OK',
        attrs: {type: 'button'},
        on: {click: () => finish(true)},
      }),
    ]
    if (options.showCancel !== false) {
      actionButtons.push(
          Utilities.makeEl('button', {
            class: 'bv-button',
            text: options.cancelLabel ?? 'Cancel',
            attrs: {type: 'button'},
            on: {click: () => finish(false)},
          }),
      )
    }
    overlay.append(Utilities.makeEl('div', {
      class: 'bv-modal-dialog',
      children: [
        Utilities.makeEl('div', {
          class: 'bv-modal-header',
          children: [
            Utilities.makeEl('h2', {class: 'bv-modal-title', text: options.title ?? 'Confirm'}),
            closeButton,
          ],
        }),
        Utilities.makeEl('div', {
          class: 'bv-modal-body',
          children: [
            Utilities.makeEl('p', {text: options.message ?? ''}),
            Utilities.makeEl('div', {class: 'bv-actions bv-flex-column', children: actionButtons}),
          ],
        }),
      ],
    }))
    overlay.addEventListener('click', (event) => {
      if (event.target === overlay) {
        finish(false)
      }
    })
    overlay._bvModalConfirmKeydown = (event) => {
      if (event.key === 'Escape') {
        finish(false)
      }
    }
    document.addEventListener('keydown', overlay._bvModalConfirmKeydown)
    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,
    onlyTagNames: null,
    rowsPerFrame: null,
    ...options,
  }

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

  let onlyNames = null
  if (Array.isArray(settings.onlyTagNames) && settings.onlyTagNames.length) {
    onlyNames = new Set(settings.onlyTagNames.map((name) => String(name ?? '').trim()).filter(Boolean))
  }

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

  let rowsToProcess = []
  for (let row of sidebar.querySelectorAll(settings.rowSelector)) {
    let tag = settings.extractTag(row)
    if (onlyNames && !onlyNames.has(String(tag?.name ?? '').trim())) {
      continue
    }
    rowsToProcess.push(row)
  }

  let enhanceRow = (row) => {
    let tag = settings.extractTag(row)
    let count = row.querySelector(settings.countSelector)
    if (!count) {
      return
    }

    let slot = count.closest('.bv-tag-actions-slot')
    let actions
    if (!slot) {
      slot = Utilities.makeEl('span', {class: 'bv-tag-actions-slot'})
      count.before(slot)
      slot.append(count)
      actions = Utilities.makeEl('span', {class: 'bv-tag-actions', hidden: true})
      slot.append(actions)

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

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

    if (!actions) {
      return
    }

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

  let chunkSize = settings.rowsPerFrame
  if (chunkSize && chunkSize > 0 && rowsToProcess.length > chunkSize) {
    let index = 0
    let step = () => {
      let end = Math.min(index + chunkSize, rowsToProcess.length)
      for (; index < end; index++) {
        enhanceRow(rowsToProcess[index])
      }
      if (index < rowsToProcess.length) {
        requestAnimationFrame(step)
      }
    }
    requestAnimationFrame(step)
    return
  }

  for (let row of rowsToProcess) {
    enhanceRow(row)
  }
}

/**
 * @return {HTMLElement}
 */
createSeparator()
{
  return document.createElement('hr')
}

/**
 * @return {HTMLElement}
 */
createSettingsSection()
{
  let section = this.createContainer()
  section.id = 'bv-ui'
  section.classList.add('bv-dock-slide-panel', 'bv-bg-colour', 'bv-dock-panel-hidden', 'bv-settings-shell')

  let split = Utilities.makeEl('div', {class: 'bv-settings-split'})
  let primary = Utilities.makeEl('div', {
    class: 'bv-settings-primary',
    attrs: {id: 'bv-ui-primary'},
  })
  let secondary = Utilities.makeEl('div', {
    class: 'bv-settings-secondary bv-settings-secondary-hidden',
    attrs: {id: 'bv-ui-secondary'},
  })
  split.append(primary, secondary)
  section.append(this.createResizer(), split)
  section._bvSettingsPrimary = primary
  section._bvSettingsSecondary = secondary
  BrazenViewLayer._initSettingsDetailPane(secondary)
  return section
}

/**
 * @param {HTMLElement} settingsSection
 * @param {'left'|'right'|'bottom'} orientation
 */
static syncSettingsColumnOrder(settingsSection, orientation)
{
  if (!settingsSection) {
    return
  }
  let split = settingsSection.querySelector('.bv-settings-split')
  if (!split) {
    return
  }
  let primary = settingsSection.querySelector('#bv-ui-primary')
  let secondary = settingsSection.querySelector('#bv-ui-secondary')
  if (!primary || !secondary) {
    return
  }
  split.replaceChildren(primary, secondary)
  if (orientation === 'right') {
    split.replaceChildren(secondary, primary)
    settingsSection.classList.add('bv-settings-secondary-left')
    settingsSection.classList.remove('bv-settings-secondary-right')
  } else {
    split.replaceChildren(primary, secondary)
    settingsSection.classList.add('bv-settings-secondary-right')
    settingsSection.classList.remove('bv-settings-secondary-left')
  }
}

/**
 * @param {HTMLElement} secondaryEl
 * @private
 */
static _initSettingsDetailPane(secondaryEl)
{
  if (!secondaryEl || BrazenViewLayer._settingsDetailPane) {
    return
  }
  let head = Utilities.makeEl('div', {class: 'bv-settings-detail-head', children: [
    Utilities.makeEl('h3', {class: 'bv-settings-detail-title', attrs: {id: 'bv-settings-detail-title'}}),
    Utilities.makeEl('button', {
      class: 'bv-settings-detail-close bv-button',
      text: '×',
      attrs: {type: 'button', title: 'Close'},
      on: {click: () => BrazenViewLayer.closeSettingsDetailPane()},
    }),
  ]})
  let body = Utilities.makeEl('div', {class: 'bv-settings-detail-body', attrs: {id: 'bv-settings-detail-body'}})
  secondaryEl.append(head, body)
  BrazenViewLayer._settingsDetailPane = {
    root: secondaryEl,
    head: head,
    titleEl: head.querySelector('#bv-settings-detail-title'),
    bodyEl: body,
    open: false,
    onClose: null,
  }
}

/** @type {{root: HTMLElement, head: HTMLElement, titleEl: HTMLElement, bodyEl: HTMLElement, open: boolean, onClose: Function|null}|null} */
static _settingsDetailPane = null

/** Default settings column width (px) when the script does not set one. */
static DEFAULT_SETTINGS_COLUMN_WIDTH = 480

/**
 * @param {HTMLElement} shell
 * @param {number} columnWidthPx
 */
static applySettingsColumnWidth(shell, columnWidthPx)
{
  if (!shell) {
    return
  }
  let column = Math.max(150, Math.round(columnWidthPx))
  shell.style.setProperty('--bv-settings-column-width', column + 'px')
  if (shell.classList.contains('bv-settings-detail-open')) {
    shell.style.width = ''
    shell.style.minWidth = ''
    return
  }
  shell.style.width = column + 'px'
  shell.style.minWidth = ''
}

/**
 * @param {HTMLElement} shell
 * @return {number}
 * @private
 */
static _readSettingsColumnWidth(shell)
{
  let fromVar = parseFloat(shell.style.getPropertyValue('--bv-settings-column-width'))
  if (Number.isFinite(fromVar) && fromVar > 0) {
    return Math.round(fromVar)
  }
  let inlineWidth = parseFloat(shell.style.width)
  if (Number.isFinite(inlineWidth) && inlineWidth > 0) {
    if (shell.classList.contains('bv-settings-detail-open')) {
      return Math.max(150, Math.round(inlineWidth / 2))
    }
    return Math.round(inlineWidth)
  }
  let rectWidth = Math.round(shell.getBoundingClientRect().width)
  if (shell.classList.contains('bv-settings-detail-open')) {
    return Math.max(150, Math.round(rectWidth / 2)) || BrazenViewLayer.DEFAULT_SETTINGS_COLUMN_WIDTH
  }
  return rectWidth || BrazenViewLayer.DEFAULT_SETTINGS_COLUMN_WIDTH
}

/**
 * Expand `#bv-ui` to two equal columns when the detail pane opens.
 * @param {boolean} open
 * @private
 */
static _syncSettingsDetailShellLayout(open)
{
  let shell = document.getElementById('bv-ui')
  if (!shell) {
    return
  }
  let column = BrazenViewLayer._readSettingsColumnWidth(shell)
  if (open) {
    shell.classList.add('bv-settings-detail-open')
    BrazenViewLayer.applySettingsColumnWidth(shell, column)
    return
  }
  shell.classList.remove('bv-settings-detail-open')
  BrazenViewLayer.applySettingsColumnWidth(shell, column)
}

/**
 * Wrap a single ruleset entry form in a bordered detail card.
 * @param {HTMLFormElement|HTMLElement} form
 * @return {HTMLElement}
 */
static wrapRulesetDetailEntry(form)
{
  return Utilities.makeEl('div', {
    class: 'bv-ruleset-detail-entry',
    children: [form],
  })
}

/**
 * Scrollable multi-form shell for ruleset create detail panes.
 * @param {{createEntryForm?: function(): HTMLFormElement|HTMLElement, addEntryCaption?: string, submitCaption?: string, initialEntryCount?: number, onSubmit?: function(HTMLFormElement[]): void}} [options]
 * @return {HTMLElement}
 */
static createRulesetDetailMultiForm(options = {})
{
  let settings = {
    createEntryForm: () => document.createElement('form'),
    addEntryCaption: 'Add rule',
    submitCaption: 'Add Rules',
    initialEntryCount: 1,
    onSubmit: () => {},
    ...options,
  }
  let scrollEl = Utilities.makeEl('div', {class: 'bv-ruleset-detail-scroll'})
  let addRow = Utilities.makeEl('div', {class: 'bv-ruleset-detail-add-row'})
  scrollEl.append(addRow)

  let syncDismissControls = () => {
    let entries = [...scrollEl.querySelectorAll('.bv-ruleset-detail-entry')]
    let showDismiss = entries.length > 1
    for (let entry of entries) {
      let form = entry.querySelector('form.bv-ruleset-form, .bv-ruleset-form')
      let anchor = form?.querySelector('.bv-ruleset-form-field') ?? form
      let dismissBtn = entry.querySelector('.bv-ruleset-detail-entry-dismiss')
      if (showDismiss) {
        if (!dismissBtn && anchor) {
          anchor.append(Utilities.makeEl('button', {
            class: 'bv-ruleset-detail-entry-dismiss bv-button',
            text: '×',
            attrs: {type: 'button', title: 'Remove this entry'},
            on: {
              click: () => {
                entry.remove()
                syncDismissControls()
                BrazenViewLayer._notifySettingsDetailPaneChange()
              },
            },
          }))
        }
      } else if (dismissBtn) {
        dismissBtn.remove()
      }
    }
  }

  let appendEntry = (focusFirst = false) => {
    let form = settings.createEntryForm()
    scrollEl.insertBefore(BrazenViewLayer.wrapRulesetDetailEntry(form), addRow)
    syncDismissControls()
    if (focusFirst) {
      form.querySelector('input, textarea, select, .bv-tag-input')?.focus?.()
    }
    BrazenViewLayer._notifySettingsDetailPaneChange()
    return form
  }

  let initialCount = Math.max(1, settings.initialEntryCount ?? 1)
  for (let index = 0; index < initialCount; index++) {
    appendEntry(index === 0)
  }

  addRow.append(Utilities.makeEl('button', {
    class: 'bv-button bv-ruleset-detail-add-entry',
    text: settings.addEntryCaption,
    attrs: {type: 'button', title: settings.addEntryCaption},
    on: {click: () => appendEntry(true)},
  }))

  let submitBtn = Utilities.makeEl('button', {
    class: 'bv-button bv-ruleset-detail-submit',
    text: settings.submitCaption,
    attrs: {type: 'button', title: settings.submitCaption},
    on: {
      click: () => {
        let forms = [...scrollEl.querySelectorAll('.bv-ruleset-detail-entry > form, .bv-ruleset-detail-entry > .bv-ruleset-form')]
        settings.onSubmit(forms)
      },
    },
  })
  let footer = Utilities.makeEl('div', {
    class: 'bv-ruleset-detail-footer',
    children: [submitBtn],
  })
  return Utilities.makeEl('div', {
    class: 'bv-ruleset-detail-multi',
    children: [scrollEl, footer],
  })
}

/**
 * @param {{title?: string, content: HTMLElement|HTMLElement[], onClose?: Function}} options
 */
static openSettingsDetailPane(options = {})
{
  let pane = BrazenViewLayer._settingsDetailPane
  if (!pane) {
    return
  }
  let wasOpen = pane.open
  pane.titleEl.textContent = options.title ?? ''
  pane.bodyEl.replaceChildren()
  Utilities.appendChildren(pane.bodyEl, options.content)
  pane.onClose = options.onClose ?? null
  pane.open = true
  if (!wasOpen) {
    BrazenViewLayer._syncSettingsDetailShellLayout(true)
  }
  pane.root.classList.remove('bv-settings-secondary-hidden')
  BrazenViewLayer._notifySettingsDetailPaneChange()
}

static closeSettingsDetailPane()
{
  let pane = BrazenViewLayer._settingsDetailPane
  if (!pane || !pane.open) {
    return
  }
  let onClose = pane.onClose
  pane.onClose = null
  pane.open = false
  pane.bodyEl.replaceChildren()
  pane.root.classList.add('bv-settings-secondary-hidden')
  BrazenViewLayer._syncSettingsDetailShellLayout(false)
  if (typeof onClose === 'function') {
    onClose()
  }
  BrazenViewLayer._notifySettingsDetailPaneChange()
}

static isSettingsDetailPaneOpen()
{
  return !!BrazenViewLayer._settingsDetailPane?.open
}

/** @type {Function[]} */
static _settingsDetailPaneListeners = []

/**
 * @param {Function} listener
 */
static onSettingsDetailPaneChange(listener)
{
  if (typeof listener === 'function') {
    BrazenViewLayer._settingsDetailPaneListeners.push(listener)
  }
}

static _notifySettingsDetailPaneChange()
{
  let activeTagInputs = [...(BrazenViewLayer._settingsDetailPane?.bodyEl?.querySelectorAll('.bv-tag-input') ?? [])]
  for (let listener of BrazenViewLayer._settingsDetailPaneListeners) {
    listener(BrazenViewLayer.isSettingsDetailPaneOpen(), {activeTagInputs})
  }
}

/**
 * Wrap ruleset row content in a full-width clickable rule button.
 * @param {HTMLElement} contentEl
 * @param {{title?: string, onClick?: function(Event): void, className?: string}} [options]
 * @return {HTMLButtonElement}
 */
static wrapRulesetRuleButton(contentEl, options = {})
{
  let settings = {
    title: undefined,
    onClick: null,
    className: 'bv-ruleset-panel-rule-btn',
    ...options,
  }
  let btn = Utilities.makeEl('button', {
    class: `${settings.className} bv-button`,
    attrs: {type: 'button'},
  })
  if (settings.title) {
    btn.title = settings.title
  }
  if (typeof settings.onClick === 'function') {
    btn.addEventListener('click', settings.onClick)
  }
  btn.append(contentEl)
  return btn
}

/**
 * @param {string} name
 * @param {{typeEntryId?: number|null, color?: string|null, showType?: boolean}} [options]
 * @return {HTMLElement}
 */
static createRulesetTagToken(name, options = {})
{
  let core = String(name ?? '')
  // Color is always applied from the resolved type; `showType` only prepends the type name prefix.
  if (options.showType && options.typeEntryId != null) {
    let prefix = BrazenViewLayer._rulesetTagTypePrefixByEntryId?.get(options.typeEntryId)
    if (prefix) {
      core = `${prefix}:${core}`
    }
  }
  // A single colored token covers the whole thing: prefix operator + (type:)tag + suffix operator/value.
  let lead = options.leadOperators ? String(options.leadOperators) : ''
  let suffix = options.suffixOperators ? String(options.suffixOperators) : ''
  let span = Utilities.makeEl('span', {
    class: 'bv-ruleset-tag-token',
    text: `${lead}${core}${suffix}`,
  })
  if (options.color) {
    span.style.color = options.color
  }
  if (options.typeEntryId != null) {
    span.dataset.typeEntryId = String(options.typeEntryId)
  }
  return span
}

/**
 * @param {string[]} names
 * @param {{resolveTag?: function(string, number): {typeEntryId?: number|null, color?: string|null}|null}} [options]
 * @return {HTMLElement}
 */
static createRulesetTagTokens(names, options = {})
{
  let wrap = Utilities.makeEl('span', {class: 'bv-ruleset-tag-tokens'})
  let resolve = options.resolveTag ?? (() => null)
  let parts = Array.isArray(names) ? names : []
  for (let index = 0; index < parts.length; index++) {
    if (index > 0) {
      wrap.append(document.createTextNode(' & '))
    }
    let meta = resolve(parts[index], index) ?? {}
    wrap.append(BrazenViewLayer.createRulesetTagToken(parts[index], meta))
  }
  return wrap
}

/**
 * Default parenthesis-aware tokenize for ruleset tag-query rows.
 * @param {string} query
 * @return {string[]}
 * @private
 */
static _defaultRulesetQueryTokenize(query)
{
  if (typeof BrazenTagQueryEngine !== 'undefined') {
    return new BrazenTagQueryEngine().tokenize(query)
  }
  return String(query ?? '').trim().split(/\s+/).filter(Boolean)
}

/**
 * Split a tag token into its base name and opt-in operators so the base can be color-resolved while
 * the operators (and their values) are preserved for display. Operator support is opt-in per
 * consumer/template. The Gelbooru scheme handled here:
 *   - `prefixOperators` (leading, no value) control how the tag is considered — e.g. `-tag` (exclude),
 *     `*tag` (leading wildcard).
 *   - `suffixSeparators` (`key:value`, split at the FIRST known query-syntax separator) introduce
 *     query-syntax operators — e.g. `rating:safe`, `sort:id:asc`, `md5:foo*`, `-rating:questionable`.
 *     Colons inside tag names (e.g. `(half-life:alyx)`) or unknown prefixes are left intact.
 *   - `suffixOperators` (trailing, no value, only when no metatag separator) also manipulate meaning —
 *     e.g. `night~` (fuzzy), `tag*` (trailing wildcard).
 * Standalone operator tokens (e.g. the `~` between OR-group members) and embedded wildcards
 * (e.g. `ta*1`) are left intact and simply resolve to no color.
 * @param {string} text
 * @param {{prefixOperators?: string[], suffixOperators?: string[], suffixSeparators?: string[]}|null} operators
 * @return {{lead: string, base: string, suffix: string}}
 * @private
 */
static _splitTagOperators(text, operators)
{
  let value = String(text ?? '')
  if (!operators) {
    return {lead: '', base: value, suffix: ''}
  }
  let prefixOperators = Array.isArray(operators.prefixOperators) ? operators.prefixOperators : []
  let suffixOperators = Array.isArray(operators.suffixOperators) ? operators.suffixOperators : []
  let suffixSeparators = Array.isArray(operators.suffixSeparators) ? operators.suffixSeparators : []
  let querySyntaxKeys = operators.querySyntaxKeys ??
      (typeof GELBOORU_QUERY_SYNTAX_KEYS !== 'undefined' ? GELBOORU_QUERY_SYNTAX_KEYS : null)
  let lead = ''
  let suffix = ''

  let peeling = true
  while (peeling && value.length > 1) {
    peeling = false
    for (let op of prefixOperators) {
      if (op && value.startsWith(op) && value.length > op.length) {
        lead += op
        value = value.slice(op.length)
        peeling = true
        break
      }
    }
  }

  // Query-syntax separator wins when the prefix matches a known key at parenthesis depth 0.
  let cutIndex = -1
  if (suffixSeparators.includes(':') && querySyntaxKeys && typeof BrazenTagQueryEngine !== 'undefined') {
    let index = BrazenTagQueryEngine.indexOfQuerySyntaxSeparator(value, querySyntaxKeys)
    if (index > 0) {
      cutIndex = index
    }
  } else {
    for (let separator of suffixSeparators) {
      if (!separator) {
        continue
      }
      let index = value.indexOf(separator)
      if (index > 0 && (cutIndex === -1 || index < cutIndex)) {
        cutIndex = index
      }
    }
  }
  if (cutIndex > 0) {
    return {lead, base: value.slice(0, cutIndex), suffix: value.slice(cutIndex)}
  }

  // No query-syntax separator: peel trailing standalone suffix operators (fuzzy `~`, trailing wildcard `*`).
  peeling = true
  while (peeling && value.length > 1) {
    peeling = false
    for (let op of suffixOperators) {
      if (op && value.endsWith(op) && value.length > op.length) {
        suffix = op + suffix
        value = value.slice(0, -op.length)
        peeling = true
        break
      }
    }
  }

  return {lead, base: value, suffix}
}

/**
 * @param {string} base
 * @return {boolean}
 * @private
 */
static _isColorableRulesetTagBase(base)
{
  let value = String(base ?? '')
  if (!value || value === '~') {
    return false
  }
  if (/^[*~]+$/.test(value)) {
    return false
  }
  let outsideParens = value.replace(/\([^)]*\)/g, '')
  return !/[*~]/.test(outsideParens)
}

/**
 * @param {string} token
 * @param {{tokenize?: function(string): string[], normalizeTag?: function(string): string, resolveTag?: function(string): object}} options
 * @return {Node}
 * @private
 */
static _renderRulesetQueryToken(token, options = {})
{
  let normalizeTag = options.normalizeTag ?? ((value) => String(value ?? '').trim())
  let resolveTag = options.resolveTag ?? (() => ({}))
  let operators = options.operators ?? null
  let isQuerySyntaxToken = options.isQuerySyntaxToken ??
      ((value) => typeof BrazenTagQueryEngine !== 'undefined' &&
          BrazenTagQueryEngine.isQuerySyntaxToken(value, options.querySyntaxKeys))
  let text = String(token ?? '')
  if (!text) {
    return document.createTextNode('')
  }
  if (text.startsWith('(') && text.endsWith(')')) {
    let innerWrap = Utilities.makeEl('span', {class: 'bv-ruleset-tag-query-group'})
    innerWrap.append(document.createTextNode('('))
    innerWrap.append(BrazenViewLayer.createRulesetTagQueryLine(text.slice(1, -1), options))
    innerWrap.append(document.createTextNode(')'))
    return innerWrap
  }
  // Operators are opt-in: without a scheme the token is treated literally (operators not tolerated).
  let {lead, base, suffix} = BrazenViewLayer._splitTagOperators(text, operators)
  if (!operators && isQuerySyntaxToken(text)) {
    return Utilities.makeEl('span', {class: 'bv-ruleset-panel-content-text', text: text})
  }
  if (BrazenViewLayer._isColorableRulesetTagBase(base)) {
    let tagName = normalizeTag(base)
    let meta = isQuerySyntaxToken(text) ? {} : (resolveTag(tagName) ?? {})
    // The whole token (operators + optional type prefix + tag + suffix) renders as one colored span.
    return BrazenViewLayer.createRulesetTagToken(tagName, {
      ...meta,
      leadOperators: lead,
      suffixOperators: suffix,
    })
  }
  return Utilities.makeEl('span', {class: 'bv-ruleset-panel-content-text', text: text})
}

/**
 * Render a space-separated tag query line with per-token type colors.
 * @param {string} query
 * @param {{tokenize?: function(string): string[], normalizeTag?: function(string): string, resolveTag?: function(string): object}} [options]
 * @return {HTMLElement}
 */
static createRulesetTagQueryLine(query, options = {})
{
  let tokenize = options.tokenize ?? BrazenViewLayer._defaultRulesetQueryTokenize
  let text = String(query ?? '')
  let tokens = tokenize(text)
  if (!tokens.length) {
    return Utilities.makeEl('span', {class: 'bv-ruleset-panel-content-text', text: text})
  }
  let wrap = Utilities.makeEl('span', {class: 'bv-ruleset-tag-tokens bv-ruleset-tag-query'})
  for (let index = 0; index < tokens.length; index++) {
    if (index > 0) {
      wrap.append(document.createTextNode(' '))
    }
    wrap.append(BrazenViewLayer._renderRulesetQueryToken(tokens[index], options))
  }
  return wrap
}

/**
 * Extract colorable tag atoms from a ruleset query line (for TagRuntime warm-before-paint).
 * @param {string} query
 * @param {{tokenize?: function(string): string[], normalizeTag?: function(string): string}} [options]
 * @return {string[]}
 */
static collectRulesetQueryTagNames(query, options = {})
{
  let tokenize = options.tokenize ?? BrazenViewLayer._defaultRulesetQueryTokenize
  let normalizeTag = options.normalizeTag ?? ((value) => String(value ?? '').trim())
  let operators = options.operators ?? null
  let isQuerySyntaxToken = options.isQuerySyntaxToken ??
      ((value) => typeof BrazenTagQueryEngine !== 'undefined' &&
          BrazenTagQueryEngine.isQuerySyntaxToken(value, options.querySyntaxKeys))
  let names = new Set()
  let addFromToken = (token) => {
    let text = String(token ?? '')
    if (!text) {
      return
    }
    if (text.startsWith('(') && text.endsWith(')')) {
      for (let inner of tokenize(text.slice(1, -1))) {
        addFromToken(inner)
      }
      return
    }
    let {base} = BrazenViewLayer._splitTagOperators(text, operators)
    if (isQuerySyntaxToken(text)) {
      return
    }
    if (BrazenViewLayer._isColorableRulesetTagBase(base)) {
      let name = normalizeTag(base)
      if (name) {
        names.add(name)
      }
    }
  }
  for (let token of tokenize(String(query ?? ''))) {
    addFromToken(token)
  }
  return [...names]
}

/**
 * Lazy typeahead tag combobox (top 5 matches).
 * @param {{name?: string, value?: string, placeholder?: string, searchNames?: function(string): Promise<string[]>, segmentMode?: 'combo'|'query'|false}} [options]
 * @return {HTMLElement}
 */
static createTagInput(options = {})
{
  let settings = {
    name: 'tagName',
    value: '',
    placeholder: 'Tag name…',
    searchNames: async () => [],
    segmentMode: false,
    emptyDismissMs: 2500,
    ...options,
  }
  let wrap = Utilities.makeEl('div', {class: 'bv-tag-input-wrap'})
  let input = Utilities.makeEl('input', {
    class: 'bv-input bv-text bv-tag-input',
    attrs: {
      type: 'search',
      name: settings.name,
      placeholder: settings.placeholder,
      autocomplete: 'off',
      value: settings.value ?? '',
    },
  })
  let list = Utilities.makeEl('div', {class: 'bv-tag-input-suggestions bv-hidden'})
  wrap.append(input, list)

  let getSegmentPrefix = (value) => {
    let text = String(value ?? '')
    if (settings.segmentMode === 'combo') {
      let idx = text.lastIndexOf('&')
      return (idx >= 0 ? text.slice(idx + 1) : text).trim()
    }
    if (settings.segmentMode === 'query') {
      let idx = text.lastIndexOf(' ')
      return (idx >= 0 ? text.slice(idx + 1) : text).trim()
    }
    return text.trim()
  }

  let applySegmentReplacement = (value, replacement) => {
    let text = String(value ?? '')
    if (settings.segmentMode === 'combo') {
      let idx = text.lastIndexOf('&')
      if (idx >= 0) {
        return `${text.slice(0, idx).trimEnd()} & ${replacement}`
      }
      return replacement
    }
    if (settings.segmentMode === 'query') {
      let idx = text.lastIndexOf(' ')
      if (idx >= 0) {
        return `${text.slice(0, idx).trimEnd()} ${replacement}`
      }
      return replacement
    }
    return replacement
  }

  let inputTimer = null
  let emptyDismissTimer = null
  let searchGeneration = 0
  let lastRenderedPrefix = ''
  let portalListeners = []

  let clearEmptyDismissTimer = () => {
    if (emptyDismissTimer != null) {
      clearTimeout(emptyDismissTimer)
      emptyDismissTimer = null
    }
  }

  let hideSuggestions = () => {
    clearEmptyDismissTimer()
    Utilities.setHidden(list, true)
    list.classList.remove('bv-tag-input-suggestions--fixed')
    list.style.top = ''
    list.style.left = ''
    list.style.width = ''
    detachPortalListeners()
  }

  let detachPortalListeners = () => {
    for (let entry of portalListeners) {
      entry.target.removeEventListener(entry.type, entry.handler, entry.options)
    }
    portalListeners = []
  }

  let syncSuggestionPosition = () => {
    if (list.classList.contains('bv-hidden')) {
      return
    }
    let rect = input.getBoundingClientRect()
    list.classList.add('bv-tag-input-suggestions--fixed')
    list.style.top = `${rect.bottom + 2}px`
    list.style.left = `${rect.left}px`
    list.style.width = `${rect.width}px`
  }

  let attachPortalListeners = () => {
    if (portalListeners.length) {
      return
    }
    let onReposition = () => syncSuggestionPosition()
    let bind = (target, type, options) => {
      target.addEventListener(type, onReposition, options)
      portalListeners.push({target, type, handler: onReposition, options})
    }
    bind(window, 'scroll', true)
    bind(window, 'resize')
    let shell = document.getElementById('bv-ui')
    if (shell) {
      bind(shell, 'scroll', true)
    }
    for (let scrollEl of document.querySelectorAll('.bv-ruleset-detail-scroll, .bv-settings-detail-body')) {
      bind(scrollEl, 'scroll', true)
    }
  }

  let showSuggestionsPanel = () => {
    Utilities.setHidden(list, false)
    syncSuggestionPosition()
    attachPortalListeners()
  }

  let scheduleEmptyDismiss = () => {
    clearEmptyDismissTimer()
    emptyDismissTimer = setTimeout(() => {
      emptyDismissTimer = null
      if (getSegmentPrefix(input.value) === lastRenderedPrefix) {
        hideSuggestions()
      }
    }, settings.emptyDismissMs)
  }

  let renderStatus = (text) => {
    list.replaceChildren(Utilities.makeEl('div', {
      class: 'bv-tag-input-status',
      text,
    }))
    showSuggestionsPanel()
  }

  let renderMatches = async () => {
    let prefix = getSegmentPrefix(input.value)
    if (!prefix) {
      lastRenderedPrefix = ''
      hideSuggestions()
      return
    }
    clearEmptyDismissTimer()
    lastRenderedPrefix = prefix
    let generation = ++searchGeneration
    renderStatus('Searching…')
    let matches = []
    try {
      matches = await settings.searchNames(prefix)
    } catch (_) { /* ignore */ }
    if (generation !== searchGeneration || getSegmentPrefix(input.value) !== prefix) {
      return
    }
    let names = (matches ?? []).slice(0, 5)
    list.replaceChildren()
    if (!names.length) {
      renderStatus('No matching tags')
      scheduleEmptyDismiss()
      return
    }
    for (let name of names) {
      list.append(Utilities.makeEl('button', {
        class: 'bv-tag-input-option bv-button',
        text: name,
        attrs: {type: 'button'},
        on: {
          mousedown: (event) => {
            event.preventDefault()
            clearEmptyDismissTimer()
            input.value = applySegmentReplacement(input.value, name)
            hideSuggestions()
            input.dispatchEvent(new Event('input', {bubbles: true}))
            input.focus()
          },
        },
      }))
    }
    showSuggestionsPanel()
  }

  input.addEventListener('input', () => {
    clearTimeout(inputTimer)
    clearEmptyDismissTimer()
    inputTimer = setTimeout(() => void renderMatches(), 120)
  })
  input.addEventListener('focus', () => {
    wrap.classList.add('bv-tag-input-wrap--open')
    let prefix = getSegmentPrefix(input.value)
    if (prefix) {
      void renderMatches()
    }
  })
  input.addEventListener('blur', () => {
    wrap.classList.remove('bv-tag-input-wrap--open')
    setTimeout(() => hideSuggestions(), 150)
  })
  return wrap
}

/**
 * Group picker with optional create row.
 * @param {{name?: string, value?: string, groups?: string[], onCreate?: function(string): void}} [options]
 * @return {HTMLElement}
 */
static createGroupCombobox(options = {})
{
  let settings = {
    name: 'groupLabel',
    value: '',
    groups: [],
    onCreate: null,
    ...options,
  }
  let wrap = Utilities.makeEl('div', {class: 'bv-group-combobox-wrap'})
  let input = Utilities.makeEl('input', {
    class: 'bv-input bv-text bv-group-combobox',
    attrs: {
      type: 'search',
      name: settings.name,
      placeholder: 'Group…',
      list: '',
      value: settings.value ?? '',
    },
  })
  let list = Utilities.makeEl('div', {class: 'bv-group-combobox-suggestions bv-hidden'})
  wrap.append(input, list)
  let paint = () => {
    let query = String(input.value ?? '').trim()
    let lower = query.toLowerCase()
    let matches = settings.groups.filter((group) => !lower || group.toLowerCase().includes(lower)).slice(0, 5)
    list.replaceChildren()
    if (query && !settings.groups.some((group) => group.toLowerCase() === lower)) {
      list.append(Utilities.makeEl('button', {
        class: 'bv-group-combobox-create bv-button',
        text: '+ Create group "' + query + '"',
        attrs: {type: 'button'},
        on: {
          click: (event) => {
            event.preventDefault()
            settings.onCreate?.(query)
            if (!settings.groups.includes(query)) {
              settings.groups.push(query)
            }
            input.value = query
            Utilities.setHidden(list, true)
          },
        },
      }))
    }
    for (let group of matches) {
      list.append(Utilities.makeEl('button', {
        class: 'bv-group-combobox-option bv-button',
        text: group,
        attrs: {type: 'button'},
        on: {
          click: (event) => {
            event.preventDefault()
            input.value = group
            Utilities.setHidden(list, true)
          },
        },
      }))
    }
    Utilities.setHidden(list, !list.childElementCount)
  }
  input.addEventListener('input', paint)
  input.addEventListener('focus', paint)
  input.addEventListener('blur', () => setTimeout(() => Utilities.setHidden(list, true), 150))
  return wrap
}

/**
 * @param {string} label
 * @param {{help?: string|HTMLElement|HTMLElement[]}} [options]
 * @return {HTMLElement}
 */
createFieldLabelRow(label, options = {})
{
  let settings = BrazenViewLayer._normalizeFieldHelpOptions(options)
  let row = Utilities.makeEl('div', {class: 'bv-field-label-row'})
  row.append(Utilities.makeEl('span', {class: 'bv-label bv-text bv-field-label-text', text: label}))
  let helpBtn = BrazenViewLayer.createFieldHelpButton(label, settings)
  if (helpBtn) {
    row.append(helpBtn)
  }
  return row
}

/**
 * @param {string} id
 * @param {string} title
 * @return {HTMLElement}
 */
static createDockSlidePanel(id, title)
{
  return Utilities.makeEl('section', {
    class: 'bv-section bv-font-primary bv-dock-slide-panel bv-bg-colour bv-dock-panel-hidden',
    attrs: {id},
    children: [
      Utilities.makeEl('div', {
        class: 'bv-dock-slide-panel-head',
        children: [
          Utilities.makeEl('h2', {class: 'bv-dock-slide-panel-title', text: title}),
          Utilities.makeEl('button', {
            class: 'bv-dock-slide-panel-close bv-button',
            text: '×',
            attrs: {type: 'button', 'aria-label': 'Close'},
          }),
        ],
      }),
      Utilities.makeEl('div', {class: 'bv-dock-slide-panel-body'}),
    ],
  })
}

/**
 * @return {HTMLElement}
 */
createComplianceRulesSlidePanel()
{
  let panel = BrazenViewLayer.createDockSlidePanel('bv-compliance-rules', 'Active Hide Rules')
  panel.querySelector('.bv-dock-slide-panel-body')?.replaceChildren(
      Utilities.makeEl('p', {class: 'bv-dock-list-intro', text: 'Filters that hid items on this page.'}),
      Utilities.makeEl('div', {class: 'bv-dock-list-groups'}),
  )
  return panel
}

/**
 * @param {HTMLElement} panel
 * @param {{filterKey: string, filterLabel: string, rules: {label: string, count: number}[]}[]} report
 * @param {{canRemoveRule?: function(string, string): boolean, onRemoveRule?: function(string, string): void, getRuleColor?: function(string, string): string|null|undefined}} [options]
 */
renderComplianceRulesPanelContent(panel, report, options = {})
{
  let groups = panel.querySelector('.bv-dock-list-groups')
  let body = panel.querySelector('.bv-dock-slide-panel-body')
  if (!groups) {
    body?.replaceChildren(
        Utilities.makeEl('p', {class: 'bv-dock-list-intro', text: 'Filters that hid items on this page.'}),
        Utilities.makeEl('div', {class: 'bv-dock-list-groups'}),
    )
    groups = body?.querySelector('.bv-dock-list-groups') ?? null
  } else {
    groups.replaceChildren()
  }
  if (!groups) {
    return
  }
  if (!report.length) {
    groups.append(Utilities.makeEl('p', {class: 'bv-dock-list-intro', text: 'No items hidden on this page.'}))
    return
  }
  for (let section of report) {
    let list = Utilities.makeEl('ul', {class: 'bv-dock-list'})
    for (let rule of section.rules) {
      let actions = Utilities.makeEl('span', {class: 'bv-tag-actions'})
      let canRemove = false
      try {
        canRemove = !!options.canRemoveRule?.(section.filterKey, rule.label)
      } catch (error) {
        canRemove = false
      }
      if (canRemove) {
        let removeButton = this.createFormButton('Remove', 'Remove this rule from the filter list.', () => {
          options.onRemoveRule?.(section.filterKey, rule.label)
        })
        removeButton.classList.add('bv-dock-list-action-btn')
        actions.append(removeButton)
      }
      let nameEl = Utilities.makeEl('span', {class: 'bv-dock-list-name', text: rule.label})
      let ruleColor = null
      try {
        ruleColor = options.getRuleColor?.(section.filterKey, rule.label) ?? null
      } catch (error) {
        ruleColor = null
      }
      if (ruleColor) {
        nameEl.style.color = ruleColor
      }
      list.append(Utilities.makeEl('li', {
        class: 'bv-dock-list-row',
        children: [
          nameEl,
          Utilities.makeEl('span', {class: 'bv-dock-list-meta', text: rule.count + ' hidden'}),
          actions,
        ],
      }))
    }
    groups.append(Utilities.makeEl('section', {
      class: 'bv-dock-list-group',
      children: [
        Utilities.makeEl('h3', {text: section.filterLabel}),
        list,
      ],
    }))
  }
}

/**
 * @param {object} field
 * @param {{stateClass?: string, tooltip?: string, icon?: string, disabled?: boolean}} presentation
 */
updateDockFieldButton(field, presentation = {})
{
  if (!field.dockElement) {
    return
  }
  let dockElement = field.dockElement
  let button = dockElement.classList.contains('bv-dock-slot')
      ? dockElement.querySelector(':scope > .bv-dock-btn')
      : dockElement
  if (!button) {
    return
  }
  button.classList.remove('bv-dock-btn-active', 'bv-dock-btn-disabled')
  button.disabled = false
  if (presentation.disabled) {
    button.disabled = true
    button.classList.add('bv-dock-btn-disabled')
  } else if (presentation.stateClass) {
    button.classList.add(presentation.stateClass)
  }
  if (presentation.tooltip) {
    button.setAttribute('title', presentation.tooltip)
  }
  if (typeof presentation.icon === 'string') {
    if (button.dataset.bvDockIcon !== presentation.icon) {
      button.dataset.bvDockIcon = presentation.icon
      button.replaceChildren(BrazenViewLayer.getDockIcon(presentation.icon))
    }
  }
}

/**
 * @param {string} orientation
 * @param {string} nameBarText
 * @return {HTMLElement}
 */
createDock(orientation, nameBarText)
{
  return Utilities.makeEl('div', {
    class: 'bv-dock bv-dock-' + orientation,
    children: [
      Utilities.makeEl('div', {class: 'bv-dock-name', text: nameBarText}),
      Utilities.makeEl('div', {
        class: 'bv-dock-rail',
        children: [
          Utilities.makeEl('div', {class: 'bv-dock-rail-head'}),
          Utilities.makeEl('div', {class: 'bv-dock-rail-body'}),
          Utilities.makeEl('div', {class: 'bv-dock-rail-foot'}),
        ],
      }),
    ],
  })
}

/**
 * @return {HTMLElement}
 */
createDockSeparator()
{
  return Utilities.makeEl('div', {class: 'bv-dock-separator', attrs: {'aria-hidden': 'true'}})
}

/**
 * Mounts a dock rail body from an array of nodes (field buttons, separators, etc.).
 * Adjacent separators are hidden via CSS (`.bv-dock-separator + .bv-dock-separator`).
 * @param {HTMLElement} railBody
 * @param {HTMLElement[]} items
 * @return {HTMLElement}
 */
mountDockRail(railBody, items)
{
  railBody.replaceChildren()
  for (let item of items) {
    if (item) {
      railBody.append(item)
    }
  }
  return railBody
}

/**
 * @param {{id?: string, scriptName?: string}} [options]
 * @return {HTMLElement}
 */
static createMigrationPanel(options = {})
{
  let panel = Utilities.makeEl('section', {
    class: 'bv-section bv-font-primary bv-migration-panel bv-bg-colour bv-hidden',
    attrs: {id: options.id ?? 'bv-migration-panel', role: 'status', 'aria-live': 'polite'},
    children: [
      Utilities.makeEl('h2', {class: 'bv-dock-slide-panel-title', text: 'Updating database'}),
      Utilities.makeEl('p', {class: 'bv-migration-script-name', text: options.scriptName ?? ''}),
      Utilities.makeEl('p', {class: 'bv-migration-group-label', text: 'Preparing…'}),
      Utilities.makeEl('p', {class: 'bv-migration-detail'}),
      Utilities.makeEl('p', {class: 'bv-migration-elapsed', text: '0s elapsed'}),
      Utilities.makeEl('div', {
        class: 'bv-migration-progress bv-migration-indeterminate',
        children: [Utilities.makeEl('span')],
      }),
    ],
  })
  panel._bvMigrationElapsedStartedAt = Date.now()
  panel._bvMigrationElapsedTimer = null
  return panel
}

/**
 * @param {HTMLElement} panel
 */
static showMigrationPanel(panel)
{
  if (!panel) {
    return
  }
  Utilities.setHidden(panel, false)
  panel.classList.remove('bv-hidden')
  panel.style.display = 'flex'
}

/**
 * @param {HTMLElement} panel
 */
static hideMigrationPanel(panel)
{
  if (!panel) {
    return
  }
  if (panel._bvMigrationElapsedTimer) {
    clearInterval(panel._bvMigrationElapsedTimer)
    panel._bvMigrationElapsedTimer = null
  }
  Utilities.setHidden(panel, true)
  panel.style.display = 'none'
}

/**
 * @param {HTMLElement} panel
 * @param {{phase?: string, label?: string, detail?: string, current?: number, total?: number, indeterminate?: boolean}} progress
 */
static updateMigrationPanel(panel, progress = {})
{
  if (!panel) {
    return
  }
  let labelEl = panel.querySelector('.bv-migration-group-label')
  let detailEl = panel.querySelector('.bv-migration-detail')
  let progressWrap = panel.querySelector('.bv-migration-progress')
  let progressBar = progressWrap?.querySelector('span')
  if (labelEl && progress.label) {
    labelEl.textContent = progress.label
  }
  if (detailEl) {
    detailEl.textContent = progress.detail ?? ''
  }
  if (!progressWrap || !progressBar) {
    return
  }
  let indeterminate = progress.indeterminate ||
      progress.total == null ||
      progress.total <= 0 ||
      progress.current == null
  progressWrap.classList.toggle('bv-migration-indeterminate', !!indeterminate)
  if (indeterminate) {
    progressBar.style.transform = ''
    return
  }
  let ratio = Math.max(0, Math.min(1, Number(progress.current) / Number(progress.total)))
  progressBar.style.transform = 'scaleX(' + ratio + ')'
}

/**
 * @param {HTMLElement} panel
 */
static startMigrationPanelElapsedTimer(panel)
{
  if (!panel || panel._bvMigrationElapsedTimer) {
    return
  }
  panel._bvMigrationElapsedStartedAt = Date.now()
  let elapsedEl = panel.querySelector('.bv-migration-elapsed')
  panel._bvMigrationElapsedTimer = window.setInterval(() => {
    if (!elapsedEl) {
      return
    }
    let seconds = Math.floor((Date.now() - panel._bvMigrationElapsedStartedAt) / 1000)
    elapsedEl.textContent = seconds + 's elapsed'
  }, 1000)
}

/**
 * @param {HTMLElement} panel
 * @param {{steps?: string[], onStart?: Function, onReset?: Function}} options
 */
static showMigrationConsentPanel(panel, options = {})
{
  if (!panel) {
    return
  }
  BrazenViewLayer.showMigrationPanel(panel)
  let title = panel.querySelector('.bv-dock-slide-panel-title')
  if (title) {
    title.textContent = 'Database update required'
  }
  panel.querySelector('.bv-migration-group-label')?.remove()
  panel.querySelector('.bv-migration-elapsed')?.remove()
  panel.querySelector('.bv-migration-progress')?.remove()
  panel.querySelector('.bv-migration-schema-too-new-body')?.remove()
  let steps = options.steps ?? []
  let consentBody = Utilities.makeEl('div', {
    class: 'bv-migration-consent-body',
    children: [
      Utilities.makeEl('p', {
        class: 'bv-migration-consent-intro',
        text: 'This script needs to update its local database before it can run. Keep this tab open until the update finishes.',
      }),
      Utilities.makeEl('p', {
        class: 'bv-migration-consent-steps-heading',
        text: 'When you choose Start update, the script will:',
      }),
      Utilities.makeEl('ol', {
        class: 'bv-migration-consent-steps',
        children: steps.map((step) => Utilities.makeEl('li', {text: step})),
      }),
      Utilities.makeEl('div', {
        class: 'bv-migration-actions',
        children: [
          Utilities.makeEl('button', {
            class: 'bv-button bv-migration-start',
            text: 'Start update',
            attrs: {type: 'button'},
            on: {click: () => options.onStart?.()},
          }),
          Utilities.makeEl('button', {
            class: 'bv-button bv-migration-reset',
            text: 'Reset database',
            attrs: {
              type: 'button',
              title: 'Deletes all local settings, tags, bookmarks, and download history for this script.',
            },
            on: {click: () => options.onReset?.()},
          }),
        ],
      }),
    ],
  })
  let detailEl = panel.querySelector('.bv-migration-detail')
  detailEl?.replaceWith(consentBody)
}

/**
 * @param {HTMLElement} panel
 */
static restoreMigrationProgressShell(panel)
{
  if (!panel) {
    return
  }
  let title = panel.querySelector('.bv-dock-slide-panel-title')
  if (title) {
    title.textContent = 'Updating database'
  }
  panel.querySelector('.bv-migration-consent-body')?.remove()
  panel.querySelector('.bv-migration-schema-too-new-body')?.remove()
  let scriptNameEl = panel.querySelector('.bv-migration-script-name')
  let labelEl = panel.querySelector('.bv-migration-group-label')
  if (!labelEl) {
    labelEl = Utilities.makeEl('p', {class: 'bv-migration-group-label', text: 'Preparing…'})
    scriptNameEl?.after(labelEl)
  } else {
    labelEl.textContent = 'Preparing…'
  }
  let detailEl = panel.querySelector('.bv-migration-detail')
  if (!detailEl) {
    detailEl = Utilities.makeEl('p', {class: 'bv-migration-detail'})
    labelEl.after(detailEl)
  }
  detailEl.textContent = ''
  let elapsedEl = panel.querySelector('.bv-migration-elapsed')
  if (!elapsedEl) {
    elapsedEl = Utilities.makeEl('p', {class: 'bv-migration-elapsed', text: '0s elapsed'})
    detailEl.after(elapsedEl)
  } else {
    elapsedEl.textContent = '0s elapsed'
  }
  if (!panel.querySelector('.bv-migration-progress')) {
    elapsedEl.after(Utilities.makeEl('div', {
      class: 'bv-migration-progress bv-migration-indeterminate',
      children: [Utilities.makeEl('span')],
    }))
  }
}

/**
 * Shown when the installed IndexedDB schema is newer than this script supports
 * (typically after downgrading the userscript).
 * @param {HTMLElement} panel
 * @param {{installedVersion?: number|null, supportedVersion?: number|null, onRetry?: Function, onReset?: Function}} options
 */
static showMigrationSchemaTooNewPanel(panel, options = {})
{
  if (!panel) {
    return
  }
  BrazenViewLayer.showMigrationPanel(panel)
  let title = panel.querySelector('.bv-dock-slide-panel-title')
  if (title) {
    title.textContent = 'Database version too new'
  }
  panel.querySelector('.bv-migration-group-label')?.remove()
  panel.querySelector('.bv-migration-elapsed')?.remove()
  panel.querySelector('.bv-migration-progress')?.remove()
  let installed = options.installedVersion
  let supported = options.supportedVersion
  let versionDetail = typeof installed === 'number' && typeof supported === 'number' ?
      'Local database schema v' + installed + ' was created by a newer script than this one (v' + supported + '). ' :
      'The local database was created by a newer script version than this one. '
  let bodyHtml = Utilities.makeEl('div', {
    class: 'bv-migration-schema-too-new-body',
    children: [
      Utilities.makeEl('p', {
        class: 'bv-migration-failure-intro',
        text: versionDetail +
            'Settings, tags, bookmarks, and download history cannot load until you resolve this.',
      }),
      Utilities.makeEl('ol', {
        class: 'bv-migration-failure-steps',
        children: [
          Utilities.makeEl('li', {
            text: 'Install the latest script version in Tampermonkey, then choose Retry below.',
          }),
          Utilities.makeEl('li', {
            text: 'Or reset the database below to start fresh (all local data will be lost).',
          }),
        ],
      }),
      Utilities.makeEl('div', {
        class: 'bv-migration-actions',
        children: [
          Utilities.makeEl('button', {
            class: 'bv-button bv-migration-retry',
            text: 'Retry',
            attrs: {type: 'button'},
            on: {click: () => options.onRetry?.()},
          }),
          Utilities.makeEl('button', {
            class: 'bv-button bv-migration-reset',
            text: 'Reset database',
            attrs: {
              type: 'button',
              title: 'Deletes all local settings, tags, bookmarks, and download history for this script.',
            },
            on: {click: () => options.onReset?.()},
          }),
        ],
      }),
    ],
  })
  let detailEl = panel.querySelector('.bv-migration-detail')
  detailEl?.replaceWith(bodyHtml)
}

/**
 * @param {HTMLElement} panel
 * @param {{backupHint?: string|null, errorMessage?: string, onRetry?: Function, onWipe?: Function}} options
 */
static showMigrationFailurePanel(panel, options = {})
{
  if (!panel) {
    return
  }
  BrazenViewLayer.showMigrationPanel(panel)
  let title = panel.querySelector('.bv-dock-slide-panel-title')
  if (title) {
    title.textContent = 'Database update failed'
  }
  panel.querySelector('.bv-migration-schema-too-new-body')?.remove()
  let backupHint = options.backupHint ?? 'your pre-migration backup zip'
  let bodyHtml = Utilities.makeEl('div', {
    children: [
      Utilities.makeEl('p', {
        class: 'bv-migration-failure-intro',
        text: options.errorMessage ??
            'Migration did not complete. Your local data may be inconsistent.',
      }),
      Utilities.makeEl('ol', {
        class: 'bv-migration-failure-steps',
        children: [
          Utilities.makeEl('li', {
            text: 'Install the previous script version (downgrade) in Tampermonkey.',
          }),
          Utilities.makeEl('li', {
            text: 'Open Settings → Toolbox → Restore Configuration and select ' + backupHint + '.',
          }),
          Utilities.makeEl('li', {
            text: 'Or reset the database below to start fresh (all local data will be lost).',
          }),
        ],
      }),
      Utilities.makeEl('div', {
        class: 'bv-migration-actions',
        children: [
          Utilities.makeEl('button', {
            class: 'bv-button bv-migration-retry',
            text: 'Retry',
            attrs: {type: 'button'},
            on: {click: () => options.onRetry?.()},
          }),
          Utilities.makeEl('button', {
            class: 'bv-button bv-migration-reset',
            text: 'Reset database',
            attrs: {type: 'button'},
            on: {click: () => options.onWipe?.()},
          }),
        ],
      }),
    ],
  })
  let detailEl = panel.querySelector('.bv-migration-detail')
  let elapsedEl = panel.querySelector('.bv-migration-elapsed')
  let progressWrap = panel.querySelector('.bv-migration-progress')
  detailEl?.replaceWith(bodyHtml)
  elapsedEl?.remove()
  progressWrap?.remove()
}

/**
 * @param {string} message
 * @return {HTMLElement}
 */
createDockMigrationStatus(message)
{
  return Utilities.makeEl('div', {
    class: 'bv-dock-status',
    attrs: {role: 'status', 'aria-live': 'polite'},
    children: [
      Utilities.makeEl('span', {class: 'bv-dock-status-spinner', attrs: {'aria-hidden': 'true'}}),
      Utilities.makeEl('span', {class: 'bv-dock-status-text', text: message}),
    ],
  })
}

/**
 * Minimal dock shell for pre-init migration UI (name bar + status, no buttons).
 * @param {string} orientation
 * @param {string} nameBarText
 * @param {string} [message='Migrating database…']
 * @return {HTMLElement}
 */
createMigrationDock(orientation, nameBarText, message = 'Migrating database…')
{
  let dock = this.createDock(orientation, nameBarText)
  this.setDockMigrationStatus(dock, message)
  return dock
}

/**
 * Enter/exit dock migrating mode: 50vw width, script name kept, buttons cleared, status in rail.
 * @param {HTMLElement} dock
 * @param {string|null} message Null clears migrating mode (status removed; caller rebuilds buttons).
 */
setDockMigrationStatus(dock, message)
{
  if (!dock) {
    return
  }
  let rail = dock.querySelector('.bv-dock-rail')
  let status = dock.querySelector(':scope > .bv-dock-rail > .bv-dock-status, :scope > .bv-dock-status')
  if (!message) {
    dock.classList.remove('bv-dock-migrating')
    status?.remove()
    return
  }
  dock.classList.add('bv-dock-migrating')
  for (let section of dock.querySelectorAll('.bv-dock-rail-head, .bv-dock-rail-body, .bv-dock-rail-foot')) {
    section.replaceChildren()
  }
  if (!status) {
    status = this.createDockMigrationStatus(message)
    if (rail) {
      rail.append(status)
    } else {
      dock.append(status)
    }
    return
  }
  let textEl = status.querySelector('.bv-dock-status-text')
  if (textEl) {
    textEl.textContent = message
  }
}

/**
 * @param {{icon?: string|HTMLElement|SVGElement, tooltip?: string, stateClass?: string, onClick?: Function, disabled?: boolean}} options
 * @return {HTMLElement}
 */
createDockButton(options = {})
{
  let button = Utilities.makeEl('button', {class: 'bv-dock-btn', attrs: {type: 'button'}})
  if (options.stateClass) {
    button.classList.add(options.stateClass)
  }
  if (options.disabled) {
    button.disabled = true
    button.classList.add('bv-dock-btn-disabled')
  }
  if (options.tooltip) {
    button.setAttribute('title', options.tooltip)
  }
  if (options.icon) {
    button.append(typeof options.icon === 'string'
        ? BrazenViewLayer.getDockIcon(options.icon)
        : options.icon)
  }
  if (options.onClick) {
    button.addEventListener('click', (event) => {
      event.preventDefault()
      options.onClick(event)
    })
  }
  return button
}

/**
 * @param {HTMLElement} parentButton
 * @param {HTMLElement[]} childButtons
 * @return {HTMLElement}
 */
createDockSlideOut(parentButton, childButtons)
{
  return Utilities.makeEl('div', {
    class: 'bv-dock-slot',
    children: [
      parentButton,
      Utilities.makeEl('div', {class: 'bv-dock-slideout', children: childButtons}),
    ],
  })
}

/**
 * Always-visible inset panel (slide-out styling, darker background) for grouped dock controls.
 * @param {HTMLElement[]} items
 * @return {HTMLElement}
 */
createDockInsetGroup(items = [])
{
  let group = Utilities.makeEl('div', {class: 'bv-dock-inset-group'})
  for (let item of items) {
    if (item) {
      group.append(item)
    }
  }
  return group
}

/**
 * @param {{orientations: string[], value: string, onCycle?: Function}} options
 * @return {HTMLElement}
 */
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
  let button = this.createDockButton({
    icon: orientationIcons[value] ?? 'dock-right',
    tooltip: 'Dock position: ' + label + ' (click to change)',
    onClick: () => options.onCycle?.(),
  })
  button.classList.add('bv-dock-position-btn')
  return button
}

/**
 * @param {string} tabName
 * @param {boolean} isFirst
 * @return {HTMLElement}
 */
createTabButton(tabName, isFirst)
{
  return Utilities.makeEl('button', {
    class: 'bv-tab-button bv-border-primary' + (isFirst ? ' bv-active bv-font-secondary' : ' bv-font-primary'),
    text: tabName,
    on: {
      click: (event) => {
        let button = event.currentTarget
        let tabSection = button.closest('.bv-tabs-section')
        if (!tabSection) {
          return
        }
        for (let tabBtn of tabSection.querySelectorAll('.bv-tab-button')) {
          tabBtn.classList.remove('bv-active', 'bv-font-secondary')
          tabBtn.classList.add('bv-font-primary')
        }
        for (let tabPanel of tabSection.querySelectorAll('.bv-tab-panel')) {
          tabPanel.classList.remove('bv-active')
        }
        button.classList.remove('bv-font-primary')
        button.classList.add('bv-active', 'bv-font-secondary')
        document.getElementById(Utilities.toKebabCase(button.textContent))?.classList.add('bv-active')
      },
      mouseenter: (event) => event.currentTarget.classList.add('bv-font-secondary'),
      mouseleave: (event) => event.currentTarget.classList.remove('bv-font-secondary'),
    },
  })
}

/**
 * @param {string} tabName
 * @param {boolean} isFirst
 * @return {HTMLElement}
 */
createTabPanel(tabName, isFirst = false)
{
  return Utilities.makeEl('div', {
    class: 'bv-tab-panel bv-border-primary' + (isFirst ? ' bv-active' : ''),
    attrs: {id: Utilities.toKebabCase(tabName)},
  })
}

/**
 * @param {string[]} tabNames
 * @param {HTMLElement[]} tabPanels
 * @return {HTMLElement}
 */
createTabsSection(tabNames, tabPanels)
{
  let tabButtons = []
  for (let i = 0; i < tabNames.length; i++) {
    tabButtons.push(this.createTabButton(tabNames[i], i === 0))
  }
  let nav = Utilities.makeEl('div', {class: 'bv-tabs-nav', children: tabButtons})
  return Utilities.makeEl('div', {class: 'bv-tabs-section', children: [nav, ...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 {HTMLElement}
 */
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 = Utilities.makeEl('a', {
    class: 'bv-button bv-donate-patreon',
    attrs: {
      href: patreonUrl,
      target: '_blank',
      rel: 'noopener noreferrer',
      title: 'Open Patreon',
    },
    children: [
      BrazenViewLayer.createPatreonIcon('bv-donate-patreon-icon'),
      Utilities.makeEl('span', {class: 'bv-donate-patreon-label', text: linkLabel}),
    ],
  })

  return Utilities.appendChildren(this.createTabPanel(tabName, !!options.isFirst), [
    Utilities.makeEl('p', {class: 'bv-donate-intro', text: message}),
    Utilities.makeEl('div', {class: 'bv-donate-actions', children: link}),
  ])
}

/**
 * @param {string} title
 * @return {HTMLElement}
 */
createTitle(title)
{
  return Utilities.makeEl('label', {class: 'bv-title', text: title})
}

/**
 * @return {HTMLElement}
 */
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.value = next
  input.dispatchEvent(new Event('input', {bubbles: true}))
  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 {HTMLElement}
 */
createPatternTokenChip(chip)
{
  let label = chip.label ?? chip.insert ?? ''
  let insert = chip.insert ?? chip.label ?? ''
  let attrs = {
    type: 'button',
    ...(insert ? {'data-insert': insert} : {}),
  }
  if (chip.title) {
    attrs.title = chip.title
  }
  let button = Utilities.makeEl('button', {
    class: 'bv-token-btn' +
        (chip.separator ? ' bv-token-sep' : '') +
        (chip.folderOnly ? ' bv-token-folder-sep' : ''),
    text: label,
    attrs,
    hidden: !!chip.folderOnly,
  })
  return button
}

/**
 * @param {{label: string, chips: {token?: string, insert?: string, label: string, title: string}[]}[]} sections
 * @param {{insert: string, label: string, title: string}[]} separators
 * @return {HTMLElement}
 */
createSharedPatternTokenBuilder(sections, separators)
{
  let builder = Utilities.makeEl('div', {class: 'bv-pattern-builder bv-pattern-builder-shared'})
  /** @type {HTMLElement[]} */
  let folderSeparatorButtons = []

  for (let section of sections) {
    let bar = Utilities.makeEl('div', {class: 'bv-token-bar'})
    for (let chip of section.chips) {
      bar.append(this.createPatternTokenChip(chip))
    }
    builder.append(Utilities.makeEl('div', {
      class: 'bv-token-row',
      children: [
        Utilities.makeEl('span', {class: 'bv-token-row-label', text: section.label}),
        bar,
      ],
    }))
  }

  let joinBar = Utilities.makeEl('div', {class: 'bv-token-bar'})
  for (let chip of separators) {
    let button = this.createPatternTokenChip({...chip, separator: true, folderOnly: chip.insert === '/'})
    if (chip.insert === '/') {
      folderSeparatorButtons.push(button)
    }
    joinBar.append(button)
  }
  builder.append(Utilities.makeEl('div', {
    class: 'bv-token-row',
    children: [
      Utilities.makeEl('span', {class: 'bv-token-row-label', text: 'Join'}),
      joinBar,
    ],
  }))

  builder._bvFolderSeparatorButtons = folderSeparatorButtons
  return builder
}

/**
 * @param {HTMLInputElement} filenameInput
 * @param {HTMLInputElement} subfolderInput
 * @param {HTMLElement} 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._bvFolderSeparatorButtons ??
      [...builder.querySelectorAll('.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)
    for (let button of folderSeparatorButtons) {
      Utilities.setHidden(button, input !== subfolderInput)
    }
  }

  builder._bvPatternAbort?.abort()
  builder._bvPatternAbort = new AbortController()
  let {signal} = builder._bvPatternAbort

  filenameInput.addEventListener('focusin', () => activate(filenameInput), {signal})
  subfolderInput.addEventListener('focusin', () => activate(subfolderInput), {signal})
  activate(filenameInput)

  builder.addEventListener('click', (event) => {
    let button = event.target.closest('.bv-token-btn')
    if (!button || !builder.contains(button)) {
      return
    }
    event.preventDefault()
    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.classList.add('bv-token-flash')
    setTimeout(() => button.classList.remove('bv-token-flash'), 180)
  }, {signal})

  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')
    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.querySelector('.bv-dock-slide-panel-close')?.remove()
  panel.querySelector('.bv-dock-slide-panel-body')?.replaceChildren(
      Utilities.makeEl('p', {
        class: 'bv-dock-list-intro',
        text: 'New tags were found. Review actions, then confirm to continue.',
      }),
      Utilities.makeEl('div', {class: 'bv-dock-list-groups'}),
      Utilities.makeEl('details', {
        class: 'bv-dock-list-similar',
        children: [
          Utilities.makeEl('summary', {
            attrs: {'data-bv-similar-label': 'Similar tags'},
            children: Utilities.makeEl('h3', {
              class: 'bv-dock-list-section-title',
              text: 'Similar tags',
            }),
          }),
          Utilities.makeEl('div', {
            class: 'bv-dock-list-similar-body',
            children: Utilities.makeEl('p', {class: 'bv-dock-list-similar-status', text: 'Loading…'}),
          }),
        ],
      }),
      Utilities.makeEl('div', {
        class: 'bv-dock-list-footer bv-dock-list-footer-row',
        children: [
          Utilities.makeEl('button', {
            class: 'bv-button bv-dock-list-footer-secondary bv-tag-discovery-skip',
            text: 'Skip',
            attrs: {type: 'button'},
            on: {click: () => options.onSkip?.()},
          }),
          Utilities.makeEl('button', {
            class: 'bv-button bv-dock-list-footer-secondary bv-tag-discovery-open-media',
            text: 'Open media',
            attrs: {type: 'button'},
            on: {click: () => options.onOpenMedia?.()},
          }),
          Utilities.makeEl('button', {
            class: 'bv-button bv-tag-discovery-confirm',
            text: 'Confirm',
            attrs: {type: 'button'},
            on: {click: () => options.onConfirm?.()},
          }),
        ],
      }),
  )
  return panel
}

/**
 * Rate-limit human verification panel (processor tab). Does not auto-close on mouseleave.
 *
 * @param {{
 *   id?: string,
 *   onConfirm?: Function,
 *   onConfirmAndClose?: Function,
 *   onReopen?: Function,
 *   title?: string,
 *   message?: string,
 *   confirmLabel?: string,
 *   confirmAndCloseLabel?: string,
 *   reopenLabel?: string,
 *   showReopen?: boolean,
 * }} [options] `showReopen: false` builds a standalone reload pane (no open-challenge control).
 *   Pass `confirmAndCloseLabel` + `onConfirmAndClose` for an optional secondary confirm
 *   (e.g. queue challenge tab: Done — resume — close tab).
 *   Pass distinct `id`s for per-lane panels (e.g. `bv-human-interaction-panel-resolution`).
 * @return {HTMLElement}
 */
static createHumanInteractionPanel(options = {})
{
  let showReopen = options.showReopen !== false
  let showConfirmAndClose = typeof options.onConfirmAndClose === 'function' ||
      !!options.confirmAndCloseLabel
  let panel = BrazenViewLayer.createDockSlidePanel(
      options.id ?? 'bv-human-interaction-panel',
      options.title ?? 'Verification required',
  )
  panel.querySelector('.bv-dock-slide-panel-close')?.remove()
  // Open challenge first (primary); Done — resume second (secondary) so focus stays until ready.
  // Optional confirm-and-close is always a secondary confirm after Done — resume.
  /** @type {HTMLElement[]} */
  let footerButtons = []
  if (showReopen) {
    footerButtons.push(
        Utilities.makeEl('button', {
          class: 'bv-button bv-human-interaction-reopen',
          text: options.reopenLabel ?? 'Open Cloudflare Challenge',
          attrs: {type: 'button'},
          on: {click: () => options.onReopen?.()},
        }),
    )
  }
  footerButtons.push(
      Utilities.makeEl('button', {
        class: 'bv-button bv-human-interaction-confirm' +
            (showReopen ? ' bv-dock-list-footer-secondary' : ''),
        text: options.confirmLabel ?? 'Done — resume',
        attrs: {type: 'button'},
        on: {click: () => options.onConfirm?.()},
      }),
  )
  if (showConfirmAndClose) {
    footerButtons.push(
        Utilities.makeEl('button', {
          class: 'bv-button bv-human-interaction-confirm-close bv-dock-list-footer-secondary',
          text: options.confirmAndCloseLabel ?? 'Done — resume — close tab',
          attrs: {type: 'button'},
          on: {click: () => options.onConfirmAndClose?.()},
        }),
    )
  }
  panel.querySelector('.bv-dock-slide-panel-body')?.replaceChildren(
      Utilities.makeEl('p', {
        class: 'bv-dock-list-intro',
        text: options.message ??
            'Click Open Cloudflare Challenge when ready, complete it, then Done — resume here.',
      }),
      Utilities.makeEl('div', {class: 'bv-dock-list-footer', children: footerButtons}),
  )
  return panel
}

/**
 * @param {HTMLElement} panel
 * @param {{
 *   title?: string,
 *   message?: string,
 *   confirmLabel?: string,
 *   confirmAndCloseLabel?: string,
 *   reopenLabel?: string,
 * }} content
 */
static updateHumanInteractionPanelContent(panel, content = {})
{
  if (content.title) {
    panel.querySelector('.bv-dock-slide-panel-title').textContent = content.title
  }
  if (content.message) {
    panel.querySelector('.bv-dock-list-intro').textContent = content.message
  }
  if (content.confirmLabel) {
    panel.querySelector('.bv-human-interaction-confirm').textContent = content.confirmLabel
  }
  if (content.confirmAndCloseLabel) {
    let closeBtn = panel.querySelector('.bv-human-interaction-confirm-close')
    if (closeBtn) {
      closeBtn.textContent = content.confirmAndCloseLabel
    }
  }
  if (content.reopenLabel) {
    panel.querySelector('.bv-human-interaction-reopen').textContent = content.reopenLabel
  }
}

/**
 * Reusable timed action button: thin end-to-end countdown bar; hover pauses the timer.
 * Returns a container with `_bvTimedButtonApi = { cancel, start }`.
 *
 * @param {{
 *   label?: string,
 *   durationMs?: number,
 *   onClick?: Function,
 *   onTimeout?: Function,
 *   autoStart?: boolean,
 * }} [options]
 * @return {HTMLElement}
 */
static createTimedButton(options = {})
{
  let durationMs = Math.max(1, options.durationMs ?? 5000)
  let remaining = durationMs
  let paused = false
  let settled = false
  let lastTick = null
  let rafId = null

  let fill = Utilities.makeEl('div', {class: 'bv-timed-button-bar-fill'})
  fill.style.width = '100%'
  let bar = Utilities.makeEl('div', {
    class: 'bv-timed-button-bar',
    children: [fill],
  })
  let button = Utilities.makeEl('button', {
    class: 'bv-button bv-timed-button',
    text: options.label ?? 'Continue',
    attrs: {type: 'button'},
  })
  let container = Utilities.makeEl('div', {
    class: 'bv-timed-button-container',
    children: [button, bar],
  })

  let stopRaf = () => {
    if (rafId != null) {
      cancelAnimationFrame(rafId)
      rafId = null
    }
    lastTick = null
  }

  let settle = (kind) => {
    if (settled) {
      return
    }
    settled = true
    stopRaf()
    if (kind === 'click') {
      options.onClick?.()
    } else {
      options.onTimeout?.()
    }
  }

  let tick = (now) => {
    if (settled) {
      return
    }
    if (paused) {
      lastTick = now
      rafId = requestAnimationFrame(tick)
      return
    }
    if (lastTick == null) {
      lastTick = now
    }
    remaining = Math.max(0, remaining - (now - lastTick))
    lastTick = now
    fill.style.width = ((remaining / durationMs) * 100) + '%'
    if (remaining <= 0) {
      settle('timeout')
      return
    }
    rafId = requestAnimationFrame(tick)
  }

  button.addEventListener('click', () => settle('click'))
  container.addEventListener('mouseenter', () => {
    paused = true
  })
  container.addEventListener('mouseleave', () => {
    paused = false
  })

  let api = {
    cancel: () => {
      settled = true
      stopRaf()
    },
    start: () => {
      if (settled || rafId != null) {
        return
      }
      lastTick = null
      rafId = requestAnimationFrame(tick)
    },
  }
  container._bvTimedButtonApi = api
  if (options.autoStart !== false) {
    api.start()
  }
  return container
}

/**
 * Reusable action button with a thin end-to-end progress bar (same shell as {@link createTimedButton}).
 * Returns a container with `_bvProgressButtonApi = { setProgress, setRunning, reset }`.
 *
 * @param {{
 *   label?: string,
 *   title?: string,
 *   onClick?: Function,
 * }} [options]
 * @return {HTMLElement}
 */
static createProgressButton(options = {})
{
  let fill = Utilities.makeEl('div', {class: 'bv-timed-button-bar-fill'})
  fill.style.width = '0%'
  let bar = Utilities.makeEl('div', {
    class: 'bv-timed-button-bar',
    children: [fill],
  })
  let button = Utilities.makeEl('button', {
    class: 'bv-button bv-timed-button',
    text: options.label ?? 'Run',
    attrs: {
      type: 'button',
      title: options.title ?? '',
    },
  })
  let container = Utilities.makeEl('div', {
    class: 'bv-timed-button-container',
    children: [button, bar],
  })

  let api = {
    setProgress: (current, total) => {
      if (total <= 0) {
        return
      }
      let ratio = Math.min(1, Math.max(0, current / total))
      fill.style.width = (ratio * 100) + '%'
    },
    setRunning: (running) => {
      button.disabled = !!running
    },
    reset: () => {
      button.disabled = false
      fill.style.width = '0%'
    },
  }
  container._bvProgressButtonApi = api
  button.addEventListener('click', () => options.onClick?.(api))
  return container
}

/**
 * Navigation-interrupted download decision panel (leader tab). Embeds a timed button:
 * click → complete + pause; timeout → relaunch.
 *
 * @param {{
 *   title?: string,
 *   message?: string,
 *   tip?: string,
 *   timedLabel?: string,
 *   durationMs?: number,
 *   onComplete?: Function,
 *   onTimeout?: Function,
 *   autoStart?: boolean,
 * }} [options]
 * @return {HTMLElement}
 */
static createDownloadInterruptionPanel(options = {})
{
  let panel = BrazenViewLayer.createDockSlidePanel(
      'bv-download-interruption-panel',
      options.title ?? 'Download interrupted',
  )
  panel.querySelector('.bv-dock-slide-panel-close')?.remove()
  let timed = BrazenViewLayer.createTimedButton({
    label: options.timedLabel ?? 'Complete download — pause queue',
    durationMs: options.durationMs ?? 5000,
    autoStart: options.autoStart !== false,
    onClick: () => options.onComplete?.(),
    onTimeout: () => options.onTimeout?.(),
  })
  // Order: why the popup appeared → timed button → leader-tab tip.
  panel.querySelector('.bv-dock-slide-panel-body')?.replaceChildren(
      Utilities.makeEl('p', {
        class: 'bv-dock-list-intro',
        text: options.message ??
            'A download was in progress when this page loaded. Click to mark it complete and pause the queue, or wait to relaunch it.',
      }),
      Utilities.makeEl('div', {
        class: 'bv-dock-list-footer',
        children: [timed],
      }),
      Utilities.makeEl('p', {
        class: 'bv-dock-list-tip bv-download-interruption-tip',
        text: options.tip ??
            'Tip: keep a separate lightweight homepage open as the download leader tab, and browse or pick media in other tabs so large downloads are not interrupted.',
      }),
  )
  panel._bvTimedButtonApi = timed._bvTimedButtonApi
  return panel
}

/**
 * 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 and ruleset tokens from consumer {@link tagTypes} config.
 * Also builds the `typeEntryId → prefix` map used to render ruleset tag type prefixes (e.g. `artist:name`)
 * from each type's optional `prefix` field.
 * When `typeEntryIdsByType` is omitted (e.g. tag discovery re-render), cached entry ids from a prior
 * full apply are reused so settings ruleset token colors and prefixes are not cleared.
 * @param {Record<string, {label?: string, rowClass?: string, color?: string, prefix?: string}>} tagTypes
 * @param {{typeEntryIdsByType?: Record<string, number[]>}} [options]
 */
static applyTagDiscoveryTypeColors(tagTypes, options = {})
{
  if (!tagTypes) {
    return
  }
  let incomingEntryIds = options.typeEntryIdsByType
  let hasIncomingEntryIds = incomingEntryIds != null &&
      Object.keys(incomingEntryIds).length > 0
  if (hasIncomingEntryIds) {
    BrazenViewLayer._rulesetTagTypeColorState = {
      typeEntryIdsByType: incomingEntryIds,
    }
  }
  let effectiveEntryIds = hasIncomingEntryIds ?
      incomingEntryIds :
      (BrazenViewLayer._rulesetTagTypeColorState?.typeEntryIdsByType ?? {})
  let hasEffectiveEntryIds = Object.keys(effectiveEntryIds).length > 0
  if (hasEffectiveEntryIds) {
    let prefixByEntryId = new Map()
    for (let [typeName, typeConfig] of Object.entries(tagTypes)) {
      let prefix = typeof typeConfig?.prefix === 'string' ? typeConfig.prefix.trim() : ''
      let entryIds = effectiveEntryIds[typeName]
      if (!prefix || !Array.isArray(entryIds)) {
        continue
      }
      for (let entryId of entryIds) {
        if (entryId != null) {
          prefixByEntryId.set(Number(entryId), prefix)
        }
      }
    }
    BrazenViewLayer._rulesetTagTypePrefixByEntryId = prefixByEntryId
  }
  let cssParts = Object.values(tagTypes)
      .filter((type) => type.rowClass && type.color)
      .map((type) => `#bv-tag-discovery-panel .${type.rowClass} .bv-dock-list-name{color:${type.color};}`)
  if (hasEffectiveEntryIds) {
    for (let [typeName, typeConfig] of Object.entries(tagTypes)) {
      if (!typeConfig?.color) {
        continue
      }
      let entryIds = effectiveEntryIds[typeName]
      if (!Array.isArray(entryIds)) {
        continue
      }
      for (let entryId of entryIds) {
        if (entryId != null) {
          cssParts.push(`#bv-ui .bv-ruleset-tag-token[data-type-entry-id="${entryId}"]{color:${typeConfig.color};}`)
        }
      }
    }
  }
  let css = cssParts.join('')
  if (!css) {
    return
  }
  let styleId = 'bv-tag-discovery-type-colors'
  let style = document.getElementById(styleId)
  if (!style) {
    style = document.createElement('style')
    style.id = styleId
    document.head.append(style)
  }
  style.textContent = css
}

/**
 * @param {HTMLElement} 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, onTagNameClick?: function({name: string, type: string|null, count?: number|null}): void, similarSectionLabel?: string, resetSimilar?: boolean, preserveSimilarOpen?: boolean, clearSimilarCount?: boolean, similarOpen?: boolean, similarShowCount?: boolean, intro?: string}} [options]
 */
static renderTagDiscoveryPanelContent(panel, tags, renderActions, options = {})
{
  options = BrazenViewLayer.prepareTagDiscoveryPanelOptions(options)
  if (options.tagTypes) {
    BrazenViewLayer.applyTagDiscoveryTypeColors(options.tagTypes)
  }
  if (options.intro != null && options.intro !== '') {
    panel.querySelector('.bv-dock-list-intro').textContent = options.intro
  }
  let groups = panel.querySelector('.bv-dock-list-groups')
  groups?.replaceChildren()
  let unknownTags = Array.isArray(tags) ? tags : []
  let knownTags = Array.isArray(options.knownTags) ? options.knownTags : []
  if (options.resetSimilar !== false) {
    let resetOptions = {
      label: options.similarSectionLabel,
      status: 'Loading…',
      preserveOpen: options.preserveSimilarOpen === true,
      similarOpen: options.similarOpen === true,
    }
    if (options.clearSimilarCount !== false || options.similarShowCount === false) {
      resetOptions.count = null
    }
    BrazenViewLayer.resetTagDiscoverySimilarSection(panel, resetOptions)
  }
  BrazenViewLayer._bindTagDiscoveryNameClicks(panel, options.onTagNameClick)
  if (!groups || (!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 nameClickable = typeof options.onTagNameClick === 'function'

  let appendGroupedTags = (sectionLabel, sectionTags) => {
    if (!sectionTags?.length) {
      return
    }
    if (sectionLabel) {
      groups.append(Utilities.makeEl('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 = Utilities.makeEl('ul', {class: 'bv-dock-list'})
      for (let tag of groupTags) {
        list.append(BrazenViewLayer._createTagDiscoveryRow(tag, renderActions, getRowClass, nameClickable))
      }
      groups.append(Utilities.makeEl('section', {
        class: 'bv-dock-list-group',
        children: [
          Utilities.makeEl('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,
  )
}

/**
 * Reset the Similar tags block to status text (clears prior rows).
 *
 * @param {HTMLElement} panel
 * @param {{label?: string, status?: string, count?: number|null, preserveOpen?: boolean, similarOpen?: boolean, open?: boolean}} [options]
 *        Pass `count` to set/clear the summary count; omit it to keep the prior count.
 *        `preserveOpen: true` keeps the accordion expanded/collapsed state.
 *        `similarOpen` / `open: true` forces expanded (ignored-pin review starts open).
 */
static resetTagDiscoverySimilarSection(panel, options = {})
{
  let similar = panel.querySelector('.bv-dock-list-similar')
  if (!similar) {
    similar = Utilities.makeEl('details', {
      class: 'bv-dock-list-similar',
      children: [
        Utilities.makeEl('summary', {
          attrs: {'data-bv-similar-label': options.label ?? 'Similar tags'},
          children: Utilities.makeEl('h3', {class: 'bv-dock-list-section-title'}),
        }),
        Utilities.makeEl('div', {class: 'bv-dock-list-similar-body'}),
      ],
    })
    let groups = panel.querySelector('.bv-dock-list-groups')
    if (groups) {
      groups.insertAdjacentElement('afterend', similar)
    } else {
      panel.querySelector('.bv-dock-slide-panel-body')?.append(similar)
    }
  }
  if (options.similarOpen === true || options.open === true) {
    similar.open = true
  } else if (options.preserveOpen !== true) {
    similar.open = false
  }
  let summaryOptions = {label: options.label ?? 'Similar tags'}
  if (Object.prototype.hasOwnProperty.call(options, 'count')) {
    summaryOptions.count = options.count
  }
  BrazenViewLayer._setTagDiscoverySimilarSummary(similar, summaryOptions)
  similar.querySelector('.bv-dock-list-similar-body')?.replaceChildren(
      Utilities.makeEl('p', {class: 'bv-dock-list-similar-status', text: options.status ?? 'Loading…'}),
  )
}

/**
 * Set status text inside the Similar tags body (loading / empty).
 *
 * @param {HTMLElement} panel
 * @param {string} status
 * @param {{count?: number|null}} [options]
 */
static setTagDiscoverySimilarStatus(panel, status, options = {})
{
  let body = panel.querySelector('.bv-dock-list-similar-body')
  if (!body) {
    return
  }
  if (Object.prototype.hasOwnProperty.call(options, 'count')) {
    BrazenViewLayer._setTagDiscoverySimilarSummary(panel, {count: options.count})
  }
  body.replaceChildren(Utilities.makeEl('p', {class: 'bv-dock-list-similar-status', text: status}))
}

/**
 * Fill Similar tags body without re-rendering New/Known groups.
 *
 * @param {HTMLElement} 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}>, getGroupKey?: function({name: string, type: string|null}): string, getRowClass?: function({name: string, type: string|null}): string, onTagNameClick?: function({name: string, type: string|null, count?: number|null}): void, emptyStatus?: string, similarOpen?: boolean, similarShowCount?: boolean}} [options]
 */
static renderTagDiscoverySimilarContent(panel, tags, renderActions, options = {})
{
  options = BrazenViewLayer.prepareTagDiscoveryPanelOptions(options)
  if (options.tagTypes) {
    BrazenViewLayer.applyTagDiscoveryTypeColors(options.tagTypes)
  }
  BrazenViewLayer._bindTagDiscoveryNameClicks(panel, options.onTagNameClick)
  let similar = panel.querySelector('.bv-dock-list-similar')
  let body = similar?.querySelector('.bv-dock-list-similar-body')
  if (!body) {
    return
  }
  // Replacing body nodes mid-click can make the browser toggle <details>; restore open.
  let wasOpen = !!similar.open
  let similarTags = Array.isArray(tags) ? tags : []
  if (options.similarShowCount === false) {
    BrazenViewLayer._setTagDiscoverySimilarSummary(panel, {count: null})
  } else {
    BrazenViewLayer._setTagDiscoverySimilarSummary(panel, {count: similarTags.length})
  }
  if (!similarTags.length) {
    body.replaceChildren(
        Utilities.makeEl('p', {class: 'bv-dock-list-similar-status', text: options.emptyStatus ?? 'No similar tags.'}),
    )
    similar.open = options.similarOpen === true ? true : wasOpen
    return
  }
  let getRowClass = options.getRowClass ?? (() => '')
  let nameClickable = typeof options.onTagNameClick === 'function'
  let list = Utilities.makeEl('ul', {class: 'bv-dock-list'})
  for (let tag of similarTags) {
    list.append(BrazenViewLayer._createTagDiscoveryRow(tag, renderActions, getRowClass, nameClickable))
  }
  body.replaceChildren(list)
  similar.open = options.similarOpen === true ? true : wasOpen
}

/**
 * @param {string|null|undefined} baseLabel
 * @param {number|null|undefined} count
 * @return {string}
 * @private
 */
static _formatTagDiscoverySimilarLabel(baseLabel, count)
{
  let label = baseLabel || 'Similar tags'
  if (count == null) {
    return label
  }
  return `${label} (${count})`
}

/**
 * @param {HTMLElement} panelOrSimilar
 * @param {{label?: string, count?: number|null}} [options]
 * @private
 */
static _setTagDiscoverySimilarSummary(panelOrSimilar, options = {})
{
  let root = panelOrSimilar
  let summary = root.matches?.('details.bv-dock-list-similar')
      ? root.querySelector(':scope > summary')
      : root.querySelector('.bv-dock-list-similar > summary')
  if (!summary) {
    return
  }
  let base = options.label
  if (base != null && base !== '') {
    summary.setAttribute('data-bv-similar-label', base)
  } else {
    base = summary.getAttribute('data-bv-similar-label') || 'Similar tags'
  }
  if (Object.prototype.hasOwnProperty.call(options, 'count')) {
    if (options.count == null) {
      summary.removeAttribute('data-bv-similar-count')
    } else {
      summary.setAttribute('data-bv-similar-count', String(options.count))
    }
  }
  let countAttr = summary.getAttribute('data-bv-similar-count')
  let count = countAttr != null && countAttr !== '' ? Number(countAttr) : null
  let title = summary.querySelector(':scope > .bv-dock-list-section-title')
  if (!title) {
    title = Utilities.makeEl('h3', {class: 'bv-dock-list-section-title'})
    summary.replaceChildren(title)
  }
  title.textContent = BrazenViewLayer._formatTagDiscoverySimilarLabel(base, count)
}

/**
 * Single delegated click handler for discovery tag names (avoids per-row listeners).
 *
 * @param {HTMLElement} panel
 * @param {function({name: string, type: string|null, count?: number|null}): void|null|undefined} onTagNameClick
 * @private
 */
static _bindTagDiscoveryNameClicks(panel, onTagNameClick)
{
  if (panel._bvTagDiscoveryNameClick) {
    panel.removeEventListener('click', panel._bvTagDiscoveryNameClick)
    delete panel._bvTagDiscoveryNameClick
  }
  if (typeof onTagNameClick !== 'function') {
    return
  }
  panel._bvTagDiscoveryNameClick = (event) => {
    let el = event.target.closest('.bv-dock-list-name-link')
    if (!el || !panel.contains(el)) {
      return
    }
    event.preventDefault()
    event.stopPropagation()
    let countAttr = el.getAttribute('data-tag-count')
    onTagNameClick({
      name: el.getAttribute('data-tag-name') ?? '',
      type: el.getAttribute('data-tag-type') || null,
      count: countAttr != null && countAttr !== '' ? Number(countAttr) : null,
    })
  }
  panel.addEventListener('click', panel._bvTagDiscoveryNameClick)
}

/**
 * @param {{name: string, type: string|null, count?: number|null}} tag
 * @param {function(HTMLElement, {name: string, type: string|null, count?: number|null}, HTMLElement): void} renderActions
 * @param {function({name: string, type: string|null}): string} getRowClass
 * @param {boolean} nameClickable
 * @return {HTMLElement}
 * @private
 */
static _createTagDiscoveryRow(tag, renderActions, getRowClass, nameClickable)
{
  let actions = Utilities.makeEl('span', {class: 'bv-tag-actions'})
  let row = Utilities.makeEl('li', {class: 'bv-dock-list-row'})
  let rowClass = getRowClass?.(tag) ?? ''
  if (rowClass) {
    row.classList.add(...rowClass.split(/\s+/).filter(Boolean))
  }
  let nameAttrs = {}
  if (nameClickable) {
    nameAttrs.class = 'bv-dock-list-name bv-dock-list-name-link'
    nameAttrs['data-tag-name'] = tag.name ?? ''
    nameAttrs['data-tag-type'] = tag.type ?? ''
    if (tag.count != null && tag.count !== '') {
      nameAttrs['data-tag-count'] = String(tag.count)
    }
  } else {
    nameAttrs.class = 'bv-dock-list-name'
  }
  row.append(Utilities.makeEl('span', {attrs: nameAttrs, text: tag.name}))
  if (tag.count != null && tag.count !== '') {
    row.append(Utilities.makeEl('span', {class: 'bv-dock-list-meta', text: String(tag.count)}))
  }
  row.append(actions)
  renderActions?.(row, tag, actions)
  return row
}

/**
 * @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 {HTMLElement}
 */
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 Utilities.makeEl('button', {
    class: options.className ?? 'bv-tag-action-btn',
    attrs: {
      type: 'button',
      title: titles[state] ?? titles.idle,
    },
    children: BrazenViewLayer.createTagSubstitutionIcon(state, options.iconClass ?? 'bv-tag-action-icon'),
    on: {
      click: (event) => {
        event.preventDefault()
        event.stopPropagation()
        options.onClick?.(event)
      },
    },
  })
}

/**
 * Plus (+) icon for add actions.
 * @param {string} [iconClass='bv-substitution-add-icon']
 * @return {SVGSVGElement}
 */
static createPlusIcon(iconClass = 'bv-substitution-add-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 path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
  path.setAttribute('d', 'M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5z')
  path.setAttribute('fill', 'currentColor')
  svg.append(path)
  return svg
}

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

/**
 * Flip the bookmark star on an existing tag-action button without rebuilding the row.
 * @param {HTMLElement} button
 * @param {boolean} filled
 * @param {string} [iconClass='bv-tag-action-icon']
 */
static setTagBookmarkStarFilled(button, filled, iconClass = 'bv-tag-action-icon')
{
  if (!button) {
    return
  }
  let icon = button.querySelector('svg')
  if (icon) {
    icon.replaceWith(BrazenViewLayer.createTagBookmarkStarIcon(filled, iconClass))
  } else {
    button.replaceChildren(BrazenViewLayer.createTagBookmarkStarIcon(filled, iconClass))
  }
}

/**
 * Flip the filename-ignore Aa icon on an existing tag-action button without rebuilding the row.
 * @param {HTMLElement} button
 * @param {boolean} active
 * @param {string} [iconClass='bv-tag-action-icon']
 */
static setTagIgnoreIconActive(button, active, iconClass = 'bv-tag-action-icon')
{
  if (!button) {
    return
  }
  let icon = button.querySelector('svg')
  if (icon) {
    icon.replaceWith(BrazenViewLayer.createTagIgnoreIcon(active, iconClass))
  } else {
    button.replaceChildren(BrazenViewLayer.createTagIgnoreIcon(active, iconClass))
  }
}

/**
 * @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, onClick: function(MouseEvent): void, className?: string}} options
 * @return {HTMLElement}
 */
static createTagAttributeActionButton(options)
{
  return Utilities.makeEl('button', {
    class: options.className ?? 'bv-tag-action-btn',
    attrs: {type: 'button', title: options.title ?? ''},
    children: options.icon ?? null,
    on: {
      click: (event) => {
        event.preventDefault()
        event.stopPropagation()
        options.onClick?.(event)
      },
    },
  })
}

}