Brazen Framework - Framework

Main class of the Brazen user scripts framework

Mint 2026.07.22.. Lásd a legutóbbi verzió

Ezt a szkriptet nem ajánlott közvetlenül telepíteni. Ez egy könyvtár más szkriptek számára, amik tartalmazzák a // @require https://update.sleazyfork.org/scripts/416105/1882641/Brazen%20Framework%20-%20Framework.js hivatkozást.

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Brazen Framework - Framework
// @namespace    brazenvoid
// @version      13.0.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-13.0.0'

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_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: string, itemDeepAnalysisSelector: string, requestDelay: number,
   *            onDeepAttributesResolution: Function}} ItemAttributesResolverConfiguration
   */

  /**
   * @callback ItemAttributesResolverCallback
   * @param {HTMLElement} 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 {HTMLElement}
   * @private
   */
  _sandbox

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

  /**
   * @param {ItemAttributesResolverConfiguration} configuration
   */
  constructor(configuration)
  {
    this._itemLinkSelector = configuration.itemLinkSelector
    this._itemDeepAnalysisSelector = configuration.itemDeepAnalysisSelector
    this._onDeepAttributesResolution = configuration.onDeepAttributesResolution
    this._requestDelay = configuration.requestDelay
    this._sandbox = Utilities.makeEl('div', {
      attrs: {id: 'brazen-item-attributes-resolver-sandbox'},
      hidden: true,
    })
    document.body.appendChild(this._sandbox)
  }

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

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

  /**
   * @param {HTMLElement} item
   * @param {Object} attributesBag
   * @private
   */
  _loadDeepAttributes(item, attributesBag)
  {
    let linkEl = item.querySelector(this._itemLinkSelector)
    let url = linkEl?.getAttribute('href')
    if (url) {
      Utilities.sleep(this._requestIteration * this._requestDelay).then(async () => {
        try {
          let response = await fetch(url, {credentials: 'include'})
          let html = await response.text()
          let doc = new DOMParser().parseFromString(html, 'text/html')
          let deepRoot = doc.querySelector(this._itemDeepAnalysisSelector)
          for (const attributeName in this._deepAttributes) {
            attributesBag[attributeName] = this._deepAttributes[attributeName](deepRoot)
          }
          this._onDeepAttributesResolution(item)
          this._sandbox.replaceChildren()
        } 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 {HTMLElement} item
   * @param {string} attribute
   * @returns {*}
   */
  get(item, attribute)
  {
    let attributesBag = item.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 {HTMLElement} item
   * @param {Function|null} afterResolutionCallback
   */
  resolveAttributes(item, afterResolutionCallback = null)
  {
    let attributesBag = {}
    item.scriptAttributes = attributesBag

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

  /**
   * @param {HTMLElement} item
   * @param {string} attribute
   * @param {*} value
   * @returns {BrazenItemAttributesResolver}
   */
  set(item, attribute, value)
  {
    item.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?: HTMLElement, styleClass: string, rows?: int, helpText: string, removeClasses?: string,
   *            formatter?: Function}} ItemTagHighlightsConfiguration
   */

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

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

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

  /**
   * @typedef {{doItemCompliance?: Function, downloadDuplicateLedger?: DownloadDuplicateLedgerConfiguration, downloadsDelay?: int,
   *            isUserLoggedIn?: boolean, itemDeepAnalysisSelector?: string,
   *            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 {HTMLElement} item
   * @param {*} configValues
   * @return {*}
   */

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

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

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

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

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

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

  /**
   * @callback SearchEnhancerDownloadLedgerIdCallback
   * @param {*} item - DOM 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 {HTMLElement}
   * @private
   */
  _subscriptionsLoaderButton = null

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

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

  /**
   * @type {HTMLElement|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

  /**
   * AbortController for dock resize listener (aborted on dock rebuild / pagehide).
   * @type {AbortController|null}
   * @private
   */
  _dockResizeAbort = null

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

  /**
   * Debounce timer for coalesced `_onAfterComplianceRun` after deep attribute loads.
   * @type {number|null}
   * @private
   */
  _afterComplianceRunTimer = null

  /**
   * AbortController for `#bv-ui` mouseenter/mouseleave hide scheduling.
   * @type {AbortController|null}
   * @private
   */
  _mainPanelUiAbort = null

  /**
   * Pending hide timer for `#bv-ui` mouseleave.
   * @type {number|null}
   * @private
   */
  _mainPanelHideTimer = null

  /**
   * Whether pagehide teardown for observers / UI timers has been wired.
   * @type {boolean}
   * @private
   */
  _frameworkUnloadWired = false

  /**
   * Cached subscription string used to invalidate per-username regexes.
   * @type {string|null}
   * @private
   */
  _subscriptionsFilterSource = null

  /**
   * @type {Map<string, RegExp>}
   * @private
   */
  _subscriptionsFilterRegexCache = new Map()

  /**
   * Debounce timer for dock panel position sync.
   * @type {number|null}
   * @private
   */
  _dockPanelSyncTimer = null

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

  /**
   * Names already ensureNames'd for bookmark-row attribute status (keyed by attemptedKey).
   * @type {Map<string, Set<string>>}
   * @private
   */
  _bookmarkRowAttributePrefetchAttempted = new Map()

  /**
   * @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(HTMLElement)[]}
   * @protected
   */
  _onFirstHitAfterCompliance = []

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

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

  /**
   * Logic to show the compliant search item
   * @type {Function[]}
   * @param {HTMLElement} 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 {HTMLElement[]}
   * @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)
        // Coalesce deep-resolve completions into one after-run (not once per item).
        this._scheduleAfterComplianceRun()
      },
    })

    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._onItemHide = (item) => {
      let wrapper = this._config.itemWrapperResolver(item)
      wrapper.classList.add(CLASS_NON_COMPLIANT_ITEM)
      wrapper.classList.remove(CLASS_COMPLIANT_ITEM)
      Utilities.setHidden(wrapper, true)
    }

    this._onItemShow.push((item) => {
      let wrapper = this._config.itemWrapperResolver(item)
      wrapper.classList.add(CLASS_COMPLIANT_ITEM)
      wrapper.classList.remove(CLASS_NON_COMPLIANT_ITEM)
      Utilities.setHidden(wrapper, false)
    })

    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)
  {
    return Utilities.decodeHtmlEntities(text)
  }

  /**
   * 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 = {})
  {
    this._domElementWatches ??= new Map()
    // Dedupe concurrent watches for the same selector.
    this._domElementWatches.get(selector)?.abort()

    let pollMs = options.pollMs ?? 150
    let timeoutMs = options.timeoutMs ?? 30000
    let observeRoot = options.observeRoot ?? document.documentElement
    let controller = new AbortController()
    this._domElementWatches.set(selector, controller)
    let resolved = false
    let observer = null
    let poll = null
    let timeout = null

    let cleanup = () => {
      observer?.disconnect()
      observer = null
      if (poll) {
        clearInterval(poll)
        poll = null
      }
      if (timeout) {
        clearTimeout(timeout)
        timeout = null
      }
      if (this._domElementWatches?.get(selector) === controller) {
        this._domElementWatches.delete(selector)
      }
    }

    let abort = () => {
      if (!controller.signal.aborted) {
        controller.abort()
      }
      cleanup()
    }

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

    controller.signal.addEventListener('abort', cleanup, {once: true})
    attempt()
    if (resolved) {
      return {abort}
    }

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

  /**
   * @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 {string|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.querySelector(durationNodeSelector)
        if (durationNode) {
          duration = durationNode.textContent.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 {string} 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.querySelector(ratingNodeSelector)
      return rating ? Number.parseInt(rating.textContent.trim().replace('%', '')) : null
    })

    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 {HTMLElement} 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.querySelectorAll(rule.join(', '))

          for (let tagSelector of rule) {

            if (Validator.isChildMissing(section, tagSelector)) {
              ruleApplies = false
              break
            }
          }
          if (ruleApplies) {
            for (let el of subjectTags) {
              el.classList.add(config.styleClass)
              if (config.removeClasses !== undefined) {
                for (let cls of String(config.removeClasses).split(/\s+/)) {
                  if (cls) {
                    el.classList.remove(cls)
                  }
                }
              }
            }
          } else {
            for (let el of subjectTags) {
              el.classList.remove(config.styleClass)
            }
          }
        }
      }
    }
    if (config.otherTagSectionsSelector) {
      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
          }
          let subscriptions = this._getConfig(STORE_SUBSCRIPTIONS) ?? ''
          if (this._subscriptionsFilterSource !== subscriptions) {
            this._subscriptionsFilterSource = subscriptions
            this._subscriptionsFilterRegexCache = new Map()
          }
          let escaped = String(username).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
          let regex = this._subscriptionsFilterRegexCache.get(escaped)
          if (!regex) {
            regex = new RegExp('"([^"]*' + escaped + '[^"]*)"')
            this._subscriptionsFilterRegexCache.set(escaped, regex)
          }
          return !regex.test(subscriptions)
        })
  }

  /**
   * @param {HTMLElement} 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.style.opacity = 'unset'
  }

  /**
   * Collect item tiles from a list container or observer-added nodes.
   * @param {HTMLElement|NodeList|HTMLElement[]} itemsList
   * @param {boolean} fromObserver
   * @return {HTMLElement[]}
   * @private
   */
  _collectListItems(itemsList, fromObserver = false)
  {
    let selector = this._config.itemSelectors
    if (fromObserver) {
      let items = []
      let seen = new Set()
      let add = (el) => {
        if (el instanceof Element && !seen.has(el)) {
          seen.add(el)
          items.push(el)
        }
      }
      let nodes = itemsList instanceof NodeList || Array.isArray(itemsList) ? itemsList : [itemsList]
      for (let node of nodes) {
        if (!(node instanceof Element)) {
          continue
        }
        if (node.matches(selector)) {
          add(node)
        }
        for (let el of node.querySelectorAll(selector)) {
          add(el)
        }
      }
      return items
    }
    if (!(itemsList instanceof Element)) {
      return []
    }
    if (this._config.itemSelectionMethod === 'find') {
      return Array.from(itemsList.querySelectorAll(selector))
    }
    return Array.from(itemsList.children).filter((child) => child.matches(selector))
  }

  /**
   * 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 {HTMLElement|NodeList|HTMLElement[]} itemsList
   * @param {boolean} fromObserver
   * @return {Promise<void>}
   * @protected
   */
  async _complyItemsList(itemsList, fromObserver = false)
  {
    let items = this._collectListItems(itemsList, fromObserver)

    // Shallow attribute resolve before priming (tag lists / post ids).
    for (let item of items) {
      if (this._get(item, ITEM_PROCESSED_ONCE) === null) {
        if (this._sanitizationEnabled) {
          Validator.sanitizeTextNode(
              item.querySelector(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)

    for (let item of items) {
      item.style.opacity = '0.75'
    }
    for (let item of items) {
      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 {HTMLElement[]} 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 = []
      for (let item of items) {
        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 = []
    for (let item of items) {
      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 {HTMLElement[]}
   */
  _createPaginationControls()
  {
    return [
      this._configurationManager.createElement(CONFIG_PAGINATOR_THRESHOLD),
      this._configurationManager.createElement(CONFIG_PAGINATOR_LIMIT)]
  }

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

  /**
   * @protected
   * @return {HTMLElement}
   */
  _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 {HTMLElement}
   */
  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 {HTMLElement}
   */
  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 Utilities.appendChildren(this._uiGen.createTabPanel(tabName, !!options.isFirst), elements)
  }

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

  /**
   * @param {HTMLElement} UISection
   * @private
   */
  _embedUI(UISection)
  {
    if (!this._dockConfig) {
      throw new Error('configureDock() is required before UI embed.')
    }
    this._teardownMainPanelUiListeners()
    this._mainPanelUiAbort = new AbortController()
    let signal = this._mainPanelUiAbort.signal
    let panelNode = UISection
    const cancelScheduledHide = () => {
      if (this._mainPanelHideTimer != null) {
        clearTimeout(this._mainPanelHideTimer)
        this._mainPanelHideTimer = null
      }
    }
    if (panelNode?.addEventListener) {
      panelNode.addEventListener('mouseenter', cancelScheduledHide, {signal})
      panelNode.addEventListener('mouseleave', () => {
        if (!this._uiGen.isSettingsPaneBeingResized()) {
          cancelScheduledHide()
          this._mainPanelHideTimer = setTimeout(() => {
            this._mainPanelHideTimer = null
            if (!this._uiGen.isSettingsPaneBeingResized()) {
              this._hideMainPanel(panelNode)
            }
          }, 1000)
        }
      }, {signal})
    }
    this._uiGen.constructor.appendToBody(UISection)
    this._uiSection = UISection
    if (this._complianceRules) {
      this._complianceRulesPanel = this._uiGen.createComplianceRulesSlidePanel()
      this._wireComplianceRulesSlidePanel(this._complianceRulesPanel)
      this._uiGen.constructor.appendToBody(this._complianceRulesPanel)
    }
    this._buildDock(UISection)
    this._ensureFrameworkUnloadTeardown()
  }

  /**
   * @private
   */
  _teardownMainPanelUiListeners()
  {
    if (this._mainPanelHideTimer != null) {
      clearTimeout(this._mainPanelHideTimer)
      this._mainPanelHideTimer = null
    }
    this._mainPanelUiAbort?.abort()
    this._mainPanelUiAbort = null
  }

  /**
   * Coalesce deep-attribute completions into one after-compliance handler pass.
   * @private
   */
  _scheduleAfterComplianceRun()
  {
    if (this._afterComplianceRunTimer != null) {
      clearTimeout(this._afterComplianceRunTimer)
    }
    this._afterComplianceRunTimer = setTimeout(() => {
      this._afterComplianceRunTimer = null
      this._statistics.updateUI()
      Utilities.processEventHandlerQueue(this._onAfterComplianceRun)
    }, 100)
  }

  /**
   * Disconnect observers / UI timers on pagehide so SPA remounts and tab discards do not leak.
   * @private
   */
  _ensureFrameworkUnloadTeardown()
  {
    if (this._frameworkUnloadWired) {
      return
    }
    this._frameworkUnloadWired = true
    window.addEventListener('pagehide', () => {
      this._dockResizeAbort?.abort()
      this._dockResizeAbort = null
      this._disconnectItemListChildObservers()
      this._teardownMainPanelUiListeners()
      if (this._afterComplianceRunTimer != null) {
        clearTimeout(this._afterComplianceRunTimer)
        this._afterComplianceRunTimer = null
      }
      this._configurationManager?.disposeBookmarkFields?.()
    })
  }

  /**
   * @param {HTMLElement} [railBody]
   * @param {{layout?: boolean}} [options] Pass `{ layout: false }` to refresh button state only.
   * @return {BrazenFramework}
   */
  refreshDockInterface(railBody, options = {})
  {
    railBody = railBody ?? document.querySelector('.bv-dock .bv-dock-rail-body')
    this._configurationManager.setDockIncludeContext(this)
    // Bookmarks pageMatch / reloadBookmarkFields can fire during panel createElement and
    // init hydrate — before `_buildDock` mounts `.bv-dock-rail-body`. Skip layout until then.
    if (options.layout !== false) {
      if (!railBody) {
        return this
      }
      this.composeDockRail(railBody)
      this._dockRailMembershipSignature = this._configurationManager.getDockRailMembershipSignature()
    }
    this._configurationManager.refreshDockButtonStates()
    return this
  }

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

  /**
   * @param {HTMLElement} 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.querySelector('.bv-dock-rail-head')
    let railBody = dock.querySelector('.bv-dock-rail-body')
    let railFoot = dock.querySelector('.bv-dock-rail-foot')
    let openPanel = this._dockConfig.onOpenMainPanel ?? (() => this._toggleMainPanel(UISection))
    let mainBtn = this._uiGen.createDockButton({
      icon: 'menu',
      tooltip: 'Toggle Main UI',
      onClick: () => openPanel(),
    })
    mainBtn.classList.add('bv-dock-main-ui-btn')
    Utilities.appendChildren(railHead, [mainBtn, 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')) {
      let rulesBtn = this._uiGen.createDockButton({
        icon: 'rules',
        tooltip: 'Active hide rules — click to show',
        onClick: () => this._toggleComplianceRulesPanel(),
      })
      rulesBtn.classList.add('bv-dock-compliance-rules-btn')
      Utilities.appendChildren(railFoot, [
        this._uiGen.createDockSeparator(),
        rulesBtn,
      ])
    }

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

    this._uiGen.constructor.appendToBody(dock)
    this._configurationManager.refreshDockButtonStates()
    this._observeDockPanelSync(dock)
    let panel = UISection
    let wasVisible = panel && this._isDockSlidePanelVisible(panel)
    this._dockResizeAbort?.abort()
    this._dockResizeAbort = new AbortController()
    window.addEventListener('resize', () => this._syncDockPanelPosition(), {signal: this._dockResizeAbort.signal})
    requestAnimationFrame(() => {
      this._syncDockPanelPosition()
      if (wasVisible) {
        this._showMainPanel(panel)
      } else {
        this._hideMainPanel(panel, false)
      }
    })
  }

  _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()
  {
    this._dockResizeAbort?.abort()
    this._dockResizeAbort = null
    if (this._dockPanelResizeObserver) {
      this._dockPanelResizeObserver.disconnect()
      this._dockPanelResizeObserver = null
    }
    this._dockRailMembershipSignature = null
    for (let dockEl of document.querySelectorAll('.bv-dock')) {
      dockEl.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 {HTMLElement} panel
   * @private
   */
  _wireComplianceRulesSlidePanel(panel)
  {
    BrazenViewLayer.wireDockSlidePanel(panel, {
      onClose: () => this._hideDockSlidePanel(panel),
      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
    }
    if (this._dockPanelSyncTimer != null) {
      clearTimeout(this._dockPanelSyncTimer)
    }
    this._dockPanelSyncTimer = setTimeout(() => {
      this._dockPanelSyncTimer = null
      this._syncDockPanelPositionNow()
    }, 16)
  }

  /**
   * Immediate dock panel stack sync (use from layout settle paths that already coalesce).
   * @protected
   */
  _syncDockPanelPositionNow()
  {
    if (!this._dockConfig || this._disableUI) {
      return
    }
    let dock = document.querySelector('.bv-dock')
    if (!dock) {
      return
    }
    BrazenViewLayer.syncDockSlidePanelStack(dock, this._getDockOrientation(), [
      document.getElementById('bv-download-interruption-panel'),
      document.getElementById('bv-human-interaction-panel-resolution'),
      document.getElementById('bv-human-interaction-panel-download'),
      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-download-interruption-panel',
      'bv-human-interaction-panel-resolution',
      'bv-human-interaction-panel-download',
      '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()
    // Dynamically created panes (HI / interruption) miss the dock-build orientation pass.
    panel.classList.remove('bv-dock-panel-left', 'bv-dock-panel-right', 'bv-dock-panel-bottom')
    panel.classList.add('bv-dock-panel-' + orientation)
    BrazenViewLayer.showDockSlidePanel(panel, orientation, () => this._syncDockPanelPositionNow())
    this._syncDockPanelPositionNow()
    // Post-layout pass: first sync can run mid open (offscreen) or before height settles.
    requestAnimationFrame(() => {
      this._syncDockPanelPositionNow()
      // Observe late-mounted panes so content/size changes re-stack.
      try {
        this._dockPanelResizeObserver?.observe(panel)
      } catch (_) { /* ignore */ }
    })
    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} UISection
   * @protected
   */
  _toggleMainPanel(UISection)
  {
    let panel = 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()
  }

  /**
   * @protected
   */
  _refreshDockButtonStates()
  {
    let railBody = document.querySelector('.bv-dock .bv-dock-rail-body')
    if (!railBody) {
      // Dock not mounted yet (UI build / init hydrate before `_buildDock`).
      return
    }
    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
      // Ledger-only: re-comply visible lists without resetting all statistics.
      void this._runComplianceValidation(false)
    }, 400)
  }

  /**
   * @param {HTMLElement} 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) {
      return
    }
    this._uiGen.renderComplianceRulesPanelContent(this._complianceRulesPanel, this._getComplianceRuleReport(), {
      canRemoveRule: (filterKey, ruleLabel) => this._canRemoveComplianceRule(filterKey, ruleLabel),
      onRemoveRule: (filterKey, ruleLabel) => {
        void Promise.resolve(this._removeComplianceRule(filterKey, ruleLabel)).then(() => {
          // Tag-registry removes notify → validateCompliance (re-renders when visible).
          // Fallback refresh covers sync no-ops / non-persist paths.
          if (this._complianceRulesPanel && this._isDockSlidePanelVisible(this._complianceRulesPanel)) {
            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
   * @return {void|Promise<void>}
   * @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(document.querySelector('.bv-dock .bv-dock-rail-body'))
  }

  /**
   * @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()
  {
    let restoreInput = document.getElementById('restore-settings')
    void this._configurationManager.restore(new Response(restoreInput?.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.textContent = status
        },
        (subscriptions) => {
          this._configurationManager.getField(STORE_SUBSCRIPTIONS).value = subscriptions.length ? '"' +
              subscriptions.join('""') + '"' : ''
          this._configurationManager.save()
          let loaderBtn = document.getElementById('subscriptions-loader')
          if (loaderBtn) {
            loaderBtn.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.disabled = true
            this._subscriptionsLoader.run()
          } else {
            this._showNotLoggedInAlert()
          }
        })
    this._subscriptionsLoaderButton.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 = document.querySelectorAll(this._config.itemListSelectors)
    if (firstRun) {
      this._disconnectItemListChildObservers()
      for (let itemList of itemLists) {

        if (this._paginator && itemList.matches(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(itemList)
      }
    } else {
      this._statistics.reset()
      if (this._complianceRules) {
        this._complianceRules.reset()
      }
      for (let itemsList of itemLists) {
        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 {HTMLElement} 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()
  }

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

    await this._loadDockOrientationFromStorage()

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

    if (this._config.itemNameSelector !== '') {
      this._itemAttributesResolver.addAttribute(ITEM_NAME, (item) => {
        let nameNode = item.querySelector(this._config.itemNameSelector)
        return nameNode?.textContent?.trim() ?? ''
      })
    }

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

    Utilities.processEventHandlerQueue(this._onBeforeUIBuild)

    if (!this._disableUI) {

      this._embedUI(Utilities.appendChildren(this._uiGen.createSettingsSection(), this._userInterface))
      Utilities.processEventHandlerQueue(this._onAfterUIBuild)

      this._configurationManager.updateInterface()
    }

    // After the dock exists — early DM init must not flash/hide review panels or paint
    // progress onto a detached slot created before `_buildDock`.
    if (this._downloadManager) {
      await this._downloadManager.afterDockReady()
      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 {HTMLElement}
   */
  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 {HTMLElement}
   */
  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
        })
      },
    })
  }

  /**
   * Idempotent URL-keyed bookmark toggle (optimistic row mutate + widget render).
   * Shared by single-tag and current-search dock bookmark paths.
   * @param {{
   *   fieldKey?: string,
   *   url: string,
   *   tags?: string,
   *   formatLabel?: function(string): string,
   *   normalizeUrl?: function(string): string,
   *   requireTagsToAdd?: boolean,
   * }} options
   * @return {Promise<void>}
   * @private
   */
  async _toggleBookmarkAtUrl(options = {})
  {
    let fieldKey = options.fieldKey ?? 'bookmarks'
    let field = this._configurationManager.getField(fieldKey)
    let url = options.url
    if (!field || !url) {
      return
    }
    let normalizeUrl = options.normalizeUrl ?? ((value) => String(value ?? '').trim())
    let formatLabel = options.formatLabel ?? ((tags) => String(tags).replaceAll('_', ' '))
    let tags = String(options.tags ?? '').trim()
    let normalizedUrl = normalizeUrl(url)
    if (!normalizedUrl) {
      return
    }
    let persistKey = `${fieldKey}\0url:${normalizedUrl}`
    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) === normalizedUrl)
    let previousRows = rows.slice()
    try {
      if (bookmarked) {
        let bookmark = rows.find((entry) => normalizeUrl(entry.url) === normalizedUrl)
        if (!bookmark) {
          // Active in widget/state but missing from rows — refresh to clear stale active.
          if (this._configurationManager.isStorageReady()) {
            await field.reload?.()
            field.widget?.render(field._bookmarkRows ?? [])
          }
          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()) {
          let removeId = bookmark.entryId != null ? bookmark.entryId :
              (Number.isFinite(Number(entryId)) ? Number(entryId) : entryId)
          await this._configurationManager.getRepos().bookmarks.remove(removeId)
          await field.reload?.()
          field.widget?.render(field._bookmarkRows ?? [])
          return
        }
        await field.save()
        field.widget?.render(field.value)
        return
      }
      if (options.requireTagsToAdd !== false && !tags) {
        return
      }
      if (rows.some((bookmark) => normalizeUrl(bookmark.url) === normalizedUrl)) {
        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,
   * }} 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)
  }

  /**
   * Whether the current search page URL is bookmarked (normalized URL identity).
   * @param {{
   *   fieldKey?: string,
   *   getCurrentUrl?: function(): string,
   * }} [options]
   * @return {boolean}
   */
  isCurrentSearchBookmarked(options = {})
  {
    let fieldKey = options.fieldKey ?? 'bookmarks'
    let field = this._configurationManager.getField(fieldKey)
    if (!field?.widget) {
      return false
    }
    let getCurrentUrl = options.getCurrentUrl ?? (() => location.href)
    return !!field.widget.isCurrentPageBookmarked(getCurrentUrl())
  }

  /**
   * 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 = {})
  {
    if (!options.buildUrl) {
      return
    }
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let tags = normalize(tagName)
    return this._toggleBookmarkAtUrl({
      fieldKey: options.fieldKey ?? 'bookmarks',
      url: options.buildUrl(tags),
      tags,
      formatLabel: options.formatLabel,
      normalizeUrl: options.normalizeUrl,
      requireTagsToAdd: true,
    })
  }

  /**
   * Toggle bookmark for the current search page. Decision / add / remove share one
   * normalized URL identity (`normalizeUrl(getCurrentUrl())`); new rows store the raw
   * current URL so dock/pageMatch flip active immediately. Empty tags = silent no-op
   * when adding; duplicate URL = remove. No alerts.
   * @param {{
   *   fieldKey?: string,
   *   getCurrentUrl?: function(): string,
   *   getTags?: function(): string,
   *   formatLabel?: function(string): string,
   *   normalizeUrl?: function(string): string,
   * }} [options]
   * @return {Promise<void>}
   */
  async toggleCurrentSearchBookmark(options = {})
  {
    let getCurrentUrl = options.getCurrentUrl ?? (() => location.href)
    let getTags = options.getTags ?? (() => '')
    let url = getCurrentUrl()
    let tags = String(getTags() ?? '').trim()
    return this._toggleBookmarkAtUrl({
      fieldKey: options.fieldKey ?? 'bookmarks',
      url,
      tags,
      formatLabel: options.formatLabel,
      normalizeUrl: options.normalizeUrl,
      requireTagsToAdd: true,
    })
  }

  /**
   * Register a framework-owned dock bookmark action field (state/tooltip/click).
   * Consumer supplies site config only (`getTags`, URL helpers, `include`).
   * @param {string} actionKey
   * @param {{
   *   fieldKey?: string,
   *   title?: string,
   *   helpText?: string,
   *   getCurrentUrl?: function(): string,
   *   getTags?: function(): string,
   *   formatLabel?: function(string): string,
   *   normalizeUrl?: function(string): string,
   *   include?: function(): boolean,
   *   tooltipOn?: string,
   *   tooltipOff?: string,
   * }} [options]
   * @return {ConfigurationField}
   */
  registerCurrentSearchBookmarkDock(actionKey, options = {})
  {
    let bookmarkOptions = {
      fieldKey: options.fieldKey ?? 'bookmarks',
      getCurrentUrl: options.getCurrentUrl,
      getTags: options.getTags,
      formatLabel: options.formatLabel,
      normalizeUrl: options.normalizeUrl,
    }
    return this._configurationManager.addActionField(actionKey).
        setTitle(options.title ?? 'Bookmark Search').
        setHelpText(options.helpText ?? 'Save the current search tags as a bookmark.').
        setAction(() => {
          // Optimistic row mutate + widget.render run sync before the first await.
          let persist = this.toggleCurrentSearchBookmark(bookmarkOptions)
          this._refreshDockButtonStates()
          void persist.catch(() => this._refreshDockButtonStates())
          return persist
        }).
        setDockButton({
          icon: 'bookmark',
          tooltip: () => this.isCurrentSearchBookmarked(bookmarkOptions) ?
              (options.tooltipOn ?? 'Bookmarked — click to remove') :
              (options.tooltipOff ?? 'Bookmark this search'),
          getState: () => this.isCurrentSearchBookmarked(bookmarkOptions) ? 'bv-dock-btn-active' : '',
          include: options.include,
        })
  }

  /**
   * @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 {HTMLElement|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} 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 = {})
  {
    if (!actionsElement || !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,
        }))
      }
    }

    Utilities.appendChildren(actionsElement, buttons)
  }

  /**
   * Remediation-only blacklist/explore buttons for a bookmarks-panel row.
   * Not the full sidebar/discovery action set — shows status when the bookmark
   * already matches blacklist/explore (sole or combo), with one-click remove
   * for the single-tag sole case.
   *
   * @param {{tags?: string}} bookmark
   * @param {{
   *   tokenize: function(string): string[],
   *   normalize?: function(string): string,
   *   className?: string,
   *   iconClass?: string,
   *   blacklist?: {fieldKey?: string}|false,
   *   explore?: {fieldKey?: string}|false,
   *   onAfterToggle?: function(): void,
   * }} config
   * @return {HTMLElement[]}
   */
  createBookmarkRowAttributeActions(bookmark, config = {})
  {
    if (!bookmark || typeof config.tokenize !== 'function') {
      return []
    }
    let normalize = config.normalize ?? ((value) => String(value ?? '').trim())
    let className = config.className ?? 'bv-bookmark-action'
    let iconClass = config.iconClass ?? 'bv-tag-action-icon'
    let after = config.onAfterToggle
    let tokens = config.tokenize(bookmark.tags ?? '')
    if (!Array.isArray(tokens)) {
      return []
    }
    let tagNames = tokens.map((token) => normalize(token)).filter(Boolean)
    let actions = []

    if (config.blacklist !== false && tagNames.length) {
      let blacklistKey = config.blacklist?.fieldKey ?? FILTER_TAG_BLACKLIST
      if (this._configurationManager.hasField(blacklistKey)) {
        let verdict = this._configurationManager.evaluateTagCompliance(tagNames, blacklistKey)
        if (!verdict.complies) {
          let soleRemovable = tokens.length === 1 && this.hasTagSoleAttributeActive(tagNames[0], {
            fieldKey: blacklistKey,
            normalize,
          })
          if (soleRemovable) {
            let tag = tagNames[0]
            let pending = false
            actions.push(BrazenViewLayer.createTagAttributeActionButton({
              className,
              title: 'Remove tag from blacklist',
              icon: BrazenViewLayer.createTagBlacklistIcon(true, iconClass),
              onClick: () => {
                if (pending) {
                  return
                }
                pending = true
                let persist = this.toggleTagSoleAttribute(tag, {
                  fieldKey: blacklistKey,
                  normalize,
                  source: 'sidebar',
                })
                after?.()
                void persist.catch(() => after?.()).finally(() => {
                  pending = false
                })
              },
            }))
          } else {
            actions.push(BrazenViewLayer.createTagAttributeActionButton({
              className,
              title: 'Blacklisted by rule: ' + (verdict.rule ?? 'tag blacklist'),
              icon: BrazenViewLayer.createTagBlacklistIcon(true, iconClass),
              onClick: () => {},
            }))
          }
        }
      }
    }

    if (config.explore !== false && tokens.length === 1 && tagNames[0]) {
      let exploreKey = config.explore?.fieldKey ?? 'explored-tags-tracker'
      if (this._configurationManager.hasField(exploreKey) &&
          this.hasTagSoleAttributeActive(tagNames[0], {fieldKey: exploreKey, normalize})) {
        let tag = tagNames[0]
        let pending = false
        actions.push(BrazenViewLayer.createTagAttributeActionButton({
          className,
          title: 'Remove tag from explore list',
          icon: BrazenViewLayer.createTagExploreIcon(true, iconClass),
          onClick: () => {
            if (pending) {
              return
            }
            pending = true
            let persist = this.toggleTagSoleAttribute(tag, {
              fieldKey: exploreKey,
              normalize,
              source: 'sidebar',
            })
            after?.()
            void persist.catch(() => after?.()).finally(() => {
              pending = false
            })
          },
        }))
      }
    }

    return actions
  }

  /**
   * Prefetch tag cache entries for visible bookmark rows so attribute status
   * icons do not false-negative on cache misses. Self-heals via config.onReady
   * after ensureNames; the attempted set prevents re-render loops.
   *
   * @param {Array<{tags?: string}>} bookmarks
   * @param {{
   *   tokenize: function(string): string[],
   *   normalize?: function(string): string,
   *   onReady?: function(): void,
   *   attemptedKey?: string,
   * }} config
   * @return {Promise<void>}
   */
  async prepareBookmarkRowAttributeData(bookmarks, config = {})
  {
    let tagRuntime = this._configurationManager.getTagRuntime()
    if (!tagRuntime || typeof config.tokenize !== 'function' || !Array.isArray(bookmarks) || !bookmarks.length) {
      return
    }
    let normalize = config.normalize ?? ((value) => String(value ?? '').trim())
    let attemptedKey = config.attemptedKey ?? 'default'
    let attempted = this._bookmarkRowAttributePrefetchAttempted.get(attemptedKey)
    if (!attempted) {
      attempted = new Set()
      this._bookmarkRowAttributePrefetchAttempted.set(attemptedKey, attempted)
    }

    let names = []
    let seen = new Set()
    for (let bookmark of bookmarks) {
      let tokens = config.tokenize(bookmark?.tags ?? '')
      if (!Array.isArray(tokens)) {
        continue
      }
      for (let token of tokens) {
        let name = normalize(token)
        if (!name || seen.has(name)) {
          continue
        }
        seen.add(name)
        if (attempted.has(name)) {
          continue
        }
        if (this._configurationManager.getCachedTagEntry(name)) {
          continue
        }
        names.push(name)
      }
    }

    if (!names.length) {
      return
    }

    for (let name of names) {
      attempted.add(name)
    }
    try {
      await tagRuntime.ensureNames(names)
    } catch (_) {
      return
    }
    config.onReady?.()
  }

  /**
   * @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** /
   * **Done — resume — close tab** on this media page; standalone browsing shows
   * **Done — reload**. Requires {@link configureDownloadManager}.
   * @param {{title?: string, message?: string, confirmLabel?: string, confirmAndCloseLabel?: 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
  }

  /**
   * True when the active Download Manager page has the `dashboard` role — a dedicated
   * control / future-analytics host with page-oriented dock actions gated off.
   * Does not claim or prefer processor leadership; crown / sole-tab rules unchanged.
   * @return {boolean}
   */
  isDashboardPage()
  {
    return this.isDownloadPageRole('dashboard')
  }

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

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

  /**
   * Take download-manager processor leadership when idle.
   * @return {Promise<boolean>}
   */
  requestDownloadManagerLeadership()
  {
    return this._downloadManager?.requestDownloadManagerLeadership() ?? Promise.resolve(false)
  }
}