Brazen Framework - View Layer

View layer for the Brazen userscripts framework

Este script não deve ser instalado diretamente. Este script é uma biblioteca de outros scripts para incluir com o diretório meta // @require https://update.sleazyfork.org/scripts/416104/1901970/Brazen%20Framework%20-%20View%20Layer.js

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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)
      },
    },
  })
}

}