Brazen Framework - Framework

Main class of the Brazen user scripts framework

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Brazen Framework - Framework
// @namespace    brazenvoid
// @version      12.2.0
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Main class of the Brazen user scripts framework
// ==/UserScript==

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

const ICON_RECYCLE = '&#x267B'

// Identification Classes

const CLASS_COMPLIANT_ITEM = 'brazen-compliant-item'
const CLASS_NON_COMPLIANT_ITEM = 'brazen-noncompliant-item'

// Preset filter configuration keys

const CONFIG_PAGINATOR_LIMIT = 'pagination-limit'
const CONFIG_PAGINATOR_THRESHOLD = 'pagination-threshold'

const FILTER_DURATION_RANGE = 'duration'
const FILTER_PERCENTAGE_RATING_RANGE = 'rating'
const FILTER_SUBSCRIBED_VIDEOS = 'hide-subscribed-videos'
const FILTER_TAG_BLACKLIST = 'tag-blacklist'
const FILTER_TEXT_BLACKLIST = 'blacklist'
const FILTER_TEXT_SEARCH = 'search'
const FILTER_TEXT_SANITIZATION = 'text-sanitization-rules'
const FILTER_TEXT_WHITELIST = 'whitelist'
const FILTER_UNRATED = 'unrated'

const STORE_SUBSCRIPTIONS = 'account-subscriptions'

// Item preset attributes

const ITEM_NAME = 'name'
const ITEM_PROCESSED_ONCE = 'processedOnce'

// Configuration

const OPTION_ENABLE_TEXT_BLACKLIST = 'enable-text-blacklist'
const OPTION_ENABLE_TAG_BLACKLIST = 'enable-tag-blacklist'
const OPTION_ALWAYS_SHOW_SETTINGS_PANE = 'always-show-settings-pane'
const OPTION_DOCK_POSITION = 'dock-position'
const DOCK_BRAND_PREFIX = 'Brazen Scripts - '
const OPTION_DISABLE_COMPLIANCE_VALIDATION = 'disable-all-filters'

const OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER = 'skip-duplicate-downloads'

/** Default tooltip for {@link OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER}. */
const OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER_HELP =
    'Remember prior saves in script storage and skip re-downloads. Off if you removed files and want them again.'

const OPTION_HIDE_DOWNLOADED_MEDIA = 'hide-downloaded-media'

/** Default tooltip for {@link OPTION_HIDE_DOWNLOADED_MEDIA}. */
const OPTION_HIDE_DOWNLOADED_MEDIA_HELP =
    'Hide search tiles whose media is already in the download ledger. New downloads are recorded when this option or Skip Duplicate Downloads is on.'

/**
 * Canonical `GM_download` conflict policy for Brazen downloads.
 * Always `'overwrite'` — user pattern filenames must never be uniquified.
 *
 * @type {'overwrite'}
 */
const DOWNLOAD_DUPLICATE_LEDGER_CONFLICT_ACTION = 'overwrite'

class BrazenItemAttributesResolver
{
  /**
   * @typedef {{itemLinkSelector: JQuery.Selector, itemDeepAnalysisSelector: JQuery.Selector, requestDelay: number,
   *            onDeepAttributesResolution: Function}} ItemAttributesResolverConfiguration
   */

  /**
   * @callback ItemAttributesResolverCallback
   * @param {JQuery} item
   * @return {*}
   */

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

  /**
   * @type {{}}
   * @private
   */
  _attributes = {}

  /**
   * @type {{}}
   * @private
   */
  _asyncAttributes = {}

  /**
   * @type {{}}
   * @private
   */
  _deepAttributes = {}

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

  /**
   * @type {number}
   * @private
   */
  _requestIteration = 1

  /**
   * @type {JQuery<HTMLElement> | jQuery | HTMLElement}
   * @private
   */
  _sandbox = $('<div id="brazen-item-attributes-resolver-sandbox" hidden/>').appendTo('body')

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

  /**
   * @param {ItemAttributesResolverConfiguration} configuration
   */
  constructor(configuration)
  {
    this._itemLinkSelector = configuration.itemLinkSelector
    this._itemDeepAnalysisSelector = configuration.itemDeepAnalysisSelector
    this._onDeepAttributesResolution = configuration.onDeepAttributesResolution
    this._requestDelay = configuration.requestDelay
  }

  // -------------------------------------------------------------------------
  // Private class methods
  // -------------------------------------------------------------------------

  /**
   * @param {string} attribute
   * @returns {string}
   * @private
   */
  _formatAttributeName(attribute)
  {
    return attribute.toLowerCase().replaceAll(' ', '_')
  }

  /**
   * @param {JQuery} item
   * @param {Object} attributesBag
   * @private
   */
  _loadDeepAttributes(item, attributesBag)
  {
    let url = item.find(this._itemLinkSelector).first().attr('href')
    if (url) {
      Utilities.sleep(this._requestIteration * this._requestDelay).then(() => {
        try {
          this._sandbox.load(url + ' ' + this._itemDeepAnalysisSelector, () => {
            for (const attributeName in this._deepAttributes) {
              attributesBag[attributeName] = this._deepAttributes[attributeName](this._sandbox)
            }
            this._onDeepAttributesResolution(item)
            this._sandbox.empty()
          })
        } catch {
          console.error('Deep attributes loading failed.')
        }
      })
      this._requestIteration++
    }
  }

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

  /**
   * @param {string} attribute
   * @param {ItemAttributesResolverCallback} resolutionCallback
   * @returns {this}
   */
  addAsyncAttribute(attribute, resolutionCallback)
  {
    this._asyncAttributes[this._formatAttributeName(attribute)] = resolutionCallback
    return this
  }

  /**
   * @param {string} attribute
   * @param {ItemAttributesResolverCallback} resolutionCallback
   * @returns {this}
   */
  addAttribute(attribute, resolutionCallback)
  {
    this._attributes[this._formatAttributeName(attribute)] = resolutionCallback
    return this
  }

  /**
   * @param {string} attribute
   * @param {ItemAttributesResolverCallback} resolutionCallback
   * @returns {this}
   */
  addDeepAttribute(attribute, resolutionCallback)
  {
    this._deepAttributes[this._formatAttributeName(attribute)] = resolutionCallback
    this._hasDeepAttributes = true
    return this
  }

  /**
   * @returns {BrazenItemAttributesResolver}
   */
  completeResolutionRun()
  {
    this._requestIteration = 1
    return this
  }

  /**
   * @param {JQuery} item
   * @param {string} attribute
   * @returns {*}
   */
  get(item, attribute)
  {
    let attributesBag = item[0].scriptAttributes
    if (attributesBag !== undefined) {

      let attributeName = this._formatAttributeName(attribute)
      let attributeValue = attributesBag[attributeName]

      if (attributeValue !== undefined) {
        return attributeValue
      } else if (this._hasDeepAttributes) {
        this._loadDeepAttributes(item, attributesBag)
      }
    }
    return null
  }

  /**
   * @param {JQuery} item
   * @param {Function|null} afterResolutionCallback
   */
  resolveAttributes(item, afterResolutionCallback = null)
  {
    let attributesBag = {}
    item[0].scriptAttributes = attributesBag

    for (const attributeName in this._attributes) {
      attributesBag[attributeName] = this._attributes[attributeName](item)
    }
  }

  /**
   * @param {JQuery} item
   * @param {string} attribute
   * @param {*} value
   * @returns {BrazenItemAttributesResolver}
   */
  set(item, attribute, value)
  {
    item[0].scriptAttributes[this._formatAttributeName(attribute)] = value
    return this
  }
}

class BrazenFramework
{
  /**
   * @typedef {{configKey: string, validate: SearchEnhancerFilterValidationCallback, comply: SearchEnhancerFilterComplianceCallback}} ComplianceFilter
   */

  /**
   * @typedef {{storageKey?: string, primaryField?: string, legacyIdFields?: string[],
   *            legacyStorageKeys?: string[],
   *            enableConfigKey?: string, enableHelpText?: string, enableDefault?: boolean,
   *            isValidId?: (value: *) => boolean,
   *            getDownloadId: SearchEnhancerDownloadLedgerIdCallback}} DownloadDuplicateLedgerConfiguration
   */

  /**
   * @typedef {{configKey: string, otherTagSectionsSelector?: JQuery, styleClass: string, rows?: int, helpText: string, removeClasses?: string,
   *            formatter?: Function}} ItemTagHighlightsConfiguration
   */

  /**
   * @typedef {JQuery.Selector|Function|{[pageOrLayout: string]: JQuery.Selector, default?: JQuery.Selector}} SelectorConfig
   */

  /**
   * @typedef {{detect: Function, layout?: Function}} PageDefinition
   */

  /**
   * @typedef {{pages: string[], operation: Function}} PageOperation
   */

  /**
   * @typedef {{doItemCompliance?: Function, downloadDuplicateLedger?: DownloadDuplicateLedgerConfiguration, downloadsDelay?: int,
   *            isUserLoggedIn?: boolean, itemDeepAnalysisSelector?: JQuery.Selector,
   *            itemListSelectors: SelectorConfig, itemLinkSelector?: SelectorConfig, itemNameSelector: SelectorConfig, itemSelectors: SelectorConfig,
   *            itemSelectionMethod?: string, itemWrapperResolver?: SearchEnhancerItemWrapperResolver|null,
   *            requestDelay?: number, scriptPrefix: string, legacyScriptPrefix?: string,
   *            tagSelectorGenerator?: SearchEnhancerTagSelectorGeneratorCallback|null,
   *            trackComplianceRules?: boolean}} Configuration
   */

  /**
   * @callback SearchEnhancerFilterValidationCallback
   * @param {*} configValues
   * @return boolean
   */

  /**
   * @callback SearchEnhancerFilterComplianceCallback
   * @param {JQuery} item
   * @param {*} configValues
   * @return {*}
   */

  /**
   * @callback SubscriptionsFilterExclusionsCallback
   * @return {boolean}
   */

  /**
   * @callback SubscriptionsFilterUsernameCallback
   * @param {JQuery} item
   * @return {boolean|string}
   */

  /**
   * @callback SearchEnhancerItemWorkerCallback
   * @param {JQuery} item
   */

  /**
   * @callback SearchEnhancerItemWrapperResolver
   * @param {JQuery} item
   * @return {JQuery}
   */

  /**
   * @callback SearchEnhancerTagsExtractionCallback
   * @param {JQuery} item
   * @return {string[]}
   */

  /**
   * @callback SearchEnhancerTagSelectorGeneratorCallback
   * @param {string} tag
   * @return {string}
   */

  /**
   * @callback SearchEnhancerDownloadLedgerIdCallback
   * @param {*} item - DOM node, jQuery tile, or other site-specific download context
   * @return {string|null|undefined}
   */

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

  /**
   * Array of item compliance filters
   * @type {ComplianceFilter[]}
   * @private
   */
  _complianceFilters = []

  /**
   * @type {BrazenDownloadManager|null}
   * @private
   */
  _downloadManager = null

  /**
   * @type {string}
   * @private
   */
  _highlightClasses = ''

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

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

  // -------------------------------------------------------------------------
  // Protected class variables
  // -------------------------------------------------------------------------

  /**
   * @type {boolean}
   * @protected
   */
  _disableUI = false

  /**
   * @type {JQuery|null}
   * @protected
   */
  _uiSection = null

  /**
   * @type {{orientations: string[], defaultOrientation: string, scriptName?: string, showBranding?: boolean, onOpenMainPanel?: Function}|null}
   * @protected
   */
  _dockConfig = null

  /**
   * Persisted dock edge; loaded from IndexedDB via `readSetting('dock-position')`, not `field.value`.
   * @type {string|null}
   * @protected
   */
  _dockOrientation = null

  /**
   * Last composed dock-rail membership signature (root field keys). Used to avoid
   * tearing down CSS slide-outs on every configuration ping.
   * @type {string|null}
   * @private
   */
  _dockRailMembershipSignature = null

  /**
   * Re-syncs bottom-dock panels when the dock layout changes size.
   * @type {ResizeObserver|null}
   * @private
   */
  _dockPanelResizeObserver = null

  /**
   * Compliance list ChildObservers created on first validation run.
   * @type {ChildObserver[]}
   * @private
   */
  _itemListChildObservers = []

  /**
   * Attribute keys registered via `_addItemTagAttribute` (tag name arrays on items).
   * @type {string[]}
   * @private
   */
  _tagListAttributeKeys = []

  /**
   * Hide-downloaded id resolver from `_addItemHideDownloadedMediaFilter`.
   * @type {SearchEnhancerDownloadLedgerIdCallback|null}
   * @private
   */
  _hideDownloadedGetItemId = null

  /**
   * Debounce timer for ledger-driven Hide Downloaded refreshes (claims fire often).
   * @type {ReturnType<typeof setTimeout>|null}
   * @private
   */
  _ledgerComplianceRefreshTimer = null

  /**
   * Set when a ledger config event is coalesced with queue/state traffic so hide refresh
   * still runs after the merged flush.
   * @type {boolean}
   * @private
   */
  _ledgerComplianceDirty = false

  /**
   * @type {string|null}
   * @protected
   */
  _downloadDuplicateLedgerFieldKey = null

  /**
   * @type {{enableConfigKey: string, getDownloadId: SearchEnhancerDownloadLedgerIdCallback}|null}
   * @protected
   */
  _downloadDuplicateLedgerConfig = null

  /**
   * Operations to perform after script initialization
   * @type {function[]}
   * @protected
   */
  _onAfterInitialization = []

  /**
   * Host hooks after bookmark fields reload (e.g. GM hydrate).
   * @type {function[]}
   * @protected
   */
  _onBookmarksHydrate = []

  /**
   * Script hooks after Framework refreshes panel/dock/bookmarks on configuration change.
   * @type {function({manager: BrazenConfigurationManager, source: string, local: boolean})[]}
   * @protected
   */
  _onConfigurationChange = []

  /**
   * Session UI refresh when tag-substitution link mode changes without a config write.
   * @type {function[]}
   * @protected
   */
  _onTagSubstitutionUiChange = []

  /**
   * In-progress alias for filename substitution link mode (normalized), or null.
   * @type {string|null}
   * @private
   */
  _tagSubstitutionLinkSource = null

  /**
   * Site type name for {@link _tagSubstitutionLinkSource} when linking from a typed panel row.
   * @type {string|null}
   * @private
   */
  _tagSubstitutionLinkSourceType = null

  /**
   * In-flight tag attribute persists (`fieldKey\\0normalizedName`) — blocks double-toggles
   * after optimistic UI rebuilds replace the clicked button.
   * @type {Set<string>}
   * @private
   */
  _tagAttributePersistKeys = new Set()

  /**
   * @type {{manager: BrazenConfigurationManager, source: string, local: boolean}|null}
   * @private
   */
  _pendingConfigurationChange = null

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

  /**
   * Operations to perform after a complete compliance run
   * @type {function[]}
   * @protected
   */
  _onAfterComplianceRun = []

  /**
   * Operations to perform after UI generation
   * @type {function[]}
   * @protected
   */
  _onAfterUIBuild = []

  /**
   * Operations to perform before compliance validation.
   * @type {function[]}
   * @protected
   */
  _onBeforeCompliance = []

  /**
   * Operations to perform before UI generation
   * @type {function[]}
   * @protected
   */
  _onBeforeUIBuild = []

  /**
   * Operations to perform after compliance rule checks, the first time a search item is retrieved
   * @type {function(JQuery)[]}
   * @protected
   */
  _onFirstHitAfterCompliance = []

  /**
   * Operations to perform before compliance checks, the first time a search item is retrieved
   * @type {function(JQuery)[]}
   * @protected
   */
  _onFirstHitBeforeCompliance = []

  /**
   * Logic to hide a non-compliant item
   * @type {SearchEnhancerItemWorkerCallback}
   * @param {JQuery} item
   * @protected
   */
  _onItemHide = null

  /**
   * Logic to show the compliant search item
   * @type {Function[]}
   * @param {JQuery} item
   * @protected
   */
  _onItemShow = []

  /**
   * Validate initiating initialization.
   * Can be used to stop script initialization on specific pages or vice versa
   * @type {Function}
   * @protected
   */
  _onValidateInit = () => true

  /**
   * Operations to perform at the start of gated full init (Phase 2), after page detection.
   * @type {function[]}
   * @protected
   */
  _onBeforeFullInit = []

  /**
   * Registered page definitions (name → detect/layout config).
   * @type {Map<string, PageDefinition>}
   * @protected
   */
  _pages = new Map()

  /**
   * Pages active on the current document (evaluated once per `init()`).
   * @type {Set<string>}
   * @protected
   */
  _activePages = new Set()

  /**
   * Optional allow-list of page names that gate full init. `null` = all defined pages.
   * @type {string[]|null}
   * @protected
   */
  _initPages = null

  /**
   * Optional allow-list of page names that gate compliance runs. `null` = all active pages.
   * @type {string[]|null}
   * @protected
   */
  _compliancePages = null

  /**
   * Boot-time page operations (Phase 1).
   * @type {PageOperation[]}
   * @protected
   */
  _pageOperations = []

  /**
   * Layout variant per active page name (when a layout resolver is defined).
   * @type {Map<string, string|null>}
   * @protected
   */
  _layouts = new Map()

  /**
   * Runtime switch for compliance passes (independent of Disable All Filters).
   * @type {boolean}
   * @protected
   */
  _complianceEnabled = true

  /**
   * Pagination manager
   * @type BrazenPaginator|null
   * @protected
   */
  _paginator = null

  /**
   * @type {ComplianceRuleRecorder|null}
   * @protected
   */
  _complianceRules = null

  /**
   * @type {BrazenSubscriptionsLoader|null}
   * @protected
   */
  _subscriptionsLoader = null

  /**
   * Must return the generated settings section node
   * @type {JQuery[]}
   * @protected
   */
  _userInterface = []

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

  /**
   * @param {Configuration} configuration
   */
  constructor(configuration)
  {
    this._config = configuration
    if (configuration.isUserLoggedIn === undefined) {
      this._config.isUserLoggedIn = false
    }
    if (configuration.itemSelectionMethod === undefined) {
      this._config.itemSelectionMethod = 'find'
    }
    if (configuration.itemWrapperResolver === undefined) {
      this._config.itemWrapperResolver = (item) => item
    }

    this._itemAttributesResolver = new BrazenItemAttributesResolver({
      itemDeepAnalysisSelector: this._config.itemDeepAnalysisSelector ?? '',
      itemLinkSelector: this._config.itemLinkSelector ?? '',
      requestDelay: this._config.requestDelay ?? 0,
      onDeepAttributesResolution: (item) => {
        this._complyItem(item)
        Utilities.processEventHandlerQueue(this._onAfterComplianceRun)
      },
    })

    this._statistics = new StatisticsRecorder(this._config.scriptPrefix)
    if (configuration.trackComplianceRules) {
      this._complianceRules = new ComplianceRuleRecorder()
    }

    this._uiGen = new BrazenViewLayer(this._config.scriptPrefix)

    this._configurationManager = new BrazenConfigurationManager(this._config.scriptPrefix, this._uiGen, this._config.tagSelectorGenerator)
    if (configuration.legacyScriptPrefix) {
      this._configurationManager.setLegacyScriptPrefix(configuration.legacyScriptPrefix)
    }
    this._configurationManager.onConfigurationChange((event) => {
      this._scheduleConfigurationChange(event)
    })
    this._configurationManager.addFlagField(OPTION_DISABLE_COMPLIANCE_VALIDATION).
        setTitle('Disable All Filters').
        setHelpText('Disables all search filters.').
        applyDockTemplate('invertedFiltersMaster')
    this._configurationManager.addFlagField(OPTION_ALWAYS_SHOW_SETTINGS_PANE).
        setTitle('Always Show Settings Pane').
        setHelpText('Always show configuration interface. Ignored when a button dock is enabled.')

    this._onItemHide = (item) => this._config.itemWrapperResolver(item).addClass(CLASS_NON_COMPLIANT_ITEM).removeClass(CLASS_COMPLIANT_ITEM).hide()

    this._onItemShow.push((item) => this._config.itemWrapperResolver(item).addClass(CLASS_COMPLIANT_ITEM).removeClass(CLASS_NON_COMPLIANT_ITEM).show())

    if (configuration.downloadDuplicateLedger?.getDownloadId) {
      this._initDownloadDuplicateLedger(configuration.downloadDuplicateLedger)
    }
  }

  // -------------------------------------------------------------------------
  // Private class methods
  // -------------------------------------------------------------------------

  /**
   * @return {ConfigurationField|null}
   * @private
   */
  _getDownloadDuplicateLedgerField()
  {
    if (!this._downloadDuplicateLedgerFieldKey) {
      return null
    }
    return this._configurationManager.getField(this._downloadDuplicateLedgerFieldKey)
  }

  /**
   * @param {DownloadDuplicateLedgerConfiguration} ledgerConfig
   * @private
   */
  _initDownloadDuplicateLedger(ledgerConfig)
  {
    let enableConfigKey = ledgerConfig.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER
    let enableHelpText = ledgerConfig.enableHelpText ?? OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER_HELP
    let enableFlag = this._configurationManager.addFlagField(enableConfigKey).setHelpText(enableHelpText)
    if (enableConfigKey === OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER) {
      enableFlag.setTitle('Skip Duplicate Downloads')
    }
    if (ledgerConfig.enableDefault !== false) {
      // Prefer setDefault so _settingCache matches — getValue() reads cache after createField
      // seeds false, and a bare field.value = true leaves Skip Duplicate "off" after refresh.
      enableFlag.setDefault(true)
    }
    enableFlag.applyDockTemplate('skipDuplicates')

    this._configurationManager.addLedgerField('download-ledger').
        setTitle('Download Ledger').
        setPrimaryField(ledgerConfig.primaryField ?? 'ids').
        setIsValidId(ledgerConfig.isValidId ?? ((value) => typeof value === 'string' && value.trim().length > 0)).
        reload()
    this._downloadDuplicateLedgerFieldKey = 'download-ledger'
    this._downloadDuplicateLedgerConfig = {
      enableConfigKey,
      getDownloadId: ledgerConfig.getDownloadId,
    }
  }

  /**
   * Decodes HTML entities (e.g. `&amp;` → `&`) from DOM-derived strings.
   *
   * @param {string} text
   * @return {string}
   * @protected
   */
  _decodeHtmlEntities(text)
  {
    if (typeof text !== 'string' || text.length === 0) {
      return ''
    }
    if (!text.includes('&')) {
      return text
    }
    let textarea = document.createElement('textarea')
    textarea.innerHTML = text
    return textarea.value
  }

  /**
   * Polls and observes the DOM until `predicate(element)` is true for the first
   * match of `selector`, then invokes `callback(element)` once.
   *
   * @param {string} selector
   * @param {function(Element): boolean} predicate
   * @param {function(Element): void} callback
   * @param {{pollMs?: number, timeoutMs?: number, observeRoot?: Element}} [options]
   * @protected
   */
  _waitForDomElement(selector, predicate, callback, options = {})
  {
    let pollMs = options.pollMs ?? 150
    let timeoutMs = options.timeoutMs ?? 30000
    let observeRoot = options.observeRoot ?? document.documentElement
    let resolved = false
    let observer = null
    let poll = null
    let timeout = null

    let cleanup = () => {
      observer?.disconnect()
      if (poll) {
        clearInterval(poll)
      }
      if (timeout) {
        clearTimeout(timeout)
      }
    }

    let attempt = () => {
      if (resolved) {
        return
      }
      let element = document.querySelector(selector)
      if (!element || !predicate(element)) {
        return
      }
      resolved = true
      cleanup()
      callback(element)
    }

    attempt()
    if (resolved) {
      return
    }

    observer = new MutationObserver(attempt)
    observer.observe(observeRoot, {subtree: true, childList: true, attributes: true, attributeFilter: ['src']})
    poll = setInterval(attempt, pollMs)
    timeout = setTimeout(cleanup, timeoutMs)
  }

  /**
   * @param {string} helpText
   * @param rows
   * @protected
   */
  _addItemBlacklistFilter(helpText, rows = 5)
  {
    this._configurationManager.addFlagField(OPTION_ENABLE_TEXT_BLACKLIST).
        setTitle('Enable Text Blacklist').
        setHelpText('Applies the blacklist.')
    this._configurationManager.addRulesetField(FILTER_TEXT_BLACKLIST).
        setTitle('Blacklist').
        setRows(rows).
        setHelpText(helpText).
        setOptimize((rules) => Utilities.buildWholeWordMatchingRegex(rules) ?? '').
        setSortRules(true)
    this._addItemComplexComplianceFilter(
        FILTER_TEXT_BLACKLIST,
        (rules) => this._getConfig(OPTION_ENABLE_TEXT_BLACKLIST) && rules !== '',
        (item, value) => this._get(item, ITEM_NAME)?.match(value) === null,
    )
  }

  /**
   * @param {string} configKey
   * @param {SearchEnhancerFilterValidationCallback|null} validationCallback
   * @param {SearchEnhancerFilterComplianceCallback|null|string} complianceCallback
   * @protected
   */
  _addItemComplexComplianceFilter(configKey, validationCallback, complianceCallback)
  {
    this._addItemComplianceFilter(configKey, complianceCallback, validationCallback)
  }

  /**
   * @param {string} configKey
   * @param {SearchEnhancerFilterComplianceCallback|null|string} action
   * @param {SearchEnhancerFilterValidationCallback|null} validationCallback
   * @protected
   */
  _addItemComplianceFilter(configKey, action = null, validationCallback = null)
  {
    let configType = this._configurationManager.getField(configKey).type
    if (action === null) {
      action = configKey
    }
    if (typeof action === 'string') {
      let attributeName = action
      switch (configType) {
        case CONFIG_TYPE_CHECKBOXES_GROUP:
          action = (item, values) => {
            let attribute = this._get(item, attributeName)
            return attribute && values.length ? values.includes(attribute) : true
          }
          break
        case CONFIG_TYPE_FLAG:
          action = (item) => {
            let attribute = this._get(item, attributeName)
            return attribute === null ? true : attribute
          }
          break
        case CONFIG_TYPE_RADIOS_GROUP:
          action = (item, value) => {
            let attribute = this._get(item, attributeName)
            return attribute ? value === attribute : true
          }
          break
        case CONFIG_TYPE_RANGE:
          action = (item, range) => {
            let attribute = this._get(item, attributeName)
            return attribute ? Validator.isInRange(this._get(item, attributeName), range.minimum, range.maximum) : true
          }
          break
        default:
          throw new Error('Associated config type requires explicit action callback definition.')
      }
    }
    if (validationCallback === null) {
      validationCallback = this._configurationManager.generateValidationCallback(configKey)
    }
    this._complianceFilters.push({
      configKey: configKey,
      validate: validationCallback,
      comply: action,
    })
  }

  /**
   * @param {JQuery.Selector|Function} durationNodeSelector
   * @param {string|null} helpText
   * @param {string} separator
   * @protected
   */
  _addItemDurationRangeFilter(durationNodeSelector, helpText = null, separator = ':')
  {
    this._configurationManager.addRangeField(FILTER_DURATION_RANGE, 0, 100000).
        setTitle('Duration').
        setHelpText(helpText ?? 'Filter items by duration.')

    this._itemAttributesResolver.addAttribute(FILTER_DURATION_RANGE, (item) => {
      let duration
      if (typeof durationNodeSelector === 'function') {
        duration = durationNodeSelector(item)
      } else {
        let durationNode = item.find(durationNodeSelector)
        if (durationNode.length) {
          duration = durationNode.text().trim()
        } else {
          return null
        }
      }
      duration = duration.split(separator)
      duration = (Number.parseInt(duration[0]) * 60) + Number.parseInt(duration[1])
      return duration === 0 ? null : duration
    })

    this._addItemComplianceFilter(FILTER_DURATION_RANGE)
  }

  /**
   * @param {JQuery.Selector} ratingNodeSelector
   * @param {string|null} helpText
   * @param {string|null} unratedHelpText
   * @protected
   */
  _addItemPercentageRatingRangeFilter(ratingNodeSelector, helpText = null, unratedHelpText = null)
  {
    this._configurationManager.addRangeField(FILTER_PERCENTAGE_RATING_RANGE, 0, 100000).
        setTitle('Rating').
        setHelpText(helpText ?? 'Filter items by percentage rating.')
    this._configurationManager.addFlagField(FILTER_UNRATED).
        setTitle('Unrated').
        setHelpText(unratedHelpText ?? 'Hide items with zero or no rating.')

    this._itemAttributesResolver.addAttribute(FILTER_PERCENTAGE_RATING_RANGE, (item) => {
      let rating = item.find(ratingNodeSelector)
      return rating.length === 0 ? null : Number.parseInt(rating.text().trim().replace('%', ''))
    })

    this._addItemComplianceFilter(FILTER_PERCENTAGE_RATING_RANGE, (item, range) => {
      let rating = this._get(item, FILTER_PERCENTAGE_RATING_RANGE)
      return rating ? Validator.isInRange(rating, range.minimum, range.maximum) : !this._getConfig(FILTER_UNRATED)
    })
  }

  /**
   * @param {string} key
   * @param {boolean} deepAttribute
   * @param {boolean} saveSelectors
   * @param {SearchEnhancerTagsExtractionCallback} extractTags
   * @protected
   */
  _addItemTagAttribute(key, deepAttribute, saveSelectors, extractTags)
  {
    if (!this._tagListAttributeKeys.includes(key)) {
      this._tagListAttributeKeys.push(key)
    }
    let tagsToSelectorsMapper = (item) => {
      if (saveSelectors) {
        let tagSelectors = ''
        for (let tag of extractTags(item)) {
          tagSelectors += this._config.tagSelectorGenerator(tag)
        }
        return tagSelectors
      }
      let tags = []
      for (let tag of extractTags(item)) {
        tags.push(tag)
      }
      return tags
    }
    if (deepAttribute) {
      this._itemAttributesResolver.addDeepAttribute(key, tagsToSelectorsMapper)
    } else {
      this._itemAttributesResolver.addAttribute(key, tagsToSelectorsMapper)
    }
  }

  /**
   * @param {string|null} attribute
   * @param {boolean} useSelectors
   * @param {int} rows
   * @param {string|null} helpText
   * @param {string|null} key
   * @param {string|null} optionKey
   * @param {string|null} dockTemplateName Named CM dock template (default `tagBlacklist` for primary blacklist).
   * @protected
   */
  _addItemTagBlacklistFilter(attribute, useSelectors, rows = 5, helpText = null, key = null, optionKey = null, dockTemplateName = null)
  {
    if (helpText === null) {
      helpText = 'Specify the tags blacklist with one rule on each line. <br> Conditional operators; "&" "|" can be used to make complex rules.'
    }

    if (key === null) {
      key = FILTER_TAG_BLACKLIST
    }
    if (optionKey === null) {
      optionKey = OPTION_ENABLE_TAG_BLACKLIST
    }

    let enableField = this._configurationManager.addFlagField(optionKey).setHelpText('Applies the blacklist.')
    if (optionKey === OPTION_ENABLE_TAG_BLACKLIST) {
      enableField.setTitle('Enable Tag Blacklist')
    }

    let templateName = dockTemplateName
    if (!templateName && optionKey === OPTION_ENABLE_TAG_BLACKLIST) {
      templateName = 'tagBlacklist'
    }
    if (templateName) {
      enableField.applyDockTemplate(templateName)
    }

    this._configurationManager.addTagRulesetField(key, useSelectors).
        setTitle(key === FILTER_TAG_BLACKLIST ? 'Tag Blacklist' : key).
        setRows(rows).
        setHelpText(helpText).
        setSortRules(true)

    this._addItemComplexComplianceFilter(
        key,
        () => this._validateTagComplianceFilter(key, optionKey),
        (item) => this._complyTagComplianceFilter(item, attribute, key),
    )
  }

  /**
   * @param {string} fieldKey
   * @param {string} optionKey
   * @return {boolean}
   * @protected
   */
  _validateTagComplianceFilter(fieldKey, optionKey)
  {
    if (!this._getConfig(optionKey)) {
      return false
    }
    if (this._configurationManager.isTagRegistryField(fieldKey)) {
      return this._configurationManager.hasTagComplianceRules(fieldKey)
    }
    let field = this._configurationManager.getField(fieldKey)
    let optimized = field?.optimized ?? field?.value
    return Array.isArray(optimized) ? optimized.length > 0 : !!optimized?.length
  }

  /**
   * @param {JQuery} item
   * @param {string} attribute
   * @param {string} fieldKey
   * @return {boolean|{complies: boolean, rule: string}}
   * @protected
   */
  _complyTagComplianceFilter(item, attribute, fieldKey)
  {
    let itemTags = this._get(item, attribute)
    if (itemTags === null || !itemTags.length) {
      return true
    }
    if (this._configurationManager.isTagRegistryField(fieldKey) && this._configurationManager.canPersist()) {
      let result = this._configurationManager.evaluateTagCompliance(itemTags, fieldKey)
      return result.complies ? true : result
    }
    let field = this._configurationManager.getField(fieldKey)
    return this._handleComplianceForBlacklistFilter(item, attribute, field?.optimized ?? field?.value ?? [])
  }

  /**
   * Hides items whose media is already recorded in the download duplicate ledger.
   * Membership uses recorded ledger ids (Skip Duplicate does not have to be on).
   * Claims run when Skip **or** Hide is on ({@link _shouldClaimDownloadDuplicateLedger}).
   *
   * @param {SearchEnhancerDownloadLedgerIdCallback} getItemDownloadId Resolves a per-item ledger id from a tile
   * @param {string|null} helpText
   * @param {string} optionKey
   * @protected
   */
  _addItemHideDownloadedMediaFilter(getItemDownloadId, helpText = null, optionKey = OPTION_HIDE_DOWNLOADED_MEDIA)
  {
    this._hideDownloadedGetItemId = getItemDownloadId
    let field = this._configurationManager.addFlagField(optionKey).setHelpText(helpText ?? OPTION_HIDE_DOWNLOADED_MEDIA_HELP)
    if (optionKey === OPTION_HIDE_DOWNLOADED_MEDIA) {
      field.setTitle('Hide Downloaded Media')
    }
    field.applyDockTemplate('hideDownloaded')

    this._addItemComplexComplianceFilter(
        optionKey,
        (enabled) => enabled && !!this._getDownloadDuplicateLedgerField(),
        (item) => {
          let id = getItemDownloadId(item)
          return id !== null && id !== undefined && this._isDownloadLedgerIdRecorded(String(id).trim())
              ? {complies: false, rule: 'Already downloaded'}
              : true
        },
    )
  }

  /**
   * @param {ItemTagHighlightsConfiguration} config
   * @protected
   */
  _addItemTagHighlights(config)
  {
    this.registerHighlightStyleClass(config.styleClass)

    this._configurationManager.addTagRulesetField(config.configKey, true).
        setRows(config.rows ?? 5).
        setHelpText(config.helpText ?? '').
        setFormatter(config.formatter ?? null).
        setSortRules(true)

    let highlightsHandler = (section) => {

      let optimizedRuleset = this._configurationManager.getField(config.configKey).optimized
      if (optimizedRuleset) {

        let ruleApplies, subjectTags
        for (let rule of optimizedRuleset) {

          ruleApplies = true
          subjectTags = section.find(rule.join(', '))

          for (let tagSelector of rule) {

            if (section.find(tagSelector).length === 0) {
              ruleApplies = false
              break
            }
          }
          if (ruleApplies) {
            subjectTags.addClass(config.styleClass)
            if (config.removeClasses !== undefined) {
              subjectTags.removeClass(config.removeClasses)
            }
          } else {
            subjectTags.removeClass(config.styleClass)
          }
        }
      }
    }
    if (config.otherTagSectionsSelector && config.otherTagSectionsSelector.length > 0) {
      this._onBeforeUIBuild.push(() => highlightsHandler(config.otherTagSectionsSelector))
    }
    this._onItemShow.push((item) => highlightsHandler(item))
  }

  /**
   * @param {string} helpText
   * @protected
   */
  _addItemTextSanitizationFilter(helpText)
  {
    this._sanitizationEnabled = true

    this._configurationManager.addRulesetField(FILTER_TEXT_SANITIZATION).
        setTitle('Text Sanitization Rules').
        setRows(2).
        setHelpText(helpText).
        setTranslateFromUI((rules) => {
      let sanitizationRules = {}, fragments, validatedTargetWords
      for (let sanitizationRule of rules) {

        if (sanitizationRule.includes('=')) {
          fragments = sanitizationRule.split('=')
          if (fragments[0] === '') {
            fragments[0] = ' '
          }

          validatedTargetWords = Utilities.trimAndKeepNonEmptyStrings(fragments[1].split(','))
          if (validatedTargetWords.length) {
            sanitizationRules[fragments[0]] = validatedTargetWords
          }
        }
      }
      return sanitizationRules
    }).
        setFormatForUI((rules) => {
      let sanitizationRulesText = []
      if (!rules || typeof rules !== 'object' || Array.isArray(rules)) {
        return sanitizationRulesText
      }
      for (let substitute in rules) {
        sanitizationRulesText.push(substitute + '=' + rules[substitute].join(','))
      }
      return sanitizationRulesText

    }).
        setOptimize((rules) => {
      let optimizedRules = {}
      if (!rules || typeof rules !== 'object' || Array.isArray(rules)) {
        return optimizedRules
      }
      for (const substitute in rules) {
        optimizedRules[substitute] = Utilities.buildWholeWordMatchingRegex(rules[substitute])
      }
      return optimizedRules
    })
  }

  /**
   * @param {string|null} helpText
   * @protected
   */
  _addItemTextSearchFilter(helpText = null)
  {
    this._configurationManager.addTextField(FILTER_TEXT_SEARCH).
        setTitle('Search').
        setHelpText(helpText ?? 'Show videos with these comma separated words in their names.')
    this._addItemComplianceFilter(FILTER_TEXT_SEARCH, (item, value) => this._get(item, ITEM_NAME).includes(value))
  }

  /**
   * @param {string} helpText
   * @protected
   */
  _addItemWhitelistFilter(helpText)
  {
    this._configurationManager.addRulesetField(FILTER_TEXT_WHITELIST).
        setTitle('Whitelist').
        setRows(5).
        setHelpText(helpText).
        setOptimize((rules) => Utilities.buildWholeWordMatchingRegex(rules)).
        setSortRules(true)
  }

  /**
   * @param {SubscriptionsFilterExclusionsCallback} exclusionsCallback Add page exclusions here
   * @param {SubscriptionsFilterUsernameCallback} getItemUsername Return username of the item or return false to skip
   * @protected
   */
  _addSubscriptionsFilter(exclusionsCallback, getItemUsername)
  {
    this._configurationManager.addFlagField(FILTER_SUBSCRIBED_VIDEOS).
        setTitle('Hide Subscribed Videos').
        setHelpText('Hide videos from subscribed channels.')
    this._configurationManager.addTextField(STORE_SUBSCRIPTIONS).
        setTitle('Account Subscriptions').
        setHelpText('Recorded subscription accounts.')

    this._addItemComplexComplianceFilter(
        FILTER_SUBSCRIBED_VIDEOS,
        (value) => value && this._config.isUserLoggedIn && exclusionsCallback(),
        (item) => {
          let username = getItemUsername(item)
          if (username === false) {
            return true
          }
          return !(new RegExp('"([^"]*' + username + '[^"]*)"')).test(this._getConfig(STORE_SUBSCRIPTIONS))
        })
  }

  /**
   * @param {JQuery} item
   * @protected
   */
  _complyItem(item)
  {
    let itemComplies = true
    let doItemCompliance = true

    if (this._config.doItemCompliance) {
      doItemCompliance = Utilities.callEventHandler(this._config.doItemCompliance)
    }

    if (doItemCompliance && !this._getConfig(OPTION_DISABLE_COMPLIANCE_VALIDATION) && this._validateItemWhiteList(item)) {

      let configField
      Utilities.processEventHandlerQueue(this._onBeforeCompliance, [item])

      for (let complianceFilter of this._complianceFilters) {

        configField = this._configurationManager.getFieldOrFail(complianceFilter.configKey)
        if (complianceFilter.validate(configField.optimized ?? configField.value)) {
          let complyResult = complianceFilter.comply(item, configField.optimized ?? configField.value)
          let {complies, rule} = this._unwrapComplianceResult(complyResult)
          itemComplies = complies
          this._statistics.record(complianceFilter.configKey, itemComplies)
          if (!itemComplies) {
            if (this._complianceRules) {
              let ruleLabel = rule ?? this._configurationManager.getField(complianceFilter.configKey)?.title ?? complianceFilter.configKey
              this._complianceRules.record(complianceFilter.configKey, ruleLabel)
            }
            break
          }
        }
      }
    }
    if (itemComplies) {
      Utilities.processEventHandlerQueue(this._onItemShow, [item])
    } else {
      Utilities.callEventHandler(this._onItemHide, [item])
    }
    item.css('opacity', 'unset')
  }

  /**
   * Filters items as per settings. Primes bounded ledger/tag caches for the tile set first
   * so sync filters never need a full-table RAM mirror.
   * @param {JQuery} itemsList
   * @param {boolean} fromObserver
   * @return {Promise<void>}
   * @protected
   */
  async _complyItemsList(itemsList, fromObserver = false)
  {
    let items
    if (fromObserver) {
      items = itemsList.filter(this._config.itemSelectors).add(itemsList.find(this._config.itemSelectors))
    } else if (this._config.itemSelectionMethod === 'find') {
      items = itemsList.find(this._config.itemSelectors)
    } else {
      items = itemsList.children(this._config.itemSelectors)
    }

    // Shallow attribute resolve before priming (tag lists / post ids).
    items.each((index, element) => {
      let item = $(element)
      if (this._get(item, ITEM_PROCESSED_ONCE) === null) {
        if (this._sanitizationEnabled) {
          Validator.sanitizeTextNode(
              item.find(this._config.itemNameSelector),
              this._configurationManager.getFieldOrFail(FILTER_TEXT_SANITIZATION).optimized)
        }
        this._itemAttributesResolver.resolveAttributes(item)
        Utilities.processEventHandlerQueue(this._onFirstHitBeforeCompliance, [item])
      }
    })

    // Prime IndexedDB lookups before dimming — otherwise selection overlays flicker for
    // the whole await while every tile sits at opacity 0.75.
    await this._primeComplianceCaches(items)

    items.css('opacity', 0.75)
    items.each((index, element) => {
      let item = $(element)
      this._complyItem(item)
      if (!this._get(item, ITEM_PROCESSED_ONCE)) {
        Utilities.processEventHandlerQueue(this._onFirstHitAfterCompliance, [item])
        this._itemAttributesResolver.set(item, ITEM_PROCESSED_ONCE, true)
      }
    })

    this._statistics.updateUI()

    Utilities.processEventHandlerQueue(this._onAfterComplianceRun)
  }

  /**
   * Prefetch ledger membership and tag registry rows needed for this tile set.
   * @param {JQuery} items
   * @return {Promise<void>}
   * @protected
   */
  async _primeComplianceCaches(items)
  {
    if (!this._configurationManager.canPersist() || !items?.length) {
      return
    }

    let ledgerField = this._getDownloadDuplicateLedgerField()
    if (ledgerField && (this._getConfig(OPTION_HIDE_DOWNLOADED_MEDIA) || this._shouldClaimDownloadDuplicateLedger())) {
      let ids = []
      items.each((_, element) => {
        let item = $(element)
        let id = this._hideDownloadedGetItemId
            ? Utilities.callEventHandler(this._hideDownloadedGetItemId, [item], null)
            : this._getDownloadDuplicateLedgerId(item)
        if (id !== null && id !== undefined && String(id).trim()) {
          ids.push(String(id).trim())
        }
      })
      if (ids.length) {
        await ledgerField.primeHits(ids)
      }
    }

    let tagRuntime = this._configurationManager.getTagRuntime()
    if (!tagRuntime || !this._tagListAttributeKeys.length) {
      return
    }
    let tagNameLists = []
    items.each((_, element) => {
      let item = $(element)
      for (let key of this._tagListAttributeKeys) {
        let tags = this._get(item, key)
        if (Array.isArray(tags) && tags.length) {
          tagNameLists.push(tags)
        }
      }
    })
    let specs = []
    for (let fieldKey of Object.keys(this._configurationManager._tagComplianceSpecs ?? {})) {
      let spec = this._configurationManager.getTagComplianceSpec(fieldKey)
      if (spec) {
        specs.push(spec)
      }
    }
    if (tagNameLists.length || specs.length) {
      await tagRuntime.ensureComplianceLookups(tagNameLists, specs)
    }
  }

  /**
   * @protected
   * @return {JQuery[]}
   */
  _createPaginationControls()
  {
    return [
      this._configurationManager.createElement(CONFIG_PAGINATOR_THRESHOLD),
      this._configurationManager.createElement(CONFIG_PAGINATOR_LIMIT)]
  }

  /**
   * @protected
   * @return {JQuery}
   */
  _createSettingsBackupRestoreFormActions()
  {
    return this._uiGen.createFormActions([
      this._uiGen.createFormButton('Backup Configuration', 'Download configuration file.', () => this._onBackupSettings()),
      this._uiGen.createSeparator(),
      this._uiGen.createFormGroupInput('file').attr('id', 'restore-settings').attr('placeholder', 'Browse for settings file...'),
      this._uiGen.createFormButton('Restore Configuration', 'Restore configuration from the selected file.', () => this._onRestoreSettings()),
    ], 'bv-flex-column')
  }

  /**
   * @protected
   * @return {JQuery}
   */
  _createSettingsFormActions()
  {
    return this._uiGen.createFormActions([
      this._uiGen.createFormButton('Apply', 'Apply settings.', () => this._onApplyNewSettings()),
      this._uiGen.createFormButton('Save', 'Apply and update saved configuration.', () => this._onSaveSettings()),
      this._uiGen.createFormButton('Reset', 'Revert to saved configuration.', () => this._onResetSettings()),
    ])
  }

  /**
   * Include a Donate tab panel (default Patreon: https://www.patreon.com/c/brazen_mvs).
   * Pass `Donate` (or `options.tabName`) in the matching {@link BrazenViewLayer#createTabsSection} names list.
   *
   * @param {{tabName?: string, isFirst?: boolean, patreonUrl?: string, linkLabel?: string, message?: string}} [options]
   * @return {JQuery}
   */
  createDonateTabPanel(options = {})
  {
    return this._uiGen.createDonateTabPanel(options)
  }

  /**
   * Behaviours settings tab — Download Manager flags when registered
   * (`review-ignored-filename-pins`, `skip-empty-filename-pins`, `only-show-download-manager`).
   * Pass `Behaviours` (or `options.tabName`) in the matching {@link BrazenViewLayer#createTabsSection} names list.
   *
   * @param {{tabName?: string, isFirst?: boolean}} [options]
   * @return {JQuery}
   */
  createBehavioursTabPanel(options = {})
  {
    let tabName = options.tabName ?? 'Behaviours'
    let pinKeys = [
      'review-ignored-filename-pins',
      'skip-empty-filename-pins',
    ]
    let elements = []
    for (let key of pinKeys) {
      if (this._configurationManager.hasField(key)) {
        elements.push(this._configurationManager.createElement(key))
      }
    }
    if (this._configurationManager.hasField('only-show-download-manager')) {
      if (elements.length) {
        elements.push(this._uiGen.createSeparator())
      }
      elements.push(this._configurationManager.createElement('only-show-download-manager'))
    }
    return this._uiGen.createTabPanel(tabName, !!options.isFirst).append(elements)
  }

  /**
   * @protected
   * @return {JQuery}
   */
  _createSubscriptionLoaderControls()
  {
    return this._subscriptionsLoaderButton
  }

  /**
   * Whether the main settings panel should stay open (auto-hide suppressed).
   * Inert when a dock is active — frequent controls live on the dock rail.
   * @return {boolean}
   * @protected
   */
  _isAlwaysShowSettingsPaneActive()
  {
    if (this._dockConfig && !this._disableUI) {
      return false
    }
    return !!this._getConfig(OPTION_ALWAYS_SHOW_SETTINGS_PANE)
  }

  /**
   * @param {JQuery} UISection
   * @private
   */
  _embedUI(UISection)
  {
    let hideTimer = null
    const cancelScheduledHide = () => {
      clearTimeout(hideTimer)
      hideTimer = null
    }
    UISection.on('mouseenter', cancelScheduledHide)
    UISection.on('mouseleave', (event) => {
      if (!this._uiGen.isSettingsPaneBeingResized() && !this._isAlwaysShowSettingsPaneActive()) {
        cancelScheduledHide()
        hideTimer = setTimeout(() => {
          hideTimer = null
          if (!this._uiGen.isSettingsPaneBeingResized() && !this._isAlwaysShowSettingsPaneActive()) {
            if (this._dockConfig && !this._disableUI) {
              this._hideMainPanel(event.currentTarget)
            } else {
              $(event.currentTarget).slideUp(300)
            }
          }
        }, 1000)
      }
    })
    if (this._isAlwaysShowSettingsPaneActive()) {
      UISection.show()
    }
    this._uiGen.constructor.appendToBody(UISection)
    this._uiSection = UISection
    if (this._complianceRules && this._dockConfig && !this._disableUI) {
      this._complianceRulesPanel = this._uiGen.createComplianceRulesSlidePanel()
      this._wireComplianceRulesSlidePanel(this._complianceRulesPanel)
      this._uiGen.constructor.appendToBody(this._complianceRulesPanel)
    }
    if (this._dockConfig && !this._disableUI) {
      this._buildDock(UISection)
    } else {
      this._uiGen.constructor.appendToBody(this._uiGen.createSettingsShowButton('', UISection))
    }
  }

  /**
   * @param {JQuery} [railBody]
   * @param {{layout?: boolean}} [options] Pass `{ layout: false }` to refresh button state only.
   * @return {BrazenFramework}
   */
  refreshDockInterface(railBody, options = {})
  {
    railBody = railBody ?? $('.bv-dock .bv-dock-rail-body').first()
    this._configurationManager.setDockIncludeContext(this)
    if (options.layout !== false) {
      this.composeDockRail(railBody)
      this._dockRailMembershipSignature = this._configurationManager.getDockRailMembershipSignature()
    }
    this._configurationManager.refreshDockButtonStates()
    return this
  }

  /**
   * @param {JQuery} railBody
   */
  composeDockRail(railBody)
  {
    throw new Error('composeDockRail(railBody) must be implemented when configureDock() is used.')
  }

  /**
   * @param {JQuery} UISection
   * @private
   */
  _buildDock(UISection)
  {
    let orientation = this._getDockOrientation()
    this._setDockPanelAnchor(orientation)
    let nameBar = this._getDockNameBarText()
    let dock = this._uiGen.createDock(orientation, nameBar)
    let railHead = dock.find('.bv-dock-rail-head')
    let railBody = dock.find('.bv-dock-rail-body')
    let railFoot = dock.find('.bv-dock-rail-foot')
    let openPanel = this._dockConfig.onOpenMainPanel ?? (() => this._toggleMainPanel(UISection))
    railHead.append(this._uiGen.createDockButton({
      icon: 'menu',
      tooltip: 'Toggle Main UI',
      onClick: () => openPanel(),
    }).addClass('bv-dock-main-ui-btn'))
    railHead.append(this._uiGen.createDockSeparator())

    this._configurationManager.setDockIncludeContext(this)
    this.composeDockRail(railBody)
    this._dockRailMembershipSignature = this._configurationManager.getDockRailMembershipSignature()

    if (this._complianceRules && typeof this.isPage === 'function' && this.isPage('search')) {
      railFoot.append(this._uiGen.createDockSeparator())
      railFoot.append(this._uiGen.createDockButton({
        icon: 'rules',
        tooltip: 'Active hide rules — click to show',
        onClick: () => this._toggleComplianceRulesPanel(),
      }).addClass('bv-dock-compliance-rules-btn'))
    }

    if (this._dockConfig.orientations.length > 1) {
      railFoot.append(this._uiGen.createDockSeparator())
      railFoot.append(this._uiGen.createDockPositionButton({
        orientations: this._dockConfig.orientations,
        value: orientation,
        onCycle: () => this._cycleDockOrientation(),
      }))
    }

    this._uiGen.constructor.appendToBody(dock)
    this._configurationManager.refreshDockButtonStates()
    this._observeDockPanelSync(dock[0])
    let panel = UISection[0]
    let wasVisible = panel && this._isDockSlidePanelVisible(panel)
    requestAnimationFrame(() => {
      this._syncDockPanelPosition()
      if (wasVisible || this._isAlwaysShowSettingsPaneActive()) {
        this._showMainPanel(panel)
      } else {
        this._hideMainPanel(panel, false)
      }
    })
    $(window).off('resize.bvDockPanel').on('resize.bvDockPanel', () => this._syncDockPanelPosition())
  }

  _cycleDockOrientation()
  {
    if (!this._dockConfig || this._dockConfig.orientations.length <= 1) {
      return
    }
    if (!this._configurationManager.hasField(OPTION_DOCK_POSITION)) {
      return
    }
    let orientations = this._dockConfig.orientations
    let current = this._getDockOrientation()
    let index = orientations.indexOf(current)
    if (index < 0) {
      index = 0
    }
    this._dockOrientation = orientations[(index + 1) % orientations.length]
    void this._configurationManager.writeSetting(OPTION_DOCK_POSITION, this._dockOrientation).then(() => {
      this._rebuildDock()
    })
  }

  /**
   * @private
   */
  _rebuildDock()
  {
    if (this._dockPanelResizeObserver) {
      this._dockPanelResizeObserver.disconnect()
      this._dockPanelResizeObserver = null
    }
    this._dockRailMembershipSignature = null
    $('.bv-dock').remove()
    if (this._uiSection && this._dockConfig && !this._disableUI) {
      this._buildDock(this._uiSection)
    }
  }

  /**
   * @return {string}
   * @private
   */
  _getDockNameBarText()
  {
    let scriptName = this._dockConfig.scriptName?.trim()
    if (!scriptName) {
      return DOCK_BRAND_PREFIX.trim()
    }
    return this._dockConfig.showBranding ? DOCK_BRAND_PREFIX + scriptName : scriptName
  }

  /**
   * @return {string}
   * @private
   */
  _getDockOrientation()
  {
    if (this._dockOrientation && this._dockConfig.orientations.includes(this._dockOrientation)) {
      return this._dockOrientation
    }
    return this._dockConfig.defaultOrientation
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _loadDockOrientationFromStorage()
  {
    if (!this._dockConfig || !this._configurationManager.hasField(OPTION_DOCK_POSITION)) {
      return
    }
    let stored = await this._configurationManager.readSetting(OPTION_DOCK_POSITION)
    if (typeof stored === 'string' && this._dockConfig.orientations.includes(stored)) {
      this._dockOrientation = stored
    }
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _syncDockOrientationFromStorage()
  {
    if (!this._dockConfig || !this._configurationManager.hasField(OPTION_DOCK_POSITION)) {
      return
    }
    let stored = await this._configurationManager.readSetting(OPTION_DOCK_POSITION)
    if (typeof stored !== 'string' || !this._dockConfig.orientations.includes(stored)) {
      return
    }
    if (stored === this._dockOrientation) {
      return
    }
    this._dockOrientation = stored
    this._rebuildDock()
  }

  /**
   * @param {string} orientation
   * @private
   */
  _setDockPanelAnchor(orientation)
  {
    BrazenViewLayer.setDockSlidePanelOrientations(orientation)
    this._syncDockPanelPosition()
  }

  /**
   * @param {JQuery} panel
   * @private
   */
  _wireComplianceRulesSlidePanel(panel)
  {
    BrazenViewLayer.wireDockSlidePanel(panel[0], {
      onClose: () => this._hideDockSlidePanel(panel[0]),
      onMouseLeave: (target) => {
        if (this._isDockSlidePanelVisible(target)) {
          this._hideDockSlidePanel(target)
        }
      },
    })
  }

  /**
   * Pin dock-anchored slide panels beside the dock (and stack outward when needed).
   * Order is near-dock → outward: blocking review panels first, then settings furthest out
   * so opening Tag Discovery / human-interaction pushes `#bv-ui` up (bottom) or aside.
   * @protected
   */
  _syncDockPanelPosition()
  {
    if (!this._dockConfig || this._disableUI) {
      return
    }
    let dock = $('.bv-dock').first()[0]
    if (!dock) {
      return
    }
    BrazenViewLayer.syncDockSlidePanelStack(dock, this._getDockOrientation(), [
      document.getElementById('bv-human-interaction-panel'),
      document.getElementById('bv-tag-discovery-panel'),
      document.getElementById('bv-compliance-rules'),
      document.getElementById('bv-ui'),
    ].filter(Boolean))
  }

  /**
   * @param {HTMLElement} dock
   * @private
   */
  _observeDockPanelSync(dock)
  {
    if (this._dockPanelResizeObserver) {
      this._dockPanelResizeObserver.disconnect()
      this._dockPanelResizeObserver = null
    }
    if (!dock) {
      return
    }
    this._dockPanelResizeObserver = new ResizeObserver(() => {
      this._syncDockPanelPosition()
    })
    this._dockPanelResizeObserver.observe(dock)
    // Content height/width changes (discovery Similar load, settings tabs) must re-stack.
    for (let id of [
      'bv-human-interaction-panel',
      'bv-tag-discovery-panel',
      'bv-compliance-rules',
      'bv-ui',
    ]) {
      let panel = document.getElementById(id)
      if (panel) {
        this._dockPanelResizeObserver.observe(panel)
      }
    }
  }

  /**
   * @param {HTMLElement} panel
   * @return {boolean}
   * @private
   */
  _isDockSlidePanelVisible(panel)
  {
    return BrazenViewLayer.isDockSlidePanelVisible(panel)
  }

  /**
   * @param {HTMLElement} panel
   * @protected
   */
  _showDockSlidePanel(panel)
  {
    let orientation = this._getDockOrientation()
    BrazenViewLayer.showDockSlidePanel(panel, orientation, () => this._syncDockPanelPosition())
    this._syncDockPanelPosition()
    // Post-layout pass: first sync can run mid open (offscreen) or before height settles.
    requestAnimationFrame(() => {
      this._syncDockPanelPosition()
      this._observeDockPanelSync($('.bv-dock').first()[0])
    })
    this._updateDockSlidePanelButtonStates()
  }

  /**
   * @param {HTMLElement} panel
   * @param {boolean} [animate]
   * @protected
   */
  _hideDockSlidePanel(panel, animate = true)
  {
    BrazenViewLayer.hideDockSlidePanel(panel, this._getDockOrientation(), animate, () => {
      this._syncDockPanelPosition()
    })
    this._updateDockSlidePanelButtonStates()
  }

  /**
   * @param {HTMLElement|JQuery} UISection
   * @protected
   */
  _toggleMainPanel(UISection)
  {
    let panel = UISection?.[0] ?? UISection ?? document.getElementById('bv-ui')
    if (!panel) {
      return
    }
    if (this._isDockSlidePanelVisible(panel)) {
      this._hideDockSlidePanel(panel)
      return
    }
    this._showDockSlidePanel(panel)
  }

  /**
   * @param {HTMLElement} panel
   * @protected
   */
  _showMainPanel(panel)
  {
    this._showDockSlidePanel(panel)
  }

  /**
   * @param {HTMLElement} panel
   * @param {boolean} [animate]
   * @protected
   */
  _hideMainPanel(panel, animate = true)
  {
    this._hideDockSlidePanel(panel, animate)
  }

  /**
   * @protected
   */
  _toggleComplianceRulesPanel()
  {
    let panel = document.getElementById('bv-compliance-rules')
    if (!panel) {
      return
    }
    if (this._isDockSlidePanelVisible(panel)) {
      this._hideDockSlidePanel(panel)
      return
    }
    this._renderComplianceRulesPanelContent()
    this._showDockSlidePanel(panel)
  }

  /**
   * @private
   */
  _updateDockSlidePanelButtonStates()
  {
    BrazenViewLayer.updateDockSlidePanelToggleButtons()
  }

  /**
   * @private
   */
  _updateDockMenuButtonState()
  {
    this._updateDockSlidePanelButtonStates()
  }

  /**
   * @protected
   */
  _refreshDockButtonStates()
  {
    let railBody = $('.bv-dock .bv-dock-rail-body').first()
    this._configurationManager.setDockIncludeContext(this)
    let membership = this._configurationManager.getDockRailMembershipSignature()
    // Full composeDockRail clears every slot (CSS slide-outs thrash open/closed). Only
    // rebuild when which root buttons belong on the rail actually changed.
    let needsLayout = membership !== this._dockRailMembershipSignature
    this.refreshDockInterface(railBody, {layout: needsLayout})
    this._syncDockPanelPosition()
  }

  /**
   * @param {{manager?: BrazenConfigurationManager, source?: string, local?: boolean}} event
   * @private
   */
  _scheduleConfigurationChange(event)
  {
    if (event?.source === 'ledger') {
      this._ledgerComplianceDirty = true
    }
    let pending = this._pendingConfigurationChange
    if (pending && pending.source !== event.source && event.source !== 'all') {
      // Preserve ledger dirty when coalescing ledger + queue/state into `all`.
      if (pending.source === 'ledger' || event?.source === 'ledger') {
        this._ledgerComplianceDirty = true
      }
      event = {...event, source: 'all'}
    }
    this._pendingConfigurationChange = event
    if (this._configurationChangeScheduled) {
      return
    }
    this._configurationChangeScheduled = true
    queueMicrotask(() => {
      this._configurationChangeScheduled = false
      let scheduledEvent = this._pendingConfigurationChange
      this._pendingConfigurationChange = null
      if (scheduledEvent) {
        void this._handleConfigurationChange(scheduledEvent)
      }
    })
  }

  /**
   * @param {{manager?: BrazenConfigurationManager, source?: string, local?: boolean}} event
   * @return {Promise<void>}
   * @private
   */
  async _handleConfigurationChange(event)
  {
    let manager = event.manager ?? this._configurationManager
    let source = event.source ?? 'all'
    let local = event.local !== false
    // Queue/ledger/runtime writes must not wipe unsaved settings controls.
    let runtimePipeline = source === 'downloadResolutionQueue' || source === 'downloadQueue' ||
        source === 'downloadManagerState'
    // Reload settings controls from persisted cache only after Apply/Save/reset (local
    // settings|all) or a foreign cross-tab config revision. Local tag/bookmark attribute
    // writes must not discard unsaved textarea edits.
    let reloadsSettingsControls =
        (local && (source === 'settings' || source === 'all')) ||
        (!local && (source === 'all' || source === 'settings' || source === 'tags' || source === 'bookmarks'))

    if (reloadsSettingsControls) {
      manager.refreshMountedFields()
      void this._syncDockOrientationFromStorage()
    }
    if (reloadsSettingsControls || source === 'bookmarks') {
      await manager.reloadBookmarkFields()
    }
    // Download processors put queue/state rows continuously — do not rebuild the dock on every put.
    if (!runtimePipeline) {
      this._refreshDockButtonStates()
    }
    if (reloadsSettingsControls || source === 'tags') {
      this._validateCompliance()
    }
    // Ledger claims fire once per download — do not re-dim the whole search page each time.
    // Debounce a Hide Downloaded refresh so newly recorded ids can hide without selection flicker.
    // `_ledgerComplianceDirty` survives coalesce with downloadQueue / downloadManagerState puts
    // (including when the merged event source becomes `all`).
    if (source === 'ledger' || this._ledgerComplianceDirty) {
      this._ledgerComplianceDirty = false
      this._scheduleLedgerComplianceRefresh()
    }
    if (!runtimePipeline) {
      Utilities.processEventHandlerQueue(this._onConfigurationChange, [event])
    }
  }

  /**
   * Soft compliance refresh after ledger claims (Hide Downloaded only).
   * @private
   */
  _scheduleLedgerComplianceRefresh()
  {
    if (!this._getConfig(OPTION_HIDE_DOWNLOADED_MEDIA) || !this._shouldRunCompliance()) {
      return
    }
    if (this._ledgerComplianceRefreshTimer) {
      clearTimeout(this._ledgerComplianceRefreshTimer)
    }
    this._ledgerComplianceRefreshTimer = setTimeout(() => {
      this._ledgerComplianceRefreshTimer = null
      this._validateCompliance()
    }, 400)
  }

  /**
   * @param {JQuery} item
   * @param {string} attributeName
   * @returns {*}
   * @protected
   */
  _get(item, attributeName)
  {
    return this._itemAttributesResolver.get(item, attributeName)
  }

  /**
   * @param {string} config
   * @returns {*}
   * @protected
   */
  _getConfig(config)
  {
    return this._configurationManager.getValue(config)
  }

  /**
   * @param {*} result
   * @return {{complies: boolean, rule: string|null}}
   * @protected
   */
  _unwrapComplianceResult(result)
  {
    if (result !== null && typeof result === 'object' && 'complies' in result) {
      return {complies: !!result.complies, rule: result.rule ?? null}
    }
    return {complies: !!result, rule: null}
  }

  /**
   * @return {{filterKey: string, filterLabel: string, rules: {label: string, count: number}[]}[]}
   * @protected
   */
  _getComplianceRuleReport()
  {
    if (!this._complianceRules) {
      return []
    }
    return this._complianceRules.getReport((filterKey) => this._configurationManager.getField(filterKey)?.title ?? filterKey)
  }

  /**
   * @protected
   */
  _renderComplianceRulesPanelContent()
  {
    if (!this._complianceRulesPanel?.length) {
      return
    }
    this._uiGen.renderComplianceRulesPanelContent(this._complianceRulesPanel, this._getComplianceRuleReport(), {
      canRemoveRule: (filterKey, ruleLabel) => this._canRemoveComplianceRule(filterKey, ruleLabel),
      onRemoveRule: (filterKey, ruleLabel) => {
        this._removeComplianceRule(filterKey, ruleLabel)
        this._renderComplianceRulesPanelContent()
      },
    })
  }

  /**
   * @protected
   */
  _showComplianceRulesModal()
  {
    this._toggleComplianceRulesPanel()
  }

  /**
   * @param {string} filterKey
   * @param {string} ruleLabel
   * @return {boolean}
   * @protected
   */
  _canRemoveComplianceRule(filterKey, ruleLabel)
  {
    return false
  }

  /**
   * @param {string} filterKey
   * @param {string} ruleLabel
   * @protected
   */
  _removeComplianceRule(filterKey, ruleLabel)
  {
  }

  _handleComplianceForBlacklistFilter(item, attribute, blacklistRuleset)
  {
    let itemTags = this._get(item, attribute)

    if (itemTags === null || !itemTags.length) {
      return true
    }

    if (blacklistRuleset && !Array.isArray(blacklistRuleset)) {
      return true
    }

    for (let rule of blacklistRuleset) {
      if (typeof rule === 'string') {
        if (itemTags.includes(rule)) {
          return {complies: false, rule: rule}
        }
        continue
      }

      let isBlacklisted = true
      for (let tag of rule) {
        if (!itemTags.includes(tag)) {
          isBlacklisted = false
          break
        }
      }
      if (isBlacklisted) {
        return {complies: false, rule: rule.join(' & ')}
      }
    }
    return true
  }

  /**
   * @private
   */
  _onApplyNewSettings()
  {
    this._configurationManager.update()
    this._validateCompliance()
    this.refreshDockInterface($('.bv-dock .bv-dock-rail-body').first())
  }

  /**
   * @private
   */
  _onBackupSettings()
  {
    if (this._configurationManager.isIdbBlocked()) {
      alert('IndexedDB is unavailable — backup is disabled.')
      return
    }
    void this._configurationManager.backup()
  }

  /**
   * @private
   */
  _onResetSettings()
  {
    void this._configurationManager.revertChanges()
  }

  /**
   * @private
   */
  _onRestoreSettings()
  {
    void this._configurationManager.restore(new Response($('#restore-settings').prop('files')[0]))
  }

  /**
   * @protected
   */
  _showIdbBlockedBanner()
  {
    BrazenViewLayer.ensureIdbBlockedBanner()
  }

  /**
   * @private
   */
  async _onSaveSettings()
  {
    if (this._configurationManager.isIdbBlocked()) {
      alert('IndexedDB is unavailable — settings cannot be saved.')
      return
    }
    try {
      await this._configurationManager.save()
    } catch (error) {
      console.log('[BrazenFramework] save settings failed:', error)
      alert('Settings could not be saved. See the browser console for details.')
    }
  }

  /**
   * @param {string} configKey
   * @param {function(*)} actionCallback
   * @param {function(*, function?): boolean} validationCallback
   * @returns {BrazenFramework}
   * @protected
   */
  _performComplexOperation(configKey, validationCallback, actionCallback)
  {
    return this._performOperation(configKey, actionCallback, validationCallback)
  }

  /**
   * @param {string} configKey
   * @param {function(*)} actionCallback
   * @param {function(*, function?): boolean|null} validationCallback
   * @returns {BrazenFramework}
   * @protected
   */
  _performOperation(configKey, actionCallback, validationCallback = null)
  {
    let configField = this._configurationManager.getField(configKey)
    let defaultValidationCallback = this._configurationManager.generateValidationCallback(configKey)
    let validationCallbackParams
    let values = configField.optimized ?? configField.value

    if (validationCallback) {
      validationCallbackParams = [values, defaultValidationCallback]
    } else {
      validationCallbackParams = [values]
      validationCallback = defaultValidationCallback
    }
    if (Utilities.callEventHandler(validationCallback, validationCallbackParams, true)) {
      actionCallback(values)
    }
    return this
  }

  /**
   * @param {string} flagConfigKey
   * @param {string|null} configKey
   * @param {function(*)} actionCallback
   * @param {function(*, function?): boolean} validationCallback
   * @returns {BrazenFramework}
   * @protected
   */
  _performTogglableComplexOperation(flagConfigKey, configKey, validationCallback, actionCallback)
  {
    if (this._getConfig(flagConfigKey)) {
      this._performComplexOperation(configKey ?? flagConfigKey, validationCallback, actionCallback)
    }
    return this
  }

  /**
   * @param {string} flagConfigKey
   * @param {string} configKey
   * @param {function(*)} actionCallback
   * @param {function(*, function?): boolean|null} validationCallback
   * @returns {BrazenFramework}
   * @protected
   */
  _performTogglableOperation(flagConfigKey, configKey, actionCallback, validationCallback = null)
  {
    if (this._configurationManager.getValue(flagConfigKey)) {
      this._performOperation(configKey, actionCallback, validationCallback)
    }
    return this
  }

  /**
   * @param {boolean} enableCondition
   * @param {PaginatorConfiguration} configuration
   * @protected
   */
  _setupPaginator(enableCondition, configuration)
  {
    if (enableCondition) {
      configuration.itemSelectors = this._config.itemSelectors
      this._paginator = new BrazenPaginator(configuration)
    }
    this._configurationManager.addNumberField(CONFIG_PAGINATOR_LIMIT, 1, 50).
        setTitle('Pagination Limit').
        setHelpText('Limit paginator to concatenate the specified number of maximum pages.')
    this._configurationManager.addNumberField(CONFIG_PAGINATOR_THRESHOLD, 1, 1000).
        setTitle('Pagination Threshold').
        setHelpText('Make paginator ensure the specified number of minimum results.')
  }

  /**
   * @return {BrazenSubscriptionsLoader}
   * @protected
   */
  _setupSubscriptionLoader()
  {
    this._subscriptionsLoader = new BrazenSubscriptionsLoader(
        (status) => this._subscriptionsLoaderButton.text(status),
        (subscriptions) => {
          this._configurationManager.getField(STORE_SUBSCRIPTIONS).value = subscriptions.length ? '"' +
              subscriptions.join('""') + '"' : ''
          this._configurationManager.save()
          $('#subscriptions-loader').prop('disabled', false)
        })

    this._subscriptionsLoaderButton = this._uiGen.createFormButton(
        'Load Subscriptions',
        'Makes a copy of your subscriptions in cache for related filters.',
        (event) => {
          if (this._config.isUserLoggedIn) {
            $(event.currentTarget).prop('disabled', true)
            this._subscriptionsLoader.run()
          } else {
            this._showNotLoggedInAlert()
          }
        }).attr('id', 'subscriptions-loader')

    return this._subscriptionsLoader
  }

  /**
   * @protected
   */
  _showNotLoggedInAlert()
  {
    alert('You need to be logged in to use this functionality')
  }

  /**
   * @param {boolean} firstRun
   * @protected
   */
  _validateCompliance(firstRun = false)
  {
    if (!this._shouldRunCompliance()) {
      return
    }

    void this._prepareTagComplianceRuntime().then(() => this._runComplianceValidation(firstRun))
  }

  /**
   * @return {Promise<void>}
   * @protected
   */
  async _prepareTagComplianceRuntime()
  {
    if (this._configurationManager.canPersist()) {
      await this._configurationManager.prepareTagComplianceRuntime()
    }
  }

  /**
   * @param {boolean} firstRun
   * @return {Promise<void>}
   * @protected
   */
  async _runComplianceValidation(firstRun = false)
  {
    if (!this._shouldRunCompliance()) {
      return
    }

    let itemLists = $(this._config.itemListSelectors)
    if (firstRun) {
      this._disconnectItemListChildObservers()
      let listNodes = itemLists.toArray()
      for (let itemList of listNodes) {
        let itemListObject = $(itemList)

        if (this._paginator && itemListObject.is(this._paginator.getItemListSelector())) {

          let observer = ChildObserver.create().onNodesAdded((itemsAdded) => {
            void this._complyItemsList($(itemsAdded), true).then(() => {
              this._paginator.run(this._getConfig(CONFIG_PAGINATOR_THRESHOLD), this._getConfig(CONFIG_PAGINATOR_LIMIT))
            })
          }).observe(itemList)
          this._itemListChildObservers.push(observer)

        } else {
          let observer = ChildObserver.create().onNodesAdded((itemsAdded) => {
            void this._complyItemsList($(itemsAdded), true)
          }).observe(itemList)
          this._itemListChildObservers.push(observer)
        }

        await this._complyItemsList(itemListObject)
      }
    } else {
      this._statistics.reset()
      if (this._complianceRules) {
        this._complianceRules.reset()
      }
      let listNodes = itemLists.toArray()
      for (let itemsList of listNodes) {
        await this._complyItemsList($(itemsList))
      }
    }
    if (this._paginator) {
      this._paginator.run(this._getConfig(CONFIG_PAGINATOR_THRESHOLD), this._getConfig(CONFIG_PAGINATOR_LIMIT))
    }
    this._itemAttributesResolver.completeResolutionRun()
    let compliancePanel = document.getElementById('bv-compliance-rules')
    if (compliancePanel && this._isDockSlidePanelVisible(compliancePanel)) {
      this._renderComplianceRulesPanelContent()
    }
  }

  /**
   * @param {JQuery} item
   * @return {boolean}
   * @protected
   */
  _validateItemWhiteList(item)
  {
    let field = this._configurationManager.getField(FILTER_TEXT_WHITELIST)
    if (field) {
      let validationResult = field.value.length
          ? Validator.regexMatches(this._get(item, ITEM_NAME), field.optimized)
          : true
      this._statistics.record(FILTER_TEXT_WHITELIST, validationResult)
      return validationResult
    }
    return true
  }

  /**
   * Whether the download duplicate ledger is recording / skipping (Skip Duplicate Downloads).
   * @return {boolean}
   * @protected
   */
  _isDownloadDuplicateLedgerActive()
  {
    if (!this._downloadDuplicateLedgerConfig) {
      return false
    }
    return !!this._getConfig(this._downloadDuplicateLedgerConfig.enableConfigKey)
  }

  /**
   * True when ledger claims should run — Skip Duplicate and/or Hide Downloaded Media.
   * Hide needs claims in the ledger even when the user has not opted into skip-on-download.
   * @return {boolean}
   * @protected
   */
  _shouldClaimDownloadDuplicateLedger()
  {
    if (!this._downloadDuplicateLedgerConfig) {
      return false
    }
    return this._isDownloadDuplicateLedgerActive() || !!this._getConfig(OPTION_HIDE_DOWNLOADED_MEDIA)
  }

  /**
   * Sync membership for compliance filters — uses the bounded positive cache only
   * (primed via {@link _primeComplianceCaches} / claims). Does not hit IndexedDB.
   *
   * @param {string|null|undefined} downloadId
   * @return {boolean}
   * @protected
   */
  _isDownloadLedgerIdRecorded(downloadId)
  {
    let ledgerField = this._getDownloadDuplicateLedgerField()
    if (!ledgerField || downloadId === null || downloadId === undefined) {
      return false
    }
    let id = String(downloadId).trim()
    return !!id && ledgerField.downloadedIds.has(id)
  }

  /**
   * Async membership for download pipeline / skip-duplicate (IndexedDB when not cached).
   *
   * @param {string|null|undefined} downloadId
   * @return {Promise<boolean>}
   * @protected
   */
  async _isDownloadLedgerIdRecordedAsync(downloadId)
  {
    let ledgerField = this._getDownloadDuplicateLedgerField()
    if (!ledgerField || downloadId === null || downloadId === undefined) {
      return false
    }
    let id = String(downloadId).trim()
    if (!id) {
      return false
    }
    return ledgerField.has(id)
  }

  /**
   * @param {*} item
   * @return {string|null}
   * @protected
   */
  _getDownloadDuplicateLedgerId(item)
  {
    if (!this._shouldClaimDownloadDuplicateLedger()) {
      return null
    }
    let id = Utilities.callEventHandler(this._downloadDuplicateLedgerConfig.getDownloadId, [item], null)
    if (id === null || id === undefined || !String(id).length) {
      return null
    }
    return String(id)
  }

  /**
   * Invalidates the bounded ledger positive cache (does not load the ledger into RAM).
   *
   * @return {Promise<void>}
   * @protected
   */
  async _reloadDownloadDuplicateLedgerFromStorage()
  {
    await this._getDownloadDuplicateLedgerField()?.reload()
  }

  /**
   * @return {Promise<void>}
   * @protected
   */
  async _mergeDownloadDuplicateLedgerFromStorage()
  {
    await this._getDownloadDuplicateLedgerField()?.reload()
  }

  /**
   * @param {string|null|undefined} downloadId
   * @return {boolean}
   * @protected
   */
  _isDownloadDuplicate(downloadId)
  {
    if (!this._isDownloadDuplicateLedgerActive()) {
      return false
    }
    return this._isDownloadLedgerIdRecorded(downloadId)
  }

  /**
   * @param {string|null|undefined} downloadId
   * @return {Promise<boolean>}
   * @protected
   */
  async _isDownloadDuplicateAsync(downloadId)
  {
    if (!this._isDownloadDuplicateLedgerActive()) {
      return false
    }
    return this._isDownloadLedgerIdRecordedAsync(downloadId)
  }

  /**
   * Reserves a download before `GM_download` runs. Tampermonkey often does not fire
   * `onload` in default download mode, so waiting for success would allow repeat attempts.
   *
   * @param {string|null|undefined|Array<string|null|undefined>} downloadId
   * @return {Promise<boolean>}
   * @protected
   */
  async _claimDownloadDuplicateLedgerSlot(downloadId)
  {
    let ledgerField = this._getDownloadDuplicateLedgerField()
    if (!ledgerField || !this._shouldClaimDownloadDuplicateLedger()) {
      return true
    }
    let claimed = Array.isArray(downloadId)
        ? await ledgerField.claim(downloadId)
        : await ledgerField.claim(downloadId === null || downloadId === undefined ? [] : [downloadId])
    if (claimed) {
      // Do not depend on config-event ordering with hot downloadQueue puts.
      this._scheduleLedgerComplianceRefresh()
    }
    return claimed
  }

  /**
   * @param {{name?: string|null}} download
   * @protected
   */
  _handleDuplicateDownloadSkipped(download)
  {
  }

  /**
   * Fired when `GM_download` reports success (`onload`). Tampermonkey often skips this
   * in default download mode — apps must not rely on it for ledger claims.
   *
   * @param {{name?: string|null, restorePictureOnFailure?: boolean}} download
   * @protected
   */
  _handleDownloadSucceeded(download)
  {
  }

  /**
   * @param {{name?: string|null}} download
   * @param {*} error
   * @protected
   */
  _handleDownloadFailed(download, error)
  {
    console.log('Download error:', error?.error, error?.details)
  }

  /**
   * Disconnects compliance list observers retained from the first validation run.
   * @protected
   */
  _disconnectItemListChildObservers()
  {
    for (let observer of this._itemListChildObservers) {
      observer.disconnect()
    }
    this._itemListChildObservers = []
  }

  /**
   * @param {string|string[]} names
   * @param {Function} setup
   * @return {BrazenFramework}
   * @protected
   */
  _forPage(names, setup)
  {
    return this._forPages(Array.isArray(names) ? names : [names], setup)
  }

  /**
   * @param {string[]} names
   * @param {Function} setup
   * @return {BrazenFramework}
   * @protected
   */
  _forPages(names, setup)
  {
    if (names.some((name) => this._activePages.has(name))) {
      Utilities.callEventHandler(setup)
    }
    return this
  }

  /**
   * @param {string|string[]} names
   * @param {Function} callback
   * @return {Function}
   * @protected
   */
  _gatePage(names, callback)
  {
    let pageNames = Array.isArray(names) ? names : [names]
    return (...args) => {
      if (pageNames.some((name) => this._activePages.has(name))) {
        return Utilities.callEventHandler(callback, args)
      }
    }
  }

  /**
   * @param {string|string[]} names
   * @param {string} configKey
   * @param {function(*)} actionCallback
   * @param {function(*, function?): boolean|null} validationCallback
   * @return {BrazenFramework}
   * @protected
   */
  _performOperationOnPage(names, configKey, actionCallback, validationCallback = null)
  {
    return this._forPage(names, () => this._performOperation(configKey, actionCallback, validationCallback))
  }

  /**
   * @param {string|string[]} names
   * @param {string} flagConfigKey
   * @param {string} configKey
   * @param {function(*)} actionCallback
   * @param {function(*, function?): boolean|null} validationCallback
   * @return {BrazenFramework}
   * @protected
   */
  _performTogglableOperationOnPage(names, flagConfigKey, configKey, actionCallback, validationCallback = null)
  {
    return this._forPage(names, () => this._performTogglableOperation(flagConfigKey, configKey, actionCallback, validationCallback))
  }

  /**
   * Phase 0: evaluate page detectors and resolve layout-aware selectors.
   * @protected
   */
  _initPageDetection()
  {
    this._activePages.clear()
    this._layouts.clear()

    for (let [name, page] of this._pages) {
      if (Utilities.callEventHandler(page.detect)) {
        this._activePages.add(name)
        if (page.layout) {
          this._layouts.set(name, Utilities.callEventHandler(page.layout))
        }
      }
    }

    this._resolveEffectiveSelectors()
  }

  /**
   * @param {SelectorConfig} value
   * @return {JQuery.Selector}
   * @protected
   */
  _resolveSelectorValue(value)
  {
    if (value === null || value === undefined) {
      return value
    }
    if (typeof value === 'function') {
      return Utilities.callEventHandler(value)
    }
    if (typeof value === 'string') {
      return value
    }
    if (typeof value === 'object') {
      for (let pageName of this._activePages) {
        if (pageName in value) {
          return value[pageName]
        }
        let layout = this._layouts.get(pageName)
        if (layout && layout in value) {
          return value[layout]
        }
      }
      if ('default' in value) {
        return value.default
      }
    }
    return value
  }

  /**
   * Resolves selector config values once after page/layout detection.
   * @protected
   */
  _resolveEffectiveSelectors()
  {
    this._config.itemListSelectors = this._resolveSelectorValue(this._config.itemListSelectors)
    this._config.itemSelectors = this._resolveSelectorValue(this._config.itemSelectors)
    this._config.itemNameSelector = this._resolveSelectorValue(this._config.itemNameSelector)
    if (this._config.itemLinkSelector !== undefined) {
      this._config.itemLinkSelector = this._resolveSelectorValue(this._config.itemLinkSelector)
    }
  }

  /**
   * Phase 1: run config-independent page operations for active pages.
   * @return {boolean} `true` when an operation halted the boot pipeline.
   * @protected
   */
  _runPageOperations()
  {
    for (let {pages, operation} of this._pageOperations) {
      if (pages.some((name) => this._activePages.has(name))) {
        let result = Utilities.callEventHandler(operation)
        if (result === true || (result !== null && typeof result === 'object' && result.haltInit === true)) {
          return true
        }
      }
    }
    return false
  }

  /**
   * @return {boolean}
   * @protected
   */
  _shouldRunFullInit()
  {
    if (this._pages.size === 0) {
      return Utilities.callEventHandler(this._onValidateInit)
    }
    let initPages = this._initPages ?? [...this._pages.keys()]
    let hasInitPage = initPages.some((name) => this._activePages.has(name))
    return hasInitPage && Utilities.callEventHandler(this._onValidateInit)
  }

  /**
   * @return {boolean}
   * @protected
   */
  _shouldRunCompliance()
  {
    if (!this._complianceEnabled) {
      return false
    }
    if (this._pages.size === 0) {
      return true
    }
    if (this._compliancePages === null) {
      return this._activePages.size > 0
    }
    return this._compliancePages.some((name) => this._activePages.has(name))
  }

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

  /**
   * Initialize the script and do basic UI removals
   */
  async init()
  {
    this._initPageDetection()

    if (this._runPageOperations()) {
      return
    }

    if (!this._shouldRunFullInit()) {
      return
    }

    Utilities.processEventHandlerQueue(this._onBeforeFullInit)

    await this._configurationManager.initialize()

    if (this._configurationManager.isIdbBlocked()) {
      this._showIdbBlockedBanner()
    }

    await this._configurationManager.reloadBookmarkFields()
    Utilities.processEventHandlerQueue(this._onBookmarksHydrate)

    await this._configurationManager.ensureTagRuleSetsCompiled()

    if (this._downloadManager) {
      await this._downloadManager.initialize()
      if (typeof BRAZEN_FRAMEWORK_BUILD !== 'undefined' && !BrazenFramework._buildLogged) {
        BrazenFramework._buildLogged = true
        console.info('[BrazenFramework] build', BRAZEN_FRAMEWORK_BUILD,
            typeof BRAZEN_VIEW_LAYER_BUILD !== 'undefined' ? BRAZEN_VIEW_LAYER_BUILD : '(view layer unknown)',
            typeof BRAZEN_DOWNLOAD_MANAGER_BUILD !== 'undefined' ? BRAZEN_DOWNLOAD_MANAGER_BUILD : '(dm unknown)')
      }
    }

    await this._loadDockOrientationFromStorage()

    this._itemAttributesResolver.addAttribute(ITEM_PROCESSED_ONCE, () => false)

    if (this._config.itemNameSelector !== '') {
      this._itemAttributesResolver.addAttribute(ITEM_NAME, (item) => item.find(this._config.itemNameSelector).text())
    }

    if (this._paginator) {
      this._paginator.initialize()
    }

    Utilities.processEventHandlerQueue(this._onBeforeUIBuild)

    if (!this._disableUI) {

      this._embedUI(this._uiGen.createSettingsSection().append(this._userInterface))
      Utilities.processEventHandlerQueue(this._onAfterUIBuild)

      this._configurationManager.updateInterface()
    }

    // After the dock exists — early DM init must not flash/hide the review panel.
    if (this._downloadManager) {
      await this._downloadManager.restoreTagDiscoveryPanelIfNeeded()
    }

    this._validateCompliance(true)

    Utilities.processEventHandlerQueue(this._onAfterInitialization)
  }

  /**
   * @param {string} name
   * @param {Function|PageDefinition} detectorOrConfig
   * @return {BrazenFramework}
   */
  definePage(name, detectorOrConfig)
  {
    if (typeof detectorOrConfig === 'function') {
      this._pages.set(name, {detect: detectorOrConfig})
    } else {
      this._pages.set(name, detectorOrConfig)
    }
    return this
  }

  /**
   * @param {{[name: string]: Function|PageDefinition}} pages
   * @return {BrazenFramework}
   */
  definePages(pages)
  {
    for (let name in pages) {
      this.definePage(name, pages[name])
    }
    return this
  }

  /**
   * @param {string} name
   * @return {boolean}
   */
  isPage(name)
  {
    return this._activePages.has(name)
  }

  /**
   * @param {...string} names
   * @return {boolean}
   */
  anyPage(...names)
  {
    return names.some((name) => this._activePages.has(name))
  }

  /**
   * @return {string[]}
   */
  getActivePages()
  {
    return [...this._activePages]
  }

  /**
   * @param {string} [name]
   * @return {string|null}
   */
  getLayout(name)
  {
    if (name !== undefined) {
      return this._layouts.get(name) ?? null
    }
    for (let pageName of this._activePages) {
      if (this._layouts.has(pageName)) {
        return this._layouts.get(pageName) ?? null
      }
    }
    return null
  }

  /**
   * @param {Function} handler
   * @return {BrazenFramework}
   */
  onBookmarksHydrate(handler)
  {
    this._onBookmarksHydrate.push(handler)
    return this
  }

  /**
   * @param {function({manager: BrazenConfigurationManager, source: string, local: boolean})} handler
   * @return {BrazenFramework}
   */
  onConfigurationChange(handler)
  {
    this._onConfigurationChange.push(handler)
    return this
  }

  /**
   * Fired when substitution link-mode UI state changes (including cancel with no persist).
   * @param {Function} handler
   * @return {BrazenFramework}
   */
  onTagSubstitutionUiChange(handler)
  {
    this._onTagSubstitutionUiChange.push(handler)
    return this
  }

  /**
   * @param {string|null} [fieldKey='filename-tag-substitutions']
   * @return {boolean}
   */
  hasTagSubstitutionField(fieldKey = 'filename-tag-substitutions')
  {
    return !!this._configurationManager?.hasField(fieldKey)
  }

  /**
   * @param {string} tagName
   * @param {{normalize?: function(string): string, fieldKey?: string}} [options]
   * @return {'idle'|'linkingSource'|'isSubject'|'linkingTarget'}
   */
  getTagSubstitutionUiState(tagName, options = {})
  {
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let fieldKey = options.fieldKey ?? 'filename-tag-substitutions'
    let normalized = normalize(tagName)
    let source = this._tagSubstitutionLinkSource
    if (source != null) {
      return normalize(source) === normalized ? 'linkingSource' : 'linkingTarget'
    }
    if (this._configurationManager.hasTagSubstitutionSubject(normalized, fieldKey)) {
      return 'isSubject'
    }
    return 'idle'
  }

  /**
   * Advance or apply the substitution link-mode flow for a tag.
   * @param {string} tagName
   * @param {{normalize?: function(string): string, fieldKey?: string, tag?: {name: string, type?: string|null}, source?: string}} [options]
   * @return {Promise<void>}
   */
  async toggleTagSubstitutionLink(tagName, options = {})
  {
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let fieldKey = options.fieldKey ?? 'filename-tag-substitutions'
    let state = this.getTagSubstitutionUiState(tagName, {normalize, fieldKey})
    let normalized = normalize(tagName)
    let tag = options.tag ?? {name: tagName}
    switch (state) {
      case 'idle':
        this._tagSubstitutionLinkSource = normalized
        this._tagSubstitutionLinkSourceType = tag.type ?? null
        this._notifyTagSubstitutionUiChange()
        break
      case 'linkingSource':
        this._tagSubstitutionLinkSource = null
        this._tagSubstitutionLinkSourceType = null
        this._notifyTagSubstitutionUiChange()
        break
      case 'isSubject': {
        this._tagSubstitutionLinkSource = null
        this._tagSubstitutionLinkSourceType = null
        // clearTagSubstitution paints optimistically then reconciles IDB.
        let clearPersist = this._configurationManager.clearTagSubstitution(normalized, fieldKey)
        this._notifyTagSubstitutionUiChange()
        await clearPersist
        break
      }
      case 'linkingTarget': {
        let source = this._tagSubstitutionLinkSource
        let sourceType = this._tagSubstitutionLinkSourceType
        this._tagSubstitutionLinkSource = null
        this._tagSubstitutionLinkSourceType = null
        // End of link flow: optimistic cache + UI first; ensure/type promote inside set.
        let setPersist = source ?
            this._configurationManager.setTagSubstitution(source, normalized, fieldKey, {
              subjectTypeName: sourceType,
              replacementTypeName: tag.type ?? null,
              source: options.source ?? 'tag-action',
            }) :
            Promise.resolve(true)
        this._notifyTagSubstitutionUiChange()
        await setPersist
        break
      }
    }
  }

  /**
   * @param {string} tagName
   * @param {{normalize?: function(string): string, fieldKey?: string, tag?: {name: string, type?: string|null}, className?: string, iconClass?: string, onAfterToggle?: function(): void}} [options]
   * @return {JQuery}
   */
  createTagSubstitutionActionButton(tagName, options = {})
  {
    let normalize = options.normalize
    let fieldKey = options.fieldKey
    return BrazenViewLayer.createTagSubstitutionActionButton({
      state: this.getTagSubstitutionUiState(tagName, {normalize, fieldKey}),
      className: options.className,
      iconClass: options.iconClass,
      onClick: () => {
        // Link-mode steps notify sync; commit/clear paint optimistically before IDB settles.
        let pending = this.toggleTagSubstitutionLink(tagName, {
          normalize,
          fieldKey,
          tag: options.tag ?? {name: tagName},
        })
        options.onAfterToggle?.()
        void pending.catch(() => options.onAfterToggle?.())
      },
    })
  }

  /**
   * @private
   */
  _notifyTagSubstitutionUiChange()
  {
    Utilities.processEventHandlerQueue(this._onTagSubstitutionUiChange, [])
  }

  /**
   * @param {string} optionKey
   * @protected
   */
  _enableConfigOption(optionKey)
  {
    if (!optionKey || !this._configurationManager?.hasField(optionKey)) {
      return
    }
    if (this._configurationManager.canPersist()) {
      if (!this._configurationManager.getValue(optionKey)) {
        void this._configurationManager.writeSetting(optionKey, true)
      }
      return
    }
    let enableField = this._configurationManager.getField(optionKey)
    if (enableField && !enableField.value) {
      enableField.value = true
      if (enableField.element) {
        enableField.updateUserInterface()
      }
    }
  }

  /**
   * @param {{name: string, type?: string|null}|string} tag
   * @param {function(string): string} normalize
   * @param {string} [source='tag-action']
   * @return {Promise<void>}
   * @private
   */
  async _ensureTagEntityForAction(tag, normalize, source = 'tag-action')
  {
    let name = typeof tag === 'string' ? tag : tag?.name
    if (!this._configurationManager.canPersist() || !name) {
      return
    }
    let normalized = normalize(name)
    if (!normalized) {
      return
    }
    let typeName = typeof tag === 'object' ? (tag.type ?? null) : null
    let cached = this._configurationManager.getCachedTagEntry(normalized)
    // Skip resolve/put when the row already has a confirmed type (or no type to promote).
    // Still promote when discovery/sidebar left only lastSeenTypeEntryId.
    if (cached && (cached.typeEntryId != null || !typeName)) {
      return
    }
    await this._configurationManager.getTagRuntime()?.ensureTag(normalized,
        this._configurationManager.createTagContext({typeName, source}))
  }

  /**
   * Toggle a TAG_FIELD_SPEC sole attribute (blacklist / explore / ignore).
   * @param {string} tagName
   * @param {{fieldKey: string, normalize?: function(string): string, ensureOptionKey?: string, tag?: {name: string, type?: string|null}, source?: string}} options
   * @return {Promise<boolean>}
   */
  async toggleTagSoleAttribute(tagName, options = {})
  {
    let fieldKey = options.fieldKey
    if (!fieldKey || !this._configurationManager.hasField(fieldKey)) {
      return false
    }
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let tag = options.tag ?? {name: tagName}
    let normalized = normalize(tagName)
    if (!normalized) {
      return false
    }
    let persistKey = `${fieldKey}\0${normalized}`
    if (this._tagAttributePersistKeys.has(persistKey)) {
      return false
    }
    this._tagAttributePersistKeys.add(persistKey)
    try {
      if (options.ensureOptionKey) {
        this._enableConfigOption(options.ensureOptionKey)
      }
      // Type promote happens inside patchAttributes/ensureTag — do not block the optimistic paint.
      let context = this._configurationManager.createTagContext({
        typeName: tag.type ?? null,
        source: options.source ?? 'tag-action',
      })
      if (this._configurationManager.canPersist()) {
        await this._configurationManager.toggleTagRule(fieldKey, normalized, context)
        if (fieldKey === FILTER_TAG_BLACKLIST || fieldKey === 'explored-tags-tracker') {
          await this._configurationManager.ensureTagRuleSetsCompiled()
        }
        return true
      }
      let field = this._configurationManager.getField(fieldKey)
      field?.toggleRule?.(normalized, normalized)
      await this._configurationManager.save()
      return true
    } finally {
      this._tagAttributePersistKeys.delete(persistKey)
    }
  }

  /**
   * @param {string} tagName
   * @param {{fieldKey: string, normalize?: function(string): string}} options
   * @return {boolean}
   */
  hasTagSoleAttributeActive(tagName, options = {})
  {
    let fieldKey = options.fieldKey
    if (!fieldKey) {
      return false
    }
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    return this._configurationManager.hasTagSoleAttribute(fieldKey, normalize(tagName))
  }

  /**
   * @param {string} tagName
   * @param {{
   *   kind: 'ignore'|'blacklist'|'explore',
   *   fieldKey: string,
   *   normalize?: function(string): string,
   *   ensureOptionKey?: string,
   *   tag?: {name: string, type?: string|null},
   *   className?: string,
   *   iconClass?: string,
   *   onAfterToggle?: function(): void,
   * }} options
   * @return {JQuery}
   */
  createTagSoleAttributeActionButton(tagName, options = {})
  {
    let kind = options.kind
    let normalize = options.normalize
    let fieldKey = options.fieldKey
    let active = this.hasTagSoleAttributeActive(tagName, {fieldKey, normalize})
    let titles = {
      ignore: active ? 'Stop ignoring in filenames' : 'Ignore in filenames',
      blacklist: active ? 'Stop hiding posts with this tag' : 'Hide posts with the tag',
      explore: active ? 'Remove tag from explore list' : 'Add tag to explore list',
    }
    let iconClass = options.iconClass ?? 'bv-tag-action-icon'
    let icon = kind === 'ignore' ? BrazenViewLayer.createTagIgnoreIcon(active, iconClass) :
        kind === 'explore' ? BrazenViewLayer.createTagExploreIcon(active, iconClass) :
            BrazenViewLayer.createTagBlacklistIcon(active, iconClass)
    let pending = false
    return BrazenViewLayer.createTagAttributeActionButton({
      title: titles[kind] ?? '',
      icon,
      className: options.className ?? 'bv-tag-action-btn',
      onClick: () => {
        if (pending) {
          return
        }
        pending = true
        // toggleTagRule paints cache + notifies before the first await; refresh chrome now.
        let persist = this.toggleTagSoleAttribute(tagName, {
          fieldKey,
          normalize,
          ensureOptionKey: options.ensureOptionKey,
          tag: options.tag ?? {name: tagName},
        })
        options.onAfterToggle?.()
        void persist.catch(() => options.onAfterToggle?.()).finally(() => {
          pending = false
        })
      },
    })
  }

  /**
   * @param {string} tagName
   * @param {{
   *   buildUrl: function(string): string,
   *   formatLabel?: function(string): string,
   *   normalizeUrl?: function(string): string,
   *   fieldKey?: string,
   *   normalize?: function(string): string,
   * }} options
   * @return {boolean}
   */
  isTagSearchBookmarked(tagName, options = {})
  {
    let fieldKey = options.fieldKey ?? 'bookmarks'
    let field = this._configurationManager.getField(fieldKey)
    if (!field?.widget || !options.buildUrl) {
      return false
    }
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let url = options.buildUrl(normalize(tagName))
    return !!field.widget.isCurrentPageBookmarked(url)
  }

  /**
   * Toggle bookmark for a single-tag search URL supplied by the consumer.
   * @param {string} tagName
   * @param {{
   *   buildUrl: function(string): string,
   *   formatLabel?: function(string): string,
   *   normalizeUrl?: function(string): string,
   *   fieldKey?: string,
   *   normalize?: function(string): string,
   * }} options
   * @return {Promise<void>}
   */
  async toggleTagSearchBookmark(tagName, options = {})
  {
    let fieldKey = options.fieldKey ?? 'bookmarks'
    let field = this._configurationManager.getField(fieldKey)
    if (!field || !options.buildUrl) {
      return
    }
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let normalizeUrl = options.normalizeUrl ?? ((value) => String(value ?? '').trim())
    let formatLabel = options.formatLabel ?? ((tags) => String(tags).replaceAll('_', ' '))
    let tags = normalize(tagName)
    let url = options.buildUrl(tags)
    let persistKey = `${fieldKey}\0url:${normalizeUrl(url)}`
    if (this._tagAttributePersistKeys.has(persistKey)) {
      return
    }
    this._tagAttributePersistKeys.add(persistKey)
    let rows = Array.isArray(field._bookmarkRows) ? field._bookmarkRows :
        (Array.isArray(field.value) ? field.value : [])
    let bookmarked = field.widget?.isCurrentPageBookmarked(url) ||
        rows.some((entry) => normalizeUrl(entry.url) === normalizeUrl(url))
    let previousRows = rows.slice()
    try {
      if (bookmarked) {
        let bookmark = rows.find((entry) => normalizeUrl(entry.url) === normalizeUrl(url))
        if (!bookmark) {
          return
        }
        let entryId = bookmark.entryId ?? bookmark.id
        // Optimistic remove for star / isCurrentPageBookmarked readers.
        let nextRows = rows.filter((entry) => (entry.entryId ?? entry.id) !== entryId)
        field._bookmarkRows = nextRows
        if (!this._configurationManager.isStorageReady()) {
          field.value = nextRows
        }
        field.widget?.render(nextRows)
        if (this._configurationManager.isStorageReady()) {
          await this._configurationManager.getRepos().bookmarks.remove(Number(entryId))
          await field.reload?.()
          field.widget?.render(field._bookmarkRows ?? [])
          return
        }
        await field.save()
        field.widget?.render(field.value)
        return
      }
      if (rows.some((bookmark) => normalizeUrl(bookmark.url) === normalizeUrl(url))) {
        return
      }
      let optimistic = {
        entryId: null,
        id: typeof crypto !== 'undefined' && crypto.randomUUID ?
            crypto.randomUUID() :
            String(Date.now()) + Math.random(),
        label: formatLabel(tags),
        tags,
        url,
        sortOrder: 0,
        _optimistic: true,
      }
      let nextRows = [optimistic, ...rows]
      field._bookmarkRows = nextRows
      if (!this._configurationManager.isStorageReady()) {
        field.value = nextRows
      }
      field.widget?.render(nextRows)
      if (this._configurationManager.isStorageReady()) {
        await this._configurationManager.getRepos().bookmarks.add({
          label: formatLabel(tags),
          tags,
          url,
          sortOrder: 0,
        })
        await field.reload?.()
        field.widget?.render(field._bookmarkRows ?? [])
        return
      }
      await field.save()
      field.widget?.render(field.value)
    } catch (error) {
      field._bookmarkRows = previousRows
      if (!this._configurationManager.isStorageReady()) {
        field.value = previousRows
      }
      field.widget?.render(previousRows)
      throw error
    } finally {
      this._tagAttributePersistKeys.delete(persistKey)
    }
  }

  /**
   * @param {string} tagName
   * @param {{
   *   buildUrl: function(string): string,
   *   formatLabel?: function(string): string,
   *   normalizeUrl?: function(string): string,
   *   fieldKey?: string,
   *   normalize?: function(string): string,
   *   className?: string,
   *   iconClass?: string,
   *   onAfterToggle?: function(): void,
   * }} options
   * @return {JQuery|null}
   */
  createTagBookmarkActionButton(tagName, options = {})
  {
    let fieldKey = options.fieldKey ?? 'bookmarks'
    if (!this._configurationManager.hasField(fieldKey) || !options.buildUrl) {
      return null
    }
    let bookmarked = this.isTagSearchBookmarked(tagName, options)
    return BrazenViewLayer.createTagAttributeActionButton({
      title: bookmarked ? 'Remove bookmark' : 'Bookmark',
      icon: BrazenViewLayer.createTagBookmarkStarIcon(bookmarked, options.iconClass ?? 'bv-tag-action-icon'),
      className: options.className ?? 'bv-tag-action-btn',
      onClick: () => {
        // Optimistic row mutate + widget.render run sync before the first await.
        let persist = this.toggleTagSearchBookmark(tagName, options)
        options.onAfterToggle?.()
        void persist.catch(() => options.onAfterToggle?.())
      },
    })
  }

  /**
   * Append framework-owned tag attribute actions (bookmark, ignore, substitute, blacklist, explore).
   * Consumer supplies incidence options (`buildUrl`, field keys, CSS classes, feature gates).
   * @param {HTMLElement|JQuery} actionsElement
   * @param {{name: string, type?: string|null}} tag
   * @param {{
   *   className?: string,
   *   iconClass?: string,
   *   ignoreClassName?: string,
   *   normalize?: function(string): string,
   *   bookmark?: {buildUrl: function(string): string, formatLabel?: function(string): string, normalizeUrl?: function(string): string, fieldKey?: string, iconClass?: string}|false,
   *   ignore?: {fieldKey?: string}|false,
   *   substitute?: {fieldKey?: string}|false,
   *   blacklist?: {fieldKey?: string, ensureOptionKey?: string}|false,
   *   explore?: {fieldKey?: string, ensureOptionKey?: string, isEnabled?: function(): boolean}|false,
   *   onAfterToggle?: function(): void,
   * }} [config]
   */
  appendTagAttributeActions(actionsElement, tag, config = {})
  {
    let $actions = $(actionsElement)
    if (!$actions.length || !tag?.name) {
      return
    }
    let normalize = config.normalize ?? ((value) => String(value ?? '').trim())
    let className = config.className ?? 'bv-tag-action-btn'
    let iconClass = config.iconClass ?? 'bv-tag-action-icon'
    let after = config.onAfterToggle
    let buttons = []

    if (config.bookmark !== false && config.bookmark?.buildUrl) {
      let button = this.createTagBookmarkActionButton(tag.name, {
        ...config.bookmark,
        normalize,
        className,
        iconClass: config.bookmark.iconClass ?? iconClass,
        onAfterToggle: after,
      })
      if (button) {
        buttons.push(button)
      }
    }

    let substituted = this._configurationManager.hasTagSubstitutionSubject(
        normalize(tag.name), config.substitute?.fieldKey ?? 'filename-tag-substitutions')

    if (config.ignore !== false) {
      let ignoreKey = config.ignore?.fieldKey ?? 'filename-tag-ignore-list'
      if (this._configurationManager.hasField(ignoreKey) && !substituted) {
        buttons.push(this.createTagSoleAttributeActionButton(tag.name, {
          kind: 'ignore',
          fieldKey: ignoreKey,
          normalize,
          tag,
          className: config.ignoreClassName ?? className,
          iconClass,
          onAfterToggle: after,
        }))
      }
    }

    if (config.substitute !== false) {
      let subKey = config.substitute?.fieldKey ?? 'filename-tag-substitutions'
      if (this.hasTagSubstitutionField(subKey)) {
        buttons.push(this.createTagSubstitutionActionButton(tag.name, {
          fieldKey: subKey,
          normalize,
          tag,
          className,
          iconClass,
          onAfterToggle: after,
        }))
      }
    }

    if (config.blacklist !== false) {
      let blacklistKey = config.blacklist?.fieldKey ?? FILTER_TAG_BLACKLIST
      if (this._configurationManager.hasField(blacklistKey)) {
        buttons.push(this.createTagSoleAttributeActionButton(tag.name, {
          kind: 'blacklist',
          fieldKey: blacklistKey,
          ensureOptionKey: config.blacklist?.ensureOptionKey,
          normalize,
          tag,
          className,
          iconClass,
          onAfterToggle: after,
        }))
      }
    }

    if (config.explore !== false) {
      let exploreKey = config.explore?.fieldKey ?? 'explored-tags-tracker'
      let exploreEnabled = config.explore?.isEnabled ? config.explore.isEnabled() : this._configurationManager.hasField(exploreKey)
      if (exploreEnabled && this._configurationManager.hasField(exploreKey)) {
        buttons.push(this.createTagSoleAttributeActionButton(tag.name, {
          kind: 'explore',
          fieldKey: exploreKey,
          ensureOptionKey: config.explore?.ensureOptionKey,
          normalize,
          tag,
          className,
          iconClass,
          onAfterToggle: after,
        }))
      }
    }

    $actions.append(buttons)
  }

  /**
   * @param {string|string[]} names
   * @param {Function} operation Return truthy or `{haltInit: true}` to skip full init.
   * @return {BrazenFramework}
   */
  addPageOperation(names, operation)
  {
    let pageNames = Array.isArray(names) ? names : [names]
    this._pageOperations.push({pages: pageNames, operation})
    return this
  }

  /**
   * @return {BrazenFramework}
   */
  enableCompliance()
  {
    this._complianceEnabled = true
    return this
  }

  /**
   * @return {BrazenFramework}
   */
  disableCompliance()
  {
    this._complianceEnabled = false
    return this
  }

  /**
   * @return {boolean}
   */
  isComplianceEnabled()
  {
    return this._complianceEnabled
  }

  /**
   * @param {string[]} names Compliance runs only when one of these pages is active.
   * @return {BrazenFramework}
   */
  setCompliancePages(names)
  {
    this._compliancePages = names
    return this
  }

  /**
   * @returns {boolean}
   */
  isUserLoggedIn()
  {
    return this._config.isUserLoggedIn
  }

  registerHighlightStyleClass(styleClass)
  {
    this._highlightClasses += ' ' + styleClass
    return this
  }

  /**
   * @param {{
   *   orientations: string[],
   *   defaultOrientation?: string,
   *   scriptName?: string,
   *   showBranding?: boolean,
   *   onOpenMainPanel?: Function,
   * }} config
   * @return {BrazenFramework}
   */
  configureDock(config)
  {
    let validOrientations = ['left', 'right', 'bottom']
    let orientations = (config.orientations ?? []).filter((orientation) => validOrientations.includes(orientation))
    if (!orientations.length) {
      throw new Error('configureDock() requires at least one valid orientation: left, right, or bottom.')
    }

    let defaultOrientation = config.defaultOrientation ?? orientations[0]
    if (!orientations.includes(defaultOrientation)) {
      defaultOrientation = orientations[0]
    }

    this._dockConfig = {
      orientations,
      defaultOrientation,
      scriptName: config.scriptName ?? '',
      showBranding: config.showBranding === true,
      onOpenMainPanel: config.onOpenMainPanel,
    }
    this._dockOrientation = defaultOrientation

    this._configurationManager.setDockActive(true)
    this._configurationManager.setDockIncludeContext(this)
    // Dock flag clicks already persist via writeSetting(); do not call save() here —
    // save()'s update() re-reads stale settings-panel checkboxes and can revert the toggle,
    // which remounts CSS slide-outs open/closed repeatedly.

    if (orientations.length > 1) {
      this._configurationManager.addHeadlessSettingField(OPTION_DOCK_POSITION, defaultOrientation)
    }

    return this
  }

  /**
   * @return {BrazenDownloadManager|null}
   */
  getDownloadManager()
  {
    return this._downloadManager
  }

  /**
   * @param {object} config
   * @return {BrazenFramework}
   */
  configureDownloadManager(config)
  {
    if (!this._dockConfig) {
      throw new Error('configureDownloadManager() requires configureDock() to be called first.')
    }
    this._downloadManager = new BrazenDownloadManager(this, this._configurationManager, config)
    return this
  }

  /**
   * Phase-1 Cloudflare / challenge page: queue HI tabs show **Done — resume** on this
   * media page; standalone browsing shows **Done — reload**. Requires {@link configureDownloadManager}.
   * @param {{title?: string, message?: string, confirmLabel?: string}} [options]
   * @return {Promise<void>}
   */
  handleMediaCloudflarePage(options = {})
  {
    return this._downloadManager?.handleMediaCloudflarePage(options) ?? Promise.resolve()
  }

  /**
   * @param {{mediaElement: HTMLElement, removeMediaOnSuccess?: boolean, source?: string}} options
   * @return {Promise<void>}
   */
  downloadImmediate(options)
  {
    return this._downloadManager?.downloadImmediate(options) ?? Promise.resolve()
  }

  /**
   * @param {object} context
   * @return {Promise<boolean>}
   */
  enqueueDownload(context)
  {
    return this._downloadManager?.enqueueDownload(context) ?? Promise.resolve(false)
  }

  /**
   * @param {string} itemId
   * @return {Promise<void>}
   */
  dequeueDownload(itemId)
  {
    return this._downloadManager?.dequeueDownload(itemId) ?? Promise.resolve()
  }

  /**
   * @param {string} itemId
   * @return {Promise<boolean>}
   */
  isQueued(itemId)
  {
    return this._downloadManager?.isQueued(itemId) ?? Promise.resolve(false)
  }

  /**
   * @return {Promise<void>}
   */
  confirmTagDiscoveryMappings()
  {
    return this._downloadManager?.confirmTagDiscoveryMappings() ?? Promise.resolve()
  }

  /**
   * @return {Promise<void>}
   */
  skipTagDiscoveryInclusion()
  {
    return this._downloadManager?.skipTagDiscoveryInclusion() ?? Promise.resolve()
  }

  /**
   * @return {Promise<void>}
   */
  openTagDiscoveryMedia()
  {
    return this._downloadManager?.openTagDiscoveryMedia() ?? Promise.resolve()
  }

  /**
   * @return {Promise<void>}
   */
  toggleDownloadManagerPaused()
  {
    return this._downloadManager?.toggleDownloadManagerPaused() ?? Promise.resolve()
  }

  /**
   * @return {Promise<void>}
   */
  clearDownloadQueue()
  {
    return this._downloadManager?.clearDownloadQueue() ?? Promise.resolve()
  }

  /**
   * Wipe all download-ledger ids from IndexedDB and the in-memory Set.
   *
   * @return {Promise<void>}
   */
  clearDownloadDuplicateLedger()
  {
    return this._configurationManager.clearDownloadLedger()
  }

  /**
   * Wipe this script's entire IndexedDB database. Reloads afterward so setup can rebuild it.
   * Prefer calling from a Toolbox confirm button.
   *
   * @return {Promise<void>}
   */
  async clearScriptDatabase()
  {
    let wipeFlagKey = this._config.scriptPrefix + 'pending-idb-wipe'
    try {
      sessionStorage.setItem(wipeFlagKey, '1')
    } catch (e) {
    }
    try {
      await this._configurationManager.clearScriptDatabase()
      try {
        sessionStorage.removeItem(wipeFlagKey)
      } catch (e) {
      }
    } catch (error) {
      // Keep the flag so initialize() retries the wipe before open on the next load.
      console.warn('[Brazen] clearScriptDatabase will retry on reload:', error)
    }
    location.reload()
  }

  /**
   * @return {Promise<{resolution: {current: number, total: number}, download: {current: number, total: number}}>}
   */
  getDownloadManagerProgress()
  {
    return this._downloadManager?.getDownloadManagerProgress() ?? Promise.resolve({
      resolution: {current: 0, total: 0},
      download: {current: 0, total: 0},
    })
  }

  /**
   * @return {Promise<number>}
   */
  getDownloadQueueCount()
  {
    return this._downloadManager?.getDownloadQueueCount() ?? Promise.resolve(0)
  }

  /**
   * @return {Promise<void>}
   */
  toggleTagDiscoveryMode()
  {
    return this._downloadManager?.toggleTagDiscoveryMode() ?? Promise.resolve()
  }

  /**
   * @return {void}
   */
  toggleSelectionMode()
  {
    this._downloadManager?.toggleSelectionMode()
  }

  /**
   * @return {Promise<void>}
   */
  toggleCurrentMediaQueued()
  {
    return this._downloadManager?.toggleCurrentMediaQueued() ?? Promise.resolve()
  }

  /**
   * @param {string} role
   * @return {boolean}
   */
  isDownloadPageRole(role)
  {
    return this._downloadManager?.isDownloadPageRole(role) ?? false
  }

  /**
   * @return {boolean}
   */
  isDownloadManagerEnabled()
  {
    return this._downloadManager?.isDownloadManagerEnabled() ?? false
  }

  /**
   * @return {boolean}
   */
  isDownloadManagerLeaderTab()
  {
    return this._downloadManager?.isDownloadManagerLeaderTab() ?? false
  }
}