Brazen Framework - View Layer

View layer for the Brazen userscripts framework

Ce script ne devrait pas être installé directement. C'est une librairie créée pour d'autres scripts. Elle doit être inclus avec la commande // @require https://update.sleazyfork.org/scripts/416104/1881858/Brazen%20Framework%20-%20View%20Layer.js

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         Brazen Framework - View Layer
// @namespace    brazenvoid
// @version      3.10.1
// @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-3.10.1'

/**
 * @function GM_addStyle
 * @param {string} style
 */
GM_addStyle(
  `@keyframes brazen-fade{from{opacity:0}to{opacity:1}}#bv-resizer{align-items:center;background-color:white;border-radius:3px;box-shadow:3px 3px 10px rgba(0,0,0,0.5);cursor:ew-resize;display:flex;height:18px;justify-content:center;position:absolute;right:10px;transition:background 0.3s ease;width:25px;z-index:5}#bv-resizer:hover{background-color:lightgrey}#bv-resizer-icon{color:black;font-size:100%;pointer-events:none}#bv-ui{backdrop-filter:blur(10px);background:rgba(255,255,255,0.1);border-bottom-right-radius:8px;border-left:0;border-top-right-radius:8px;bottom:5vh;box-shadow:0 2px 8px rgba(0,0,0,0.5);box-sizing:border-box;opacity:0.3;overflow:auto;top:5vh;z-index:1001}#bv-ui:hover{opacity:1}#restore-settings.bv-input{margin-bottom:1rem}.bv-actions{display:inline-flex;justify-content:center;padding:0 0.25rem;text-align:center}.bv-actions .bv-button{width:auto}.bv-bg-colour{background-color:#4f535b}.bv-border-primary{border:1px solid black}.bv-break{margin:0.5rem 0}.bv-button{background-color:revert;padding:0.5rem 1rem;width:100%}.bv-flex-column{flex-direction:column}.bv-font-primary{color:white}.bv-font-secondary{color:black}.bv-group{align-items:center;display:flex;min-height:20px}.bv-group + .bv-group{margin-top:1rem}.bv-group.bv-range-group,.bv-group.bv-text-group{align-items:center}.bv-group.bv-range-group > input{width:75px}.bv-group.bv-range-group > input + input{margin-left:5px}.bv-group.bv-textarea-group{align-items:start;flex-direction:column;overflow:hidden}.bv-group.bv-textarea-group > textarea.bv-input{margin-top:0.5rem;resize:vertical;width:100%}.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-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-bookmark-link),#bv-ui :is(button,input,select,textarea,.bv-button,.bv-tab-button,.bv-label,.bv-title,.bv-token-btn,.bv-bookmark-link),.bv-modal-dialog :is(button,input,select,textarea,.bv-button,.bv-modal-title,h3,p,span){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-bookmarks-group{display:flex;flex-direction:column;gap:0.25rem;min-width:0;width:100%}.bv-bookmark-columns{column-gap:0.25rem;display:grid;grid-template-columns:minmax(0,1fr) var(--bv-bookmark-action-size,1.5rem);min-width:0;row-gap:0.25rem;scrollbar-gutter:stable;width:100%}.bv-bookmark-toolbar{align-items:center;display:grid;grid-column:1 / -1;grid-template-columns:subgrid}.bv-bookmark-scroll{display:grid;grid-auto-rows:min-content;grid-column:1 / -1;grid-template-columns:subgrid;max-height:14rem;overflow-y:auto;row-gap:0.25rem}.bv-bookmark-list{display:contents}.bv-bookmark-row{align-items:center;display:grid;grid-column:1 / -1;grid-template-columns:subgrid}.bv-bookmark-search{box-sizing:border-box;grid-column:1;height:var(--bv-bookmark-action-size,1.5rem);margin-top:0;min-height:var(--bv-bookmark-action-size,1.5rem);min-width:0;width:auto}.bv-bookmark-search.bv-input.bv-text{width:auto}.bv-bookmark-sort{box-sizing:border-box;grid-column:2;height:var(--bv-bookmark-action-size,1.5rem);margin:0;min-height:var(--bv-bookmark-action-size,1.5rem);min-width:0;padding:0.25rem 0.5rem;width:var(--bv-bookmark-action-size,1.5rem)}.bv-bookmark-main{align-items:center;display:flex;gap:0.2rem;grid-column:1;min-width:0}.bv-bookmark-main > .bv-bookmark-link{flex:1 1 auto}.bv-bookmark-action{align-items:center;box-sizing:border-box;display:inline-flex;flex:0 0 auto;height:var(--bv-bookmark-action-size,1.5rem);justify-content:center;margin:0;min-height:var(--bv-bookmark-action-size,1.5rem);min-width:0;padding:0;width:var(--bv-bookmark-action-size,1.5rem)}.bv-bookmark-link{box-sizing:border-box;display:block;height:var(--bv-bookmark-action-size,1.5rem);line-height:var(--bv-bookmark-action-size,1.5rem);margin:0;min-height:var(--bv-bookmark-action-size,1.5rem);min-width:0;overflow:hidden;padding:0 0.38rem;text-align:left;text-overflow:ellipsis;white-space:nowrap;width:auto}.bv-bookmark-remove{align-items:center;box-sizing:border-box;display:inline-flex;grid-column:2;height:var(--bv-bookmark-action-size,1.5rem);justify-content:center;line-height:1;margin:0;min-height:var(--bv-bookmark-action-size,1.5rem);min-width:0;padding:0.25rem 0.5rem;width:var(--bv-bookmark-action-size,1.5rem)}.bv-bookmark-empty{font-size:0.85rem;grid-column:1 / -1;margin-top:0.15rem}.bv-bookmark-toolbar button + button,.bv-bookmark-row button + button{margin-left:0}@supports not (grid-template-columns:subgrid){.bv-bookmark-columns{scrollbar-gutter:auto}.bv-bookmark-toolbar,.bv-bookmark-row{column-gap:0.25rem;display:grid;grid-column:1 / -1;grid-template-columns:minmax(0,1fr) var(--bv-bookmark-action-size,1.5rem)}.bv-bookmark-scroll{display:block}.bv-bookmark-list{display:flex;flex-direction:column;gap:0.25rem}.bv-bookmark-toolbar{box-sizing:border-box;padding-inline-end:var(--bv-bookmark-scroll-inset,0px)}}.bv-modal-overlay{align-items:center;background:rgba(0,0,0,0.55);display:flex;inset:0;justify-content:center;position:fixed;z-index:10002}.bv-modal-dialog{background:#4f535b;border:1px solid black;border-radius:4px;box-shadow:0 4px 20px rgba(0,0,0,0.5);color:white;max-height:80vh;max-width:90vw;min-width:280px;overflow:auto;padding:1rem}.bv-modal-header{align-items:center;display:flex;gap:0.5rem;justify-content:space-between;margin-bottom:0.75rem}.bv-modal-title{font-size:1rem;font-weight:500;margin:0}.bv-modal-close{padding:0.25rem 0.5rem;width:auto}.bv-modal-dialog .bv-button{background-color:#3a3f47;border:1px solid #222;color:#fff;cursor:pointer;width:auto}.bv-modal-dialog .bv-button:hover{background-color:#4a505a}.bv-modal-body ul:not(.bv-dock-list){margin:0 0 1rem;padding-left:1.25rem}.bv-modal-body h3{font-size:0.9rem;margin:0 0 0.35rem}.bv-dock-slide-panel{box-sizing:border-box;display:flex;flex-direction:column;position:fixed;z-index:1001}.bv-dock-slide-panel-head{align-items:center;display:flex;flex-shrink:0;gap:0.5rem;justify-content:space-between;margin-bottom:0.35rem}.bv-dock-slide-panel-title{font-size:0.85rem;font-weight:500;margin:0}.bv-dock-slide-panel-close{padding:0.2rem 0.45rem;width:auto}.bv-dock-slide-panel-body{flex:1 1 auto;min-height:0;overflow:auto}.bv-dock-slide-panel.bv-dock-panel-left{border-bottom-left-radius:0;border-top-left-radius:0}.bv-dock-slide-panel.bv-dock-panel-right{border-bottom-right-radius:0;border-top-right-radius:0}.bv-dock-slide-panel.bv-dock-panel-bottom{border-bottom-left-radius:0;border-bottom-right-radius:0;box-shadow:0 6px 16px rgba(0,0,0,0.4)}.bv-dock-slide-panel.bv-dock-panel-animate{transition:transform 0.25s ease,opacity 0.25s ease}.bv-dock-slide-panel.bv-dock-panel-offscreen{pointer-events:none}.bv-dock-slide-panel.bv-dock-panel-hidden{display:none !important}#bv-idb-blocked-banner{background:#6b2d2d;color:#fff;font:13px/1.35 sans-serif;left:0;padding:8px 12px;position:fixed;right:0;text-align:center;top:0;z-index:10001}.bv-tag-actions-slot{display:inline-flex;position:relative}.bv-tag-actions-slot .tag-count[hidden]{display:none}.bv-tag-actions{align-items:center;display:inline-flex;gap:0.15rem}.bv-tag-actions[hidden]{display:none}/* Shared dock-slide list chrome (tag discovery,compliance rules,human interaction) */ .bv-dock-list-intro{font-size:0.78rem;line-height:1.35;margin:0 0 0.5rem}.bv-dock-list-groups{display:flex;flex-direction:column;gap:0.35rem}.bv-dock-list-group h3{color:#e8efe8;font-size:0.72rem;font-weight:500;letter-spacing:0.04em;margin:0.35rem 0 0.2rem;text-transform:uppercase}.bv-dock-list-group:first-child h3{margin-top:0}.bv-dock-list{list-style:none;margin:0;padding:0}.bv-dock-list-row{align-items:center;display:flex;gap:0.35rem;justify-content:space-between;margin-bottom:0.22rem;min-height:1.35em}.bv-dock-list-row.bv-tag-muted .bv-dock-list-name,.bv-dock-list-row.r34-tag-muted .bv-dock-list-name{color:#8a8a8a}.bv-dock-list-row.bv-tag-hidden .bv-dock-list-name,.bv-dock-list-row.r34-tag-hidden .bv-dock-list-name{color:#865050}.bv-dock-list-name{flex:1 1 auto;font-size:0.8125rem;line-height:1.3;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bv-dock-list-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}/* Button dock */ .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)}`
)

GM_addStyle(
  `.bv-dock-slide-panel.bv-dock-panel-bottom-half-left,.bv-dock-slide-panel.bv-dock-panel-bottom-half-right,#bv-ui.bv-dock-panel-bottom-half-left{box-sizing:border-box;overflow:auto}#bv-ui.bv-dock-panel-bottom-half-left{opacity:1}`
)

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

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

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

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

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

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

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

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

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

  switch (name) {
    case 'menu':
      path.setAttribute('d', 'M4 7h16M4 12h16M4 17h16')
      break
    case 'bookmark':
      path.setAttribute('d', 'M6 4h12v16l-6-4-6 4V4z')
      break
    case 'download':
      path.setAttribute('d', 'M12 4v10m0 0l4-4m-4 4l-4-4M5 20h14')
      break
    case 'next':
      path.setAttribute('d', 'M6 7l5 5-5 5M13 7l5 5-5 5')
      break
    case 'explore':
      svg.innerHTML = ''
      let ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
      ring.setAttribute('cx', '12')
      ring.setAttribute('cy', '12')
      ring.setAttribute('r', '8')
      ring.setAttribute('fill', 'none')
      ring.setAttribute('stroke', 'currentColor')
      ring.setAttribute('stroke-width', '1.5')
      let needle = document.createElementNS('http://www.w3.org/2000/svg', 'path')
      needle.setAttribute('d', 'M12 6.5L14.5 12L12 17.5L9.5 12Z')
      needle.setAttribute('fill', 'none')
      needle.setAttribute('stroke', 'currentColor')
      needle.setAttribute('stroke-width', '1.5')
      needle.setAttribute('stroke-linejoin', 'round')
      svg.append(ring, needle)
      return svg
    case 'clear':
      path.setAttribute('d', 'M4 7h16M6 7l1 14h10l1-14M9 7V5h6v2')
      break
    case 'filter':
      path.setAttribute('d', 'M4 6h16M7 12h10M10 18h4')
      break
    case 'tags':
      path.setAttribute('d', 'M7 7h.01M4 6l8.5 8.5a2 2 0 0 0 2.8 0l5.2-5.2a2 2 0 0 0 0-2.8L12 2 4 10a2 2 0 0 0 0 2.8z')
      break
    case 'inject':
      path.setAttribute('d', 'M11 6H4M11 10H4M11 14H7M18 9v6m-3-3h6')
      break
    case 'block':
      path.setAttribute('d', 'M6 6l12 12M12 3a9 9 0 1 1 0 18 9 9 0 0 1 0-18')
      break
    case 'resolution':
      path.setAttribute('d', 'M5 6h14v12H5z M8 10h8M8 14h5')
      break
    case 'rules':
      path.setAttribute('d', 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01')
      break
    case 'history':
      path.setAttribute('d', 'M12 8v5l3 2M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z')
      break
    case 'eye':
      path.setAttribute('d', 'M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12z')
      svg.append(path)
      let pupil = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
      pupil.setAttribute('cx', '12')
      pupil.setAttribute('cy', '12')
      pupil.setAttribute('r', '2.25')
      pupil.setAttribute('fill', 'none')
      pupil.setAttribute('stroke', 'currentColor')
      pupil.setAttribute('stroke-width', '1.75')
      svg.append(pupil)
      return svg
    case 'eye-off':
      svg.innerHTML = ''
      let eyeOffOutline = document.createElementNS('http://www.w3.org/2000/svg', 'path')
      eyeOffOutline.setAttribute('d', 'M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12z')
      eyeOffOutline.setAttribute('fill', 'none')
      eyeOffOutline.setAttribute('stroke', 'currentColor')
      eyeOffOutline.setAttribute('stroke-width', '1.75')
      eyeOffOutline.setAttribute('stroke-linecap', 'round')
      eyeOffOutline.setAttribute('stroke-linejoin', 'round')
      let eyeOffPupil = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
      eyeOffPupil.setAttribute('cx', '12')
      eyeOffPupil.setAttribute('cy', '12')
      eyeOffPupil.setAttribute('r', '2.25')
      eyeOffPupil.setAttribute('fill', 'none')
      eyeOffPupil.setAttribute('stroke', 'currentColor')
      eyeOffPupil.setAttribute('stroke-width', '1.75')
      let eyeOffSlash = document.createElementNS('http://www.w3.org/2000/svg', 'path')
      eyeOffSlash.setAttribute('d', 'M4 4l16 16')
      eyeOffSlash.setAttribute('fill', 'none')
      eyeOffSlash.setAttribute('stroke', 'currentColor')
      eyeOffSlash.setAttribute('stroke-width', '1.75')
      eyeOffSlash.setAttribute('stroke-linecap', 'round')
      svg.append(eyeOffOutline, eyeOffPupil, eyeOffSlash)
      return svg
    case 'skip-duplicate':
      // Overlapping sheets (duplicate media) — distinct from tag-blacklist `block`.
      path.setAttribute('d', 'M9 5h8v11H9zM7 8h8v11H7z')
      break
    case 'target':
      path.setAttribute('d', 'M12 3v3m0 12v3m9-9h-3M6 12H3m14.3-5.3l-2.1 2.1M8.8 15.2l-2.1 2.1m12.6 0l-2.1-2.1M8.8 8.8L6.7 6.7')
      break
    case 'play':
      path.setAttribute('d', 'M8 6v12l11-6-11-6z')
      path.setAttribute('fill', 'currentColor')
      path.setAttribute('stroke', 'none')
      svg.append(path)
      return svg
    case 'pause':
      path.setAttribute('d', 'M9 7v10M15 7v10')
      break
    case 'queue-add': {
      // 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 '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 = 'IndexedDB is unavailable — persistence is disabled for this session.'
  document.body.prepend(banner)
  return banner
}

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

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

/**
 * Stack visible dock slide panels from the rail outward.
 * Callers pass panels in near-dock → outward order (e.g. discovery before `#bv-ui`).
 * Bottom dock: when settings (`#bv-ui`) and a review panel are both open, place them
 * side-by-side in screen halves hugging the centerline instead of stacking vertically.
 *
 * @param {HTMLElement} dockElement
 * @param {string} orientation
 * @param {HTMLElement[]} panels
 */
static syncDockSlidePanelStack(dockElement, orientation, panels)
{
  if (!dockElement?.getBoundingClientRect) {
    return
  }
  let dockRect = dockElement.getBoundingClientRect()
  let list = (panels ?? []).filter(Boolean)
  if (orientation === 'bottom') {
    BrazenViewLayer._syncBottomDockSlidePanelStack(dockRect, list)
    return
  }
  for (let panel of list) {
    BrazenViewLayer._clearBottomHalfPanelLayout(panel)
  }
  let anchorRect = dockRect
  for (let panel of list) {
    BrazenViewLayer._syncDockSlidePanelPosition(panel, dockRect, orientation, anchorRect)
    if (BrazenViewLayer.isDockSlidePanelVisible(panel)) {
      anchorRect = panel.getBoundingClientRect()
    }
  }
}

/**
 * @param {HTMLElement} panel
 * @private
 */
static _clearBottomHalfPanelLayout(panel)
{
  if (!panel) {
    return
  }
  panel.classList.remove('bv-dock-panel-bottom-half-left', 'bv-dock-panel-bottom-half-right')
  panel.style.maxWidth = ''
  panel.style.width = ''
  panel.style.minWidth = ''
  panel.style.maxHeight = ''
}

/**
 * Bottom dock: dual-open settings + review → left/right halves; otherwise vertical stack.
 * @param {DOMRect} dockRect
 * @param {HTMLElement[]} panels
 * @private
 */
static _syncBottomDockSlidePanelStack(dockRect, panels)
{
  let visible = panels.filter((panel) => BrazenViewLayer.isDockSlidePanelVisible(panel))
  let settings = visible.find((panel) => panel.id === 'bv-ui') ?? null
  let reviews = visible.filter((panel) => panel.id !== 'bv-ui')
  let dual = !!(settings && reviews.length)

  if (!dual) {
    for (let panel of panels) {
      BrazenViewLayer._clearBottomHalfPanelLayout(panel)
    }
    let anchorRect = dockRect
    for (let panel of panels) {
      BrazenViewLayer._syncDockSlidePanelPosition(panel, dockRect, 'bottom', anchorRect)
      if (BrazenViewLayer.isDockSlidePanelVisible(panel)) {
        anchorRect = panel.getBoundingClientRect()
      }
    }
    return
  }

  let gap = 8
  let halfMax = Math.max(160, (window.innerWidth / 2) - gap)
  let maxHeight = Math.max(160, dockRect.top - gap)
  let dockBottom = window.innerHeight - dockRect.top

  let reviewAnchorTop = dockRect.top
  for (let panel of reviews) {
    panel.classList.add('bv-dock-panel-bottom-half-right')
    panel.classList.remove('bv-dock-panel-bottom-half-left')
    panel.style.left = `calc(50% + ${gap / 2}px)`
    panel.style.right = 'auto'
    panel.style.top = 'auto'
    panel.style.bottom = (window.innerHeight - reviewAnchorTop) + 'px'
    panel.style.minWidth = '0'
    panel.style.maxWidth = halfMax + 'px'
    panel.style.width = halfMax + 'px'
    panel.style.maxHeight = maxHeight + 'px'
    BrazenViewLayer._applyDockSlidePanelTransform(
        panel,
        'bottom-half-right',
        panel.classList.contains('bv-dock-panel-offscreen'),
    )
    reviewAnchorTop = panel.getBoundingClientRect().top
  }

  settings.classList.add('bv-dock-panel-bottom-half-left')
  settings.classList.remove('bv-dock-panel-bottom-half-right')
  settings.style.left = 'auto'
  settings.style.right = `calc(50% + ${gap / 2}px)`
  settings.style.top = 'auto'
  settings.style.bottom = dockBottom + 'px'
  settings.style.minWidth = '0'
  settings.style.maxWidth = halfMax + 'px'
  settings.style.width = halfMax + 'px'
  settings.style.maxHeight = maxHeight + 'px'
  BrazenViewLayer._applyDockSlidePanelTransform(
      settings,
      'bottom-half-left',
      settings.classList.contains('bv-dock-panel-offscreen'),
  )

  for (let panel of panels) {
    if (BrazenViewLayer.isDockSlidePanelVisible(panel)) {
      continue
    }
    BrazenViewLayer._clearBottomHalfPanelLayout(panel)
    BrazenViewLayer._syncDockSlidePanelPosition(panel, dockRect, 'bottom', dockRect)
  }
}

/**
 * @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)
  let transformOrientation = BrazenViewLayer._resolveDockSlideTransformOrientation(panel, orientation)
  if (BrazenViewLayer.isDockSlidePanelVisible(panel)) {
    BrazenViewLayer._applyDockSlidePanelTransform(panel, transformOrientation, false)
    beforeAnimate?.()
    return
  }
  // Already in the open layout (e.g. mid hide/show) — finish on-screen without restarting
  // the off→on pulse (avoids stacked open animations / delayed jerks).
  let prepared = panel.style.display === 'flex' && !panel.classList.contains('bv-dock-panel-hidden')
  panel.classList.remove('bv-dock-panel-hidden')
  panel.style.display = 'flex'
  if (prepared) {
    panel.classList.remove('bv-dock-panel-offscreen')
    BrazenViewLayer._applyDockSlidePanelTransform(panel, transformOrientation, false)
    beforeAnimate?.()
    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')
  $('.bv-dock-main-ui-btn').toggleClass('bv-dock-btn-active', !!(mainPanel && BrazenViewLayer.isDockSlidePanelVisible(mainPanel)))
  $('.bv-dock-compliance-rules-btn').toggleClass('bv-dock-btn-active', !!(compliancePanel && BrazenViewLayer.isDockSlidePanelVisible(compliancePanel)))
}

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

/**
 * @param {HTMLElement|null|undefined} element
 * @param {{label: string, stepIndex: number, stepCount: number, failed?: boolean, complete?: boolean}|null} progress
 */
static updateDownloadManagerItemProgress(element, progress)
{
  if (!element) {
    return
  }
  if (!progress?.label || progress.stepCount == null || progress.stepIndex == null) {
    BrazenViewLayer.clearDownloadManagerItemProgress(element)
    return
  }
  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 {JQuery}
 */
static createDownloadManagerProgressSlot()
{
  return $('<div class="bv-dock-progress-panel">').attr(
    'title',
    'Download queue: primary bar and completed/total. Resolution queue: secondary bar (below on bottom dock; beside counters on left/right).'
  ).append(
    $('<div class="bv-dock-progress-slot">').append([
      $('<div class="bv-dock-progress-bar bv-dock-progress-download">').append($('<span>')),
      $('<div class="bv-dock-progress-label-wrap">').append($('<span class="bv-dock-progress-label">')),
      $('<div class="bv-dock-progress-bar bv-dock-progress-resolution">').append($('<span>')),
    ]),
  )
}

/**
 * @param {JQuery} slot
 * @param {{resolution: {current: number, total: number}, download: {current: number, total: number}}} progress
 */
static updateDownloadManagerProgressSlot(slot, progress)
{
  let download = progress.download ?? {current: 0, total: 0}
  let resolution = progress.resolution ?? {current: 0, total: 0}
  let vertical = slot.closest('.bv-dock-left, .bv-dock-right').length > 0
  // 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) => {
    let ratio = total > 0 ? Math.min(1, Math.max(0, current / total)) : 0
    if (fillVertical) {
      fill.css({
        width: '100%',
        height: '100%',
        transform: `scaleY(${ratio})`,
        transformOrigin: 'bottom center',
      })
    } else {
      fill.css({
        width: '100%',
        height: '100%',
        transform: `scaleX(${ratio})`,
        transformOrigin: 'left center',
      })
    }
  }
  setFill(slot.find('.bv-dock-progress-download > span'), download.current, download.total, vertical)
  setFill(slot.find('.bv-dock-progress-resolution > span'), resolution.current, resolution.total, vertical)
  slot.find('.bv-dock-progress-label').text(`${download.current}/${download.total}`)
}

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

/**
 * Prefer bottom half-panel transform when dual-open classes are set.
 * @param {HTMLElement} panel
 * @param {string} orientation
 * @return {string}
 * @private
 */
static _resolveDockSlideTransformOrientation(panel, orientation)
{
  if (orientation !== 'bottom') {
    return orientation
  }
  if (panel?.classList.contains('bv-dock-panel-bottom-half-left')) {
    return 'bottom-half-left'
  }
  if (panel?.classList.contains('bv-dock-panel-bottom-half-right')) {
    return 'bottom-half-right'
  }
  return orientation
}

static _applyDockSlidePanelTransform(panel, orientation, offscreen)
{
  panel.classList.add('bv-dock-panel-animate')
  if (orientation === 'bottom-half-left' || orientation === 'bottom-half-right') {
    panel.style.transform = offscreen ? 'translateY(100%)' : 'translateY(0)'
    return
  }
  if (orientation === 'bottom') {
    panel.style.transform = offscreen ? 'translate(-50%, 100%)' : 'translateX(-50%)'
    return
  }
  if (orientation === 'right') {
    panel.style.transform = offscreen ? 'translateX(100%)' : 'translateX(0)'
    return
  }
  panel.style.transform = offscreen ? 'translateX(-100%)' : 'translateX(0)'
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

createFormRadiosGroupSection(label, keyValuePairs, hoverHelp)
{
  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 {JQuery}
 * @private
 */
_createFormMultiInputSection(label, keyValuePairs, hoverHelp, inputType, sectionClass)
{
  let section = this.createFormSection(label).addClass(sectionClass).attr('title', hoverHelp)
  for (let i = 0; i < keyValuePairs.length; i++) {
    let pair = keyValuePairs[i]
    let input = this.createFormGroupInput(inputType).attr('data-value', pair[1])
    if (inputType === 'radio') {
      input.prop('checked', i === 0).on('change', (event) => {
        $(event.currentTarget).parents('.' + sectionClass).first().find('input').each((index, element) => {
          if (!element.isSameNode(event.currentTarget)) {
            $(element).prop('checked', false)
          }
        })
      })
    }
    section.append(
        this.createFormGroup().append([
          this.createFormGroupLabel(pair[0], inputType),
          input,
        ]),
    )
  }
  return section
}

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

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

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

/**
 * Subject → Alias row used above a read-only substitution ruleset textarea.
 *
 * @param {{
 *   subjectPlaceholder?: string,
 *   aliasPlaceholder?: string,
 *   addHelpText?: string,
 *   onAdd?: function(string, string): void,
 * }} [options]
 * @return {JQuery}
 */
createFormSubstitutionComposer(options = {})
{
  let settings = {
    subjectPlaceholder: 'Subject',
    aliasPlaceholder: 'Alias',
    addHelpText: 'Add substitution',
    onAdd: () => {},
    ...options,
  }
  let subjectInput = this.createFormGroupInput('text').
      attr('placeholder', settings.subjectPlaceholder).
      attr('autocomplete', 'off').
      attr('spellcheck', 'false')
  let aliasInput = this.createFormGroupInput('text').
      attr('placeholder', settings.aliasPlaceholder).
      attr('autocomplete', 'off').
      attr('spellcheck', 'false')
  let submit = () => {
    let subject = String(subjectInput.val() ?? '').trim()
    let alias = String(aliasInput.val() ?? '').trim()
    if (!subject || !alias) {
      return
    }
    Utilities.callEventHandler(settings.onAdd, [subject, alias])
    subjectInput.val('')
    aliasInput.val('')
    subjectInput.trigger('focus')
  }
  subjectInput.on('keydown', (event) => {
    if (event.key === 'Enter') {
      event.preventDefault()
      aliasInput.trigger('focus')
    }
  })
  aliasInput.on('keydown', (event) => {
    if (event.key === 'Enter') {
      event.preventDefault()
      submit()
    }
  })
  let addButton = $('<button type="button" class="bv-button bv-substitution-add">').
      attr('title', settings.addHelpText).
      append(BrazenViewLayer.createPlusIcon('bv-substitution-add-icon')).
      on('click', (event) => {
        event.preventDefault()
        submit()
      })
  return $('<div class="bv-substitution-composer">').append([
    subjectInput,
    $('<span class="bv-substitution-arrow" aria-hidden="true">').text('→'),
    aliasInput,
    addButton,
  ])
}

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

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

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

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

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

  /** @type {ResizeObserver|null} */
  let bookmarkResizeObserver = null
  if (typeof ResizeObserver !== 'undefined') {
    bookmarkResizeObserver = new ResizeObserver(() => syncBookmarkColumnInset())
    bookmarkResizeObserver.observe(scrollEl[0])
  }

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

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

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

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

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

      syncBookmarkColumnInset()
      widget.checkPageMatch()
    },
  }

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

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

  return widget
}

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

        event.preventDefault()
        this._resizing = true

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

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

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

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

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

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

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

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

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

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

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

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

  let $sidebar = $(settings.sidebarSelector)
  if (!$sidebar.length) {
    return
  }

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

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

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

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

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

    if (!$actions.length) {
      return
    }

    let tag = settings.extractTag($row[0])
    $actions.empty()
    settings.renderActions($row[0], tag, $actions[0], {isBookmarked: settings.isBookmarked})
  })
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        $('#' + Utilities.toKebabCase(button.text())).addClass('bv-active')
      }).
      on('mouseenter', (event) => $(event.currentTarget).addClass('bv-font-secondary')).
      on('mouseleave', (event) => $(event.currentTarget).removeClass('bv-font-secondary'))

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

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

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

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

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

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

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

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

isSettingsPaneBeingResized()
{
  return this._resizing
}

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

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

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

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

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

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

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

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

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

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

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

  return {ok: true}
}

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

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

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

/**
 * Rate-limit human verification panel (processor tab). Does not auto-close on mouseleave.
 *
 * @param {{
 *   onConfirm?: Function,
 *   onReopen?: Function,
 *   title?: string,
 *   message?: string,
 *   confirmLabel?: string,
 *   reopenLabel?: string,
 *   showReopen?: boolean,
 * }} [options] `showReopen: false` builds a standalone reload pane (no open-challenge control).
 * @return {HTMLElement}
 */
static createHumanInteractionPanel(options = {})
{
  let showReopen = options.showReopen !== false
  let $panel = BrazenViewLayer.createDockSlidePanel(
      'bv-human-interaction-panel',
      options.title ?? 'Verification required',
  )
  $panel.find('.bv-dock-slide-panel-close').remove()
  // Open challenge first (primary); Done — resume second (secondary) so focus stays until ready.
  let footerButtons = []
  if (showReopen) {
    footerButtons.push(
        $('<button type="button" class="bv-button bv-human-interaction-reopen">').
            text(options.reopenLabel ?? 'Open Cloudflare Challenge').
            on('click', () => options.onReopen?.()),
    )
  }
  footerButtons.push(
      $('<button type="button" class="bv-button bv-human-interaction-confirm' +
          (showReopen ? ' bv-dock-list-footer-secondary' : '') + '">').
          text(options.confirmLabel ?? 'Done — resume').
          on('click', () => options.onConfirm?.()),
  )
  $panel.find('.bv-dock-slide-panel-body').empty().append(
      $('<p class="bv-dock-list-intro">').text(
          options.message ??
          'Click Open Cloudflare Challenge when ready, complete it, then Done — resume here.',
      ),
      $('<div class="bv-dock-list-footer">').append(footerButtons),
  )
  return $panel[0]
}

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

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

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

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

/**
 * @param {HTMLElement|JQuery} panel
 * @param {Array<{name: string, type: string|null, count?: number|null}>} tags
 * @param {function(HTMLElement, {name: string, type: string|null, count?: number|null}, HTMLElement): void} renderActions
 * @param {{tagTypes?: Record<string, {label?: string, rowClass?: string, color?: string}>, groupOrder?: string[], getGroupKey?: function({name: string, type: string|null}): string, getGroupLabel?: function(string): string, getRowClass?: function({name: string, type: string|null}): string, knownTags?: Array<{name: string, type: string|null, count?: number|null}>, unknownSectionLabel?: string, knownSectionLabel?: string, onTagNameClick?: function({name: string, type: string|null, count?: number|null}): void, similarSectionLabel?: string, resetSimilar?: boolean, preserveSimilarOpen?: boolean, clearSimilarCount?: boolean, intro?: string}} [options]
 */
static renderTagDiscoveryPanelContent(panel, tags, renderActions, options = {})
{
  options = BrazenViewLayer.prepareTagDiscoveryPanelOptions(options)
  if (options.tagTypes) {
    BrazenViewLayer.applyTagDiscoveryTypeColors(options.tagTypes)
  }
  let $panel = $(panel)
  if (options.intro != null && options.intro !== '') {
    $panel.find('.bv-dock-list-intro').text(options.intro)
  }
  let $groups = $panel.find('.bv-dock-list-groups').empty()
  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,
    }
    if (options.clearSimilarCount !== false) {
      resetOptions.count = null
    }
    BrazenViewLayer.resetTagDiscoverySimilarSection(panel, resetOptions)
  }
  BrazenViewLayer._bindTagDiscoveryNameClicks(panel, options.onTagNameClick)
  if (!$groups.length || (!unknownTags.length && !knownTags.length)) {
    return
  }

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

  let appendGroupedTags = (sectionLabel, sectionTags) => {
    if (!sectionTags?.length) {
      return
    }
    if (sectionLabel) {
      $groups.append($('<h3 class="bv-dock-list-section-title">').text(sectionLabel))
    }
    let buckets = new Map()
    for (let tag of sectionTags) {
      let key = getGroupKey(tag)
      if (!buckets.has(key)) {
        buckets.set(key, [])
      }
      buckets.get(key).push(tag)
    }
    let appendGroup = (key, groupTags) => {
      if (!groupTags?.length) {
        return
      }
      let $list = $('<ul class="bv-dock-list">')
      for (let tag of groupTags) {
        $list.append(BrazenViewLayer._createTagDiscoveryRow(tag, renderActions, getRowClass, nameClickable))
      }
      $groups.append(
          $('<section class="bv-dock-list-group">').append(
              $('<h3>').text(getGroupLabel(key)),
              $list,
          ),
      )
    }

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

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

/**
 * Reset the Similar tags block to status text (clears prior rows).
 *
 * @param {HTMLElement|JQuery} panel
 * @param {{label?: string, status?: string, count?: number|null, preserveOpen?: 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.
 */
static resetTagDiscoverySimilarSection(panel, options = {})
{
  let $panel = $(panel)
  let $similar = $panel.find('.bv-dock-list-similar')
  if (!$similar.length) {
    $similar = $('<details class="bv-dock-list-similar">').append(
        $('<summary>').attr('data-bv-similar-label', options.label ?? 'Similar tags').append(
            $('<h3 class="bv-dock-list-section-title">'),
        ),
        $('<div class="bv-dock-list-similar-body">'),
    )
    let $groups = $panel.find('.bv-dock-list-groups')
    if ($groups.length) {
      $similar.insertAfter($groups)
    } else {
      $panel.find('.bv-dock-slide-panel-body').append($similar)
    }
  }
  if (options.preserveOpen !== true) {
    $similar.prop('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.find('.bv-dock-list-similar-body').empty().append(
      $('<p class="bv-dock-list-similar-status">').text(options.status ?? 'Loading…'),
  )
}

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

/**
 * Fill Similar tags body without re-rendering New/Known groups.
 *
 * @param {HTMLElement|JQuery} panel
 * @param {Array<{name: string, type: string|null, count?: number|null}>} tags
 * @param {function(HTMLElement, {name: string, type: string|null, count?: number|null}, HTMLElement): void} renderActions
 * @param {{tagTypes?: Record<string, {label?: string, rowClass?: string, color?: string}>, 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}} [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).find('.bv-dock-list-similar')
  let $body = $similar.find('.bv-dock-list-similar-body')
  if (!$body.length) {
    return
  }
  // Replacing body nodes mid-click can make the browser toggle <details>; restore open.
  let wasOpen = !!$similar.prop('open')
  let similarTags = Array.isArray(tags) ? tags : []
  BrazenViewLayer._setTagDiscoverySimilarSummary(panel, {count: similarTags.length})
  if (!similarTags.length) {
    $body.empty().append(
        $('<p class="bv-dock-list-similar-status">').text(options.emptyStatus ?? 'No similar tags.'),
    )
    $similar.prop('open', wasOpen)
    return
  }
  let getRowClass = options.getRowClass ?? (() => '')
  let nameClickable = typeof options.onTagNameClick === 'function'
  let $list = $('<ul class="bv-dock-list">')
  for (let tag of similarTags) {
    $list.append(BrazenViewLayer._createTagDiscoveryRow(tag, renderActions, getRowClass, nameClickable))
  }
  $body.empty().append($list)
  $similar.prop('open', 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|JQuery} panelOrSimilar
 * @param {{label?: string, count?: number|null}} [options]
 * @private
 */
static _setTagDiscoverySimilarSummary(panelOrSimilar, options = {})
{
  let $root = $(panelOrSimilar)
  let $summary = $root.is('details.bv-dock-list-similar')
      ? $root.children('summary').first()
      : $root.find('.bv-dock-list-similar > summary').first()
  if (!$summary.length) {
    return
  }
  let base = options.label
  if (base != null && base !== '') {
    $summary.attr('data-bv-similar-label', base)
  } else {
    base = $summary.attr('data-bv-similar-label') || 'Similar tags'
  }
  if (Object.prototype.hasOwnProperty.call(options, 'count')) {
    if (options.count == null) {
      $summary.removeAttr('data-bv-similar-count')
    } else {
      $summary.attr('data-bv-similar-count', String(options.count))
    }
  }
  let countAttr = $summary.attr('data-bv-similar-count')
  let count = countAttr != null && countAttr !== '' ? Number(countAttr) : null
  let $title = $summary.children('.bv-dock-list-section-title').first()
  if (!$title.length) {
    $title = $('<h3 class="bv-dock-list-section-title">')
    $summary.empty().append($title)
  }
  $title.text(BrazenViewLayer._formatTagDiscoverySimilarLabel(base, count))
}

/**
 * Single delegated click handler for discovery tag names (avoids per-row listeners).
 *
 * @param {HTMLElement|JQuery} panel
 * @param {function({name: string, type: string|null, count?: number|null}): void|null|undefined} onTagNameClick
 * @private
 */
static _bindTagDiscoveryNameClicks(panel, onTagNameClick)
{
  let $panel = $(panel)
  $panel.off('click.bvTagDiscoveryName')
  if (typeof onTagNameClick !== 'function') {
    return
  }
  $panel.on('click.bvTagDiscoveryName', '.bv-dock-list-name-link', (event) => {
    event.preventDefault()
    event.stopPropagation()
    let el = event.currentTarget
    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,
    })
  })
}

/**
 * @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 {JQuery}
 * @private
 */
static _createTagDiscoveryRow(tag, renderActions, getRowClass, nameClickable)
{
  let $actions = $('<span class="bv-tag-actions">')
  let $row = $('<li class="bv-dock-list-row">')
  let rowClass = getRowClass?.(tag) ?? ''
  if (rowClass) {
    $row.addClass(rowClass)
  }
  let $name = $('<span class="bv-dock-list-name">').text(tag.name)
  if (nameClickable) {
    $name.
        addClass('bv-dock-list-name-link').
        attr('data-tag-name', tag.name ?? '').
        attr('data-tag-type', tag.type ?? '')
    if (tag.count != null && tag.count !== '') {
      $name.attr('data-tag-count', String(tag.count))
    }
  }
  $row.append($name)
  if (tag.count != null && tag.count !== '') {
    $row.append($('<span class="bv-dock-list-meta">').text(String(tag.count)))
  }
  $row.append($actions)
  renderActions?.($row[0], tag, $actions[0])
  return $row
}

/**
 * @param {HTMLElement|JQuery} row
 * @param {{name: string, type: string|null}} tag
 * @param {HTMLElement|JQuery} actionsElement
 * @param {{renderButtons: function(HTMLElement, {name: string, type: string|null}, HTMLElement, object): void, getRowState?: function({name: string, type: string|null}): {muted?: boolean, hidden?: boolean}}} config
 */
static renderTagActions(row, tag, actionsElement, config)
{
  let $row = $(row)
  let $actions = $(actionsElement).empty()
  config.renderButtons($row[0], tag, $actions[0], config.helpers ?? {})
  let state = config.getRowState?.(tag) ?? {}
  $row.toggleClass('bv-tag-muted', !!state.muted).toggleClass('bv-tag-hidden', !!state.hidden)
}

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

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

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

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

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

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

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

/**
 * @param {boolean} active
 * @param {string} [iconClass='bv-tag-action-icon']
 * @return {SVGSVGElement}
 */
static createTagExploreIcon(active, iconClass = 'bv-tag-action-icon')
{
  let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
  svg.setAttribute('viewBox', '0 4 24 17')
  svg.setAttribute('class', iconClass)
  svg.setAttribute('aria-hidden', 'true')
  let ring = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
  ring.setAttribute('cx', '12')
  ring.setAttribute('cy', '12')
  ring.setAttribute('r', '8')
  ring.setAttribute('fill', 'none')
  ring.setAttribute('stroke', 'currentColor')
  ring.setAttribute('stroke-width', '1.5')
  let needle = document.createElementNS('http://www.w3.org/2000/svg', 'path')
  needle.setAttribute('d', 'M12 6.5L14.5 12L12 17.5L9.5 12Z')
  needle.setAttribute('fill', 'none')
  needle.setAttribute('stroke', 'currentColor')
  needle.setAttribute('stroke-width', '1.5')
  needle.setAttribute('stroke-linejoin', 'round')
  svg.append(ring, needle)
  if (active) {
    let slash = document.createElementNS('http://www.w3.org/2000/svg', 'line')
    slash.setAttribute('x1', '4')
    slash.setAttribute('y1', '20')
    slash.setAttribute('x2', '20')
    slash.setAttribute('y2', '4')
    slash.setAttribute('stroke', 'currentColor')
    slash.setAttribute('stroke-width', '2')
    slash.setAttribute('stroke-linecap', 'round')
    svg.append(slash)
  }
  return svg
}

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

}